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:
KeelrLeek committed 2026-09-14 02:21:37 -05:00
1 parent 46cf6a98fe
commit 95c2bfeeba
13 files changed
+633 -14

No files matched your search

+120 -3
View File
@@ -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`));