android: the first run — a folder to keep music in, and an account to reach the network
Nobody had seen this app on a phone that has just installed it. On Android the library root is never missing — config.rs falls back to the app's own sandbox, a real folder nothing else can see — so "no folder yet" cannot be a null check, and setup_done has never been written by the phone, so every phone in existence reads as unstamped, Kyle's included. The tour therefore opens on three clauses, not one: no flag, no saved account, and no records, read only after the library and the credentials have both answered. Two cards, skippable, and no password anywhere in it — the Soulseek step goes to the card on Community and stops. Every screen that is empty because of those two things now says so with the row that fixes it: Home (whose copy predated the folder picker), Library → Albums, Library → Artists, which had no empty state at all, and Search, where a flat sentence about signing in became the row that does it. A row goes away because the config moved, so the two doors onto the same folder tell each other — You re-reads when the tour moves it, the tour's store is told when You's own picker does — and the all-files row re-reads the grant when the system screen hands the app back, the way You already did. You → This phone opens the tour again as a preview that reads the config and writes nothing, which is the door the phone walk drives on Kyle's own device. The mock can answer as a fresh install now — an empty database, no account, no permission, the sandbox root — so the whole thing is walked end to end, and two commands that had no mock at all (player_control, open_external) got one on the way past. It is the mock walk's first reload, which turned up two things a reload does not clear by itself: the player writes the session back on pagehide, so the fresh block clears it from a pagehide listener of its own, and the viewport emulation lives on the DevTools session, so the harness hands it back after the re-attach.
This commit is contained in:
1 parent
46cf6a98fe
commit
95c2bfeeba
13 files changed
+633
-14
No files matched your search
+120
-3
@@ -251,6 +251,23 @@ const PROBE = `(function () {
|
||||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||||
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||||
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||||
/* the first run: the sheet, the two cards it offers, and the state
|
||||
it read out of the config — "<folder>:<account>:<flag>".
|
||||
setupWrites is the number of times the store has stamped
|
||||
setup_done, and a PREVIEW must leave it where it found it: that
|
||||
is the whole proof that the door in You is not a door onto Kyle's
|
||||
own config, and the phone walk asserts it on his phone. */
|
||||
setupUp: !!document.querySelector('.halfsheet.up .setup'),
|
||||
setupCards: [].map.call(document.querySelectorAll('.halfsheet.up .setup .sheet-sec'), function (s) { return s.textContent.trim(); }).join('|'),
|
||||
setupState: (function () { try { return __ss.setupState(); } catch (e) { return ''; } })(),
|
||||
setupWrites: (function () { try { return __ss.setup.writes; } catch (e) { return -1; } })(),
|
||||
/* the note the blocked screen carries, and the rows it offers: a
|
||||
row goes away because the config changed, not because a flag was
|
||||
flipped, which is why the count is the thing worth reading */
|
||||
setupNotes: document.querySelectorAll('.screen .setup-note').length,
|
||||
setupRows: document.querySelectorAll('.screen .setup-note .row').length,
|
||||
setupNoteText: [].map.call(document.querySelectorAll('.screen .setup-note .row .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||
signIn: ((document.querySelector('.signin .signin-t') || {}).textContent || '').trim(),
|
||||
/* the boot census, latched at the shell's first frame and never
|
||||
re-opened: how many commands the app asked for before it had
|
||||
drawn anything, how many of those left in the shell's own turn,
|
||||
@@ -836,6 +853,50 @@ const MOCK_STEPS = [
|
||||
{ do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 },
|
||||
{ do: `__settle()`, wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 },
|
||||
/* ── the first run, on a device that has just been installed ──────
|
||||
The mock is the whole backend here, so it can answer as one: no
|
||||
records, no playlists, no account, no all-files permission, and the
|
||||
library root still the sandbox folder Android hands a fresh
|
||||
install. The flag rides in the URL fragment — a reload cannot lose
|
||||
that the way a file:// document's storage might — and in
|
||||
localStorage beside it, for a page opened by hand.
|
||||
|
||||
This block is LAST on purpose. The reload wipes every module's
|
||||
state (the mock's playlists, its contacts, its one fake transfer),
|
||||
so nothing the two hundred steps above built survives into it, and
|
||||
it puts the walk back on a provisioned device at the end so that
|
||||
nothing appended after it is quietly running against an empty one.
|
||||
|
||||
A reload is not a reinstall: the webview's own storage comes back
|
||||
with it, so the session the walk left behind is cleared by hand —
|
||||
a now-playing bar on a phone with nothing in it is the one piece
|
||||
of the pretence that would show in the stills. Removing the key is
|
||||
not enough on its own, and a `pagehide` listener is not either:
|
||||
the player writes the session on pagehide, on visibilitychange AND
|
||||
on a ten-second timer while a track plays (player.svelte.js), and a
|
||||
CDP-driven reload is not obliged to fire pagehide at all — so the
|
||||
first attempt at this step came back with the bar still up. The
|
||||
clearing therefore shuts the door instead of chasing the writers:
|
||||
setItem is taught to refuse that one key for the few hundred
|
||||
milliseconds this page has left, so nothing can put it back. */
|
||||
{ do: `(function () { var K = 'ss.mobile.session.v1'; var set = Storage.prototype.setItem; Storage.prototype.setItem = function (k) { if (k === K) return; return set.apply(this, arguments); }; try { localStorage.removeItem(K); } catch (e) {} localStorage.setItem('mockFresh', '1'); location.hash = 'fresh'; })(); true`, wait: 400 },
|
||||
{ do: `location.reload()`, name: "fresh-boot", wait: 4500 },
|
||||
/* the tour's Soulseek step navigates to the card and stops there —
|
||||
nothing in the walk types a username or a password */
|
||||
{ do: `__click('.halfsheet .row', 'Sign in on Community')`, name: "fresh-community", wait: 1400 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-home", wait: 1000 },
|
||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "fresh-albums", wait: 900 },
|
||||
/* the Artists tab had no empty state at all before this round */
|
||||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "fresh-artists", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "fresh-search", wait: 900 },
|
||||
/* the preview door: it reads the state and writes nothing */
|
||||
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
|
||||
{ do: `__click('.screen .row', 'Set up this phone again')`, name: "fresh-preview", wait: 1100 },
|
||||
{ do: `__click('.halfsheet .row', 'Choose a folder')`, name: "fresh-folder", wait: 2000 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
|
||||
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
|
||||
{ do: `location.reload()`, name: "fresh-off", wait: 4500 },
|
||||
];
|
||||
const MOCK_EXPECT = {
|
||||
tracks: (i) => i.title.startsWith("Your library"),
|
||||
@@ -1095,6 +1156,40 @@ const MOCK_EXPECT = {
|
||||
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
|
||||
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
|
||||
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
|
||||
/* ── the first run ────────────────────────────────────────────────
|
||||
Every one of these is on state that STAYS: a sheet that is up, a
|
||||
counter, a field in the config, a row that is present or absent.
|
||||
Nothing here waits on a notice or a picker — both windows are
|
||||
shorter than the step's own wait plus the screenshot. */
|
||||
"fresh-boot": (i) => i.setupUp && i.setupCards === "Where your music lives|Soulseek"
|
||||
&& i.setupState === "private:out:todo" && i.setupWrites === 0
|
||||
&& /^dialog\/true\/Welcome to SoulStream\/focus$/.test(i.dlg)
|
||||
/* and nothing of the walk before it came through the reload: a
|
||||
phone that has just been installed has no session to restore */
|
||||
&& !i.mini
|
||||
&& !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||||
/* the tour's own door to the sign-in, and the stamp that a tour which
|
||||
ran leaves behind whichever door closed it */
|
||||
"fresh-community": (i) => !i.setupUp && i.title.startsWith("Community")
|
||||
&& i.signIn === "Sign in to Soulseek" && i.setupWrites === 1 && i.setupState === "private:out:done",
|
||||
"fresh-home": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 2
|
||||
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
|
||||
"fresh-albums": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2
|
||||
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
|
||||
"fresh-artists": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2,
|
||||
"fresh-search": (i) => i.title.startsWith("Search") && i.setupNotes === 1 && i.setupRows === 2,
|
||||
/* the preview reads the real config and adds nothing to the counter */
|
||||
"fresh-preview": (i) => i.setupUp && i.setupWrites === 1 && i.setupCards === "Where your music lives|Soulseek",
|
||||
/* a folder picked from the tour: the config moved, the permission the
|
||||
new folder needs is offered, and still nothing was stamped */
|
||||
"fresh-folder": (i) => i.setupUp && i.setupState === "chosen:out:done" && i.setupWrites === 1
|
||||
&& /Allow all files access/.test(i.halfText),
|
||||
/* and the note loses the row it answered — because the config changed,
|
||||
not because a flag was flipped */
|
||||
"fresh-fixed": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 1
|
||||
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
|
||||
/* the provisioned device is back, and it is NOT offered a first run */
|
||||
"fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast,
|
||||
};
|
||||
|
||||
/* the phone walk: a record by name, played for real */
|
||||
@@ -1255,6 +1350,15 @@ const PHONE_STEPS = [
|
||||
nothing is written. It also catches a build where trouble.js simply
|
||||
did not ship: __ss.say would not be there and the line stays empty. */
|
||||
{ do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 },
|
||||
/* the first run's preview door, on Kyle's own phone. It READS his
|
||||
state — his folder, his account — and must write nothing at all: the
|
||||
walk never clears his config, never re-runs setup and never stamps
|
||||
the flag, and `setupWrites` staying at zero is what says so. Driven
|
||||
through __ss rather than the row in You so the step cannot miss it
|
||||
on a long screen, and closed by the scrim, which on a preview is the
|
||||
same no-op as the button. Nothing is typed and nothing is picked. */
|
||||
{ do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 },
|
||||
{ do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
|
||||
];
|
||||
const PHONE_EXPECT = {
|
||||
@@ -1348,6 +1452,13 @@ const PHONE_EXPECT = {
|
||||
"trouble-words": (i) => i.trouble.split(" || ").length === 11
|
||||
&& i.trouble.split(" || ").every(function (s) { return s.length > 12; })
|
||||
&& !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble),
|
||||
/* both cards on a phone that is fully set up, and the counter at zero
|
||||
on the way in and on the way out — the preview wrote nothing to his
|
||||
config. `setupWrites` would also be 1 if the boot gate had fired on
|
||||
his phone, which is the other thing this catches. */
|
||||
"setup-preview": (i) => i.setupUp && i.setupWrites === 0
|
||||
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
|
||||
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
|
||||
end: (i) => /^(Good |Still)/.test(i.title),
|
||||
};
|
||||
|
||||
@@ -1525,9 +1636,11 @@ async function main() {
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Log.enable");
|
||||
};
|
||||
/* the emulated phone, kept: a re-attach has to be handed it again */
|
||||
let metrics = { width: 411, height: 914, deviceScaleFactor: 2, mobile: true };
|
||||
if (!PHONE) {
|
||||
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
|
||||
await sleep(600);
|
||||
}
|
||||
@@ -1546,7 +1659,8 @@ async function main() {
|
||||
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
|
||||
else {
|
||||
try {
|
||||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true });
|
||||
metrics = { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true };
|
||||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
|
||||
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
|
||||
}
|
||||
@@ -1560,7 +1674,10 @@ async function main() {
|
||||
}
|
||||
await sleep(s.wait || 500);
|
||||
if (s.do === "location.reload()") {
|
||||
try { await reattach(); } catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
|
||||
try {
|
||||
await reattach();
|
||||
if (!PHONE) await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
} catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
|
||||
}
|
||||
if (!s.name) continue;
|
||||
await still(path.join(OUT, `${prefix}${s.name}.png`));
|
||||
|
||||
Reference in new issue
Block a user