diff --git a/ANDROID.md b/ANDROID.md
index 0471c10..72ad644 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -1131,6 +1131,111 @@ track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer
above moved it to the normal mixer; the phone walk samples the underrun
counters before and after and fails if they rose.
+### The long session
+
+Every walk is minutes long; a phone runs the app for hours, and nothing in
+the port had ever been looked at for a leak. It has now, by driving the real
+bundle in headless Chrome for lap after lap of the whole app — five tabs,
+the Library, a record, playback, the mini bar, all three faces of the sheet,
+the artwork viewer, a row's half sheet, an artist page — and counting what
+the app was still holding at the start and at the end.
+
+**Listeners and timers do not leak, and the obvious counter says they do.**
+With a registry installed at document start — an investigation, not what
+ships — add minus remove climbs 23 → 119 over eight laps, about twelve a
+lap, almost all of them `scroll`. None of it is a leak: `{#key nav.seq}`
+replaces the `.screen` element on every navigation, so each new screen gets
+its own handler and the old element is dropped whole. Counting only what is
+still ATTACHED — `window`, `document`, or an element that is still
+connected — gives 29 at the first lap and 29 at the eighth, key for key.
+Intervals are the same story: two at boot (the media card's 30 s, the
+player's 10 s session writer), three from the first lap on, and three at the
+last; `Lane` and `Diagnostics` both clear theirs in their effect teardown,
+and every `bind*()` in `src/mobile/` is guarded by a module-level `bound`
+flag, so no Tauri subscription is ever registered twice.
+
+**What the walk ships is narrower than that, and says so.** A script
+registered with `Page.addScriptToEvaluateOnNewDocument` does not survive the
+first-run block's reloads — the re-attach opens a new DevTools session — so
+the registry the walk installs goes in as a STEP, after everything else, and
+can only see what is registered from that moment on. Its absolute counts are
+therefore small and depend on how much ran first: `tallyEars` reads 2 at the
+end of the full mock walk and 8 from a settled boot, `tallyTicks` 1 in both
+(the mock's own playback ticker). Those numbers are not a census of the app
+and are not asserted as one. What is asserted is the DELTA across the laps,
+which is where a per-navigation leak shows, and that was proved by planting
+one: a listener and an interval stranded once a lap take `grewEars` 0 → 9
+and `grewTicks` 0 → 9, and the step fails.
+
+**The thumbnail map did leak, and is bounded now.** `src/mobile/art.svelte.js`
+kept every cover it had ever fetched for the WebView's lifetime: one browse
+of a 114-cover library took it from 5 keys to 114 and gave nothing back, and
+a catalog crawl adds to the same map without limit. A 360 px cover comes
+back from `album_art` as base64 JPEG — about 63 KB of string, measured
+against this machine's own thumbnail cache — so that browse alone is roughly
+7 MB retained. The bound is the desktop's, by its own numbers:
+`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB from
+`src/lib/state.svelte.js`, trimmed oldest first. Two rules the desktop does
+not need are added, because the desktop's callers ask through `getArt(path)`
+while the phone's templates INDEX the map (`art[path]` in Cover, Release,
+Sheet, Viewer, Artist, Discography, Home and the media card): the entry that
+has just arrived is never dropped, and neither is one a connected `
` is
+painting — `Cover`'s `visible` action re-arms only when its path PROP
+changes, so an evicted cover that is still mounted would stay a disc icon
+for good. The few places that paint a cover as a `background-image` rather
+than an `
` — the ground, the sheet's wash, the artist hero — are
+covered twice over: each of them reads `art[path]` inside an effect that
+calls `wantArt`, so an eviction under one re-fetches on its own. The cache
+is therefore allowed to sit over its cap for as long as one screen needs all
+of it, and comes down the moment that screen goes. Built with the cap forced
+to 8 and scrolled through eighteen screens of a 140-card Library grid, the
+map sat at 61 — every one of them spared for being painted — and not one
+cover on screen lost its image at any point; the cards that had scrolled
+away repaint cold and fill as they come back, which is what a cold first
+visit already looks like. At the shipped cap of 512 a whole library walk
+runs the trim zero times and the census is identical either way: on Kyle's
+phone the change is inert, and it is the crawl past 512 covers that it
+exists for. The accent table gets the same cap — an accent is a pure
+function of its cover, so dropping one costs a recompute and nothing else.
+
+**Playlist covers.** `pls.covers` is keyed `id:revision`, and an edit bumps
+the revision, so every edit stranded one 360 px data URL under a key nothing
+could ever read again. The new cover now drops that playlist's other
+revisions as it lands — no cap and no policy, just the keys that are
+unreachable by construction.
+
+**Three things are unbounded on purpose.** `catalog.svelte.js`'s ten maps are
+cached for the session by design, and evicting from them is not safe the way
+the art map is: `Artist.svelte` calls `lookupArtist` inside `untrack`, so a
+page whose entry went missing under it would never look it up again.
+`lib.svelte.js`'s `tracksCache` is unbounded but finite — it is bounded by
+the library. And `player.svelte.js`'s `scheduleRecents` leaves a 47-second
+timeout per track start, which self-drains and is the desktop's own shape;
+coalescing the two callers into one slot would cancel one of them, so it is
+watched under a ceiling instead of changed.
+
+The walk measures all of it. Every step prints `tallyArt`, `tallyChars`,
+`tallyAcc`, `tallyPend`, `tallyCat`, `tallyQueue`, `tallyXfer` and
+`tallyNodes` from the app itself (`artTally()` on the `__ss` handle, because
+the map and its side tables are module-private), and `tallyEars`,
+`tallyTimers`, `tallyTicks` plus the `grew*` deltas from a registry the
+session block installs in the page. The registry is installed by a STEP, not
+by `Page.addScriptToEvaluateOnNewDocument`: the first-run block reloads three
+times and the re-attach opens a new DevTools session, so a script registered
+on the old one would be gone. The block's last step takes it out again —
+`EventTarget` and the four timer functions restored, `__ssCount` and the
+baseline deleted — so nothing appended after it inherits a patched platform,
+and every step after the block reads −1 for those fields, as does the whole
+phone walk, where it is never installed at all. The assertion is a threshold
+with headroom, not a stopwatch — the listener and interval counts are held
+flat, the art map is held under its cap, and `tallyTimers` is a ceiling
+rather than a delta because the recents window keeps a handful pending
+during a walk this fast. Across eleven laps of the block as it ships, driven
+against a real build of this tree, the listeners and the intervals moved by
+0, the timers stood at 9 against a ceiling of 32, the art map grew 17
+against a headroom of 80 and the catalog 5 against 24, and the DOM ended 10
+nodes smaller than it started.
+
## Prerequisites (home machine, done 2026-09-12)
- Android SDK at `%LOCALAPPDATA%\Android\Sdk`: command-line tools,
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index 07a653c..cf50b31 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -40,7 +40,7 @@
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
- import { art, wantArt, accentFor, applyAccent } from "./art.svelte.js";
+ import { art, artTally, wantArt, accentFor, applyAccent } from "./art.svelte.js";
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js";
@@ -317,7 +317,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
- window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
+ window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
diff --git a/src/mobile/art.svelte.js b/src/mobile/art.svelte.js
index 096e628..0e3f1a6 100644
--- a/src/mobile/art.svelte.js
+++ b/src/mobile/art.svelte.js
@@ -14,6 +14,79 @@ export const art = $state({});
export const DEFAULT_ACCENT = { accent: "#1f9ad6", hover: "#41b7ee" };
+/* ── how much of this the phone may hold ────────────────────────────────
+ The desktop bounds the identical map with these two numbers
+ (src/lib/state.svelte.js: ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS,
+ trimmed in insertion order by trimResolvedCache). Kept exactly as they
+ are rather than invented here: `album_art` answers with base64 JPEG of a
+ 360px thumbnail on both, so 512 covers is the same budget either side.
+ Until now nothing on the phone gave a byte of it back — one browse of a
+ 114-cover library retains every one of them for the WebView's lifetime,
+ and a catalog crawl adds to the same map without limit. */
+const ART_MAX = 512;
+const ART_MAX_CHARS = 16 * 1024 * 1024;
+
+/* The desktop may drop any cold entry, because every caller there asks
+ through getArt(path). The phone's templates INDEX this map — art[path] in
+ Cover, Release, Sheet, Viewer, Artist, Discography, Home and the media
+ card — so a key something is painting would blank on the spot, and
+ Cover's `visible` action re-arms only when its path PROP changes, so an
+ evicted cover that is still mounted would never come back at all. Hence
+ two rules the desktop does not need: never the entry that has just
+ landed, and never one that a connected
is showing. Oldest first
+ otherwise — Object.keys is insertion order here, because every key is a
+ file path, an https URL or a "stream:" string and never integer-like,
+ which would be hoisted to the front and go first. A remembered failure
+ ("" so it is not retried) is evictable like anything else: past the cap
+ a retry is the cheaper of the two.
+
+ The map is allowed to sit OVER its cap for as long as one screen needs
+ all of it — a mounted grid of 600 covers loses none of them — and it
+ comes down the moment that screen goes.
+
+ Called from pump() and nowhere else, which is never inside an $effect:
+ Object.keys on a $state proxy reads the version signal, so an effect
+ that called this would re-run on every cover that lands. The two
+ writers that do not go through pump() — loadArt's data: short-circuit
+ and a stream's tag art in player.svelte.js — are therefore bounded at
+ the next cover that does, which on any screen worth the name is the
+ next one. Under the cap
+ it is a key count and a char sum and returns; the pass over the screen's
+ images is built once, lazily, and only when there is something to drop,
+ because reading a base64 src is a copy of the whole data URL. */
+function trimArt(justAdded) {
+ const keys = Object.keys(art);
+ let chars = 0;
+ for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; }
+ let live = keys.length;
+ if (live <= ART_MAX && chars <= ART_MAX_CHARS) return;
+ let painted = null;
+ for (const k of keys) {
+ if (live <= ART_MAX && chars <= ART_MAX_CHARS) break;
+ if (k === justAdded) continue;
+ const v = art[k];
+ if (v) {
+ if (!painted) {
+ painted = new Set();
+ /* the attribute, not the .src property: for a data URL they
+ are the same string and the property re-serializes it.
+ The fallback is for an
whose src was set as a
+ property instead — none today, and a miss here is the one
+ way this could blank a cover. */
+ for (const img of document.images) {
+ if (!img.isConnected) continue;
+ const src = img.getAttribute("src") || img.src;
+ if (src) painted.add(src);
+ }
+ }
+ if (painted.has(v)) continue;
+ }
+ delete art[k];
+ if (typeof v === "string") chars -= v.length;
+ live--;
+ }
+}
+
const pending = new Map(); // path -> { promise, resolve }
const waiting = []; // LIFO
let inFlight = 0;
@@ -64,6 +137,8 @@ function pump() {
const w = pending.get(p);
pending.delete(p);
if (w) w.resolve(u);
+ /* bounded here, where it grows, and nowhere else */
+ trimArt(p);
})
.finally(() => { inFlight--; pump(); });
}
@@ -118,6 +193,7 @@ export function accentFor(path) {
.then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT))
.catch(() => DEFAULT_ACCENT),
);
+ while (accents.size > ART_MAX) accents.delete(accents.keys().next().value);
}
return accents.get(path);
}
@@ -129,3 +205,15 @@ export function applyAccent(c) {
s.setProperty("--accent", c.accent);
s.setProperty("--accent-hover", c.hover);
}
+
+/* ── what this module is holding ────────────────────────────────────────
+ The map and its three side tables are module-private, so nothing outside
+ this file can see whether they grow. The walk reads this before and after
+ a long session and asserts the difference. A pure read — it fetches
+ nothing, evicts nothing and is safe to call on Kyle's own phone. */
+export function artTally() {
+ const keys = Object.keys(art);
+ let chars = 0;
+ for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; }
+ return { art: keys.length, chars, accents: accents.size, pending: pending.size, waiting: waiting.length, max: ART_MAX };
+}
diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js
index 61acf39..cffeaa2 100644
--- a/src/mobile/playlists.svelte.js
+++ b/src/mobile/playlists.svelte.js
@@ -42,6 +42,12 @@ export function coverFor(p) {
asking.add(k);
api.playlistCover(p.id, 360)
.then((u) => { pls.covers[k] = u || null; }, () => { pls.covers[k] = null; })
+ /* the key is "id:revision" and an edit bumps the revision, so
+ every earlier revision of this playlist is unreachable by
+ construction — nothing looks a playlist's cover up by
+ anything but its current one. Dropped as the new one lands:
+ no cap, no policy, just the keys that are already dead. */
+ .then(() => { const mine = p.id + ":"; for (const old of Object.keys(pls.covers)) if (old !== k && old.startsWith(mine)) delete pls.covers[old]; })
.finally(() => asking.delete(k));
}
return null;
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index 0c0a69c..02cc350 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -467,6 +467,70 @@ const PROBE = `(function () {
/* the glass bar above plus the floating bars below, as a share of the screen */
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
+ /* ── the long session: what the app is still HOLDING ─────────────
+ Minutes is all a walk has ever been. These are the numbers that
+ decide what hours look like: the thumbnail map and its side
+ tables (from the store itself — they are module-private, so
+ artTally is the only way to see them), the catalog's ten
+ session maps, the queue, the transfer list, the DOM, and the
+ live listeners and timers.
+
+ The listener and timer counts come from a registry that a step
+ installs IN THE PAGE, not from here: they are -1 until it is
+ installed, which means every step before the session block, every
+ step after it puts itself away again, and the whole phone walk,
+ where it is never installed at all. They count what was
+ registered AFTER the install and is still ATTACHED — window,
+ document, or an element that is connected — so the absolute
+ number is small and depends on how much of the walk ran first;
+ it is the DELTA across the laps that means anything. Attached
+ rather than add-minus-remove because that counter is a liar in
+ this app: {#key nav.seq} replaces the .screen element on every
+ navigation, so each new screen gets its own scroll handler and
+ the old element is simply dropped.
+
+ grew* is the same number against whatever the block latched into
+ window.__ssBase, and -1 when nothing has. Every backslash here is
+ doubled and there is not one backtick, comment included — this
+ block lives inside a template literal. */
+ tallyArt: (function () {
+ /* one census per probe, kept in __ssLast for the fields below:
+ a field of an object literal cannot see its siblings, and
+ this insert is the only part of the probe this round owns.
+ All of it is a read; nothing here fetches or evicts. */
+ window.__ssCensus = function () {
+ var out = { art: 0, chars: 0, accents: 0, pending: 0, cat: 0, queue: 0, xfer: 0, nodes: 0, ears: -1, timers: -1, ticks: -1 };
+ try { var a = __ss.artTally(); out.art = a.art; out.chars = a.chars; out.accents = a.accents; out.pending = a.pending; /* waiting is a SUBSET of pending, never a second queue */ } catch (e) {}
+ try {
+ var c = __ss.catalog;
+ var ks = ['artists', 'albums', 'extras', 'profiles', 'seeds', 'albumInfo', 'popular', 'heroSeeds', 'overviews', 'listeners'];
+ for (var i = 0; i < ks.length; i++) out.cat += Object.keys(c[ks[i]] || {}).length;
+ } catch (e) {}
+ try { out.queue = __ss.player.queue.length; } catch (e) {}
+ try { out.xfer = Object.keys(__ss.transfers.items).length; } catch (e) {}
+ try { out.nodes = document.getElementsByTagName('*').length; } catch (e) {}
+ try { var k = window.__ssCount(); out.ears = k.listeners; out.timers = k.timeouts; out.ticks = k.intervals; } catch (e) {}
+ return out;
+ };
+ window.__ssLast = window.__ssCensus();
+ return window.__ssLast.art;
+ })(),
+ tallyChars: (function () { try { return window.__ssLast.chars; } catch (e) { return 0; } })(),
+ tallyAcc: (function () { try { return window.__ssLast.accents; } catch (e) { return 0; } })(),
+ tallyPend: (function () { try { return window.__ssLast.pending; } catch (e) { return 0; } })(),
+ tallyCat: (function () { try { return window.__ssLast.cat; } catch (e) { return 0; } })(),
+ tallyQueue: (function () { try { return window.__ssLast.queue; } catch (e) { return 0; } })(),
+ tallyXfer: (function () { try { return window.__ssLast.xfer; } catch (e) { return 0; } })(),
+ tallyNodes: (function () { try { return window.__ssLast.nodes; } catch (e) { return 0; } })(),
+ tallyEars: (function () { try { return window.__ssLast.ears; } catch (e) { return -1; } })(),
+ tallyTimers: (function () { try { return window.__ssLast.timers; } catch (e) { return -1; } })(),
+ tallyTicks: (function () { try { return window.__ssLast.ticks; } catch (e) { return -1; } })(),
+ grewArt: (function () { try { return window.__ssLast.art - window.__ssBase.art; } catch (e) { return -1; } })(),
+ grewCat: (function () { try { return window.__ssLast.cat - window.__ssBase.cat; } catch (e) { return -1; } })(),
+ grewEars: (function () { try { return window.__ssLast.ears - window.__ssBase.ears; } catch (e) { return -1; } })(),
+ grewTimers: (function () { try { return window.__ssLast.timers - window.__ssBase.timers; } catch (e) { return -1; } })(),
+ grewTicks: (function () { try { return window.__ssLast.ticks - window.__ssBase.ticks; } catch (e) { return -1; } })(),
+ grewNodes: (function () { try { return window.__ssLast.nodes - window.__ssBase.nodes; } catch (e) { return -1; } })(),
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
};
})()`;
@@ -1163,6 +1227,166 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 },
{ do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 },
+ /* ── the long session ─────────────────────────────────────────────
+ Every walk before this one was minutes long. A phone runs the app
+ for hours, and nothing in the port had ever been looked at for a
+ leak. This block is both the instrument and the assertion.
+
+ The registry is installed by a STEP rather than through
+ Page.addScriptToEvaluateOnNewDocument, and that is load-bearing: the
+ first-run block above reloads three times and reattach() opens a NEW
+ DevTools session, so a script registered on the old one is gone for
+ the rest of the run. Installed from here it covers exactly the laps
+ the assertion is about, and the last step takes it out again. It
+ wraps addEventListener/removeEventListener (holding a WeakRef to
+ anything that is not window or document) and the four timer
+ functions, and it counts only what was registered AFTER it went in
+ and is still ATTACHED. Attached, because the naive counter — add
+ minus remove — climbs about twelve a lap in this app and none of it
+ is a leak: {#key nav.seq} replaces the .screen element on every
+ navigation and the old element's scroll handler goes with it. After
+ the install, because nothing can be registered before it: the
+ absolute count is therefore small (two after the whole walk, eight
+ from a settled boot) and only the DELTA across the laps is the
+ measurement. A listener or an interval stranded once a lap shows as
+ +9 in one and +9 in the other; it was driven to check.
+
+ Then two laps to warm, a reading latched into __ssBase, nine more
+ laps, and a second reading. A lap is the five tabs, the Library, a
+ record, Play, the mini bar, all three faces of the sheet, the artwork
+ viewer, a row's half sheet, the artist page (a catalog lookup) and
+ back — about five seconds each, and fifty in all. The lap count is
+ what makes the assertion sharp: a one-per-lap leak shows as +9
+ against a headroom of four, and trimming it to save time blunts the
+ only thing this block is for.
+
+ Nothing here reaches the network or the disk beyond what any other
+ mock step does, and the last step puts the Library back on whichever
+ section the walk left it on. */
+ { do: `
+ window.__ssLibTab = (function () { try { return __ss.nav.stacks.library[0].c.tab || 'albums'; } catch (e) { return 'albums'; } })();
+ window.__ssLapN = 0;
+ window.__ssLap = function (n) {
+ var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
+ var tap = function (sel, text) {
+ var all = document.querySelectorAll(sel);
+ for (var k = 0; k < all.length; k++) if (!text || (all[k].textContent || '').indexOf(text) >= 0) { all[k].click(); return true; }
+ return false;
+ };
+ /* the app's own back, never history.back(): walking off the
+ history mirror leaves file:// and the page is simply gone */
+ var peel = async function () {
+ for (var b = 0; b < 8; b++) {
+ var nv = __ss.nav;
+ if (!nv.timing && !nv.viewer && !nv.half && !nv.sheet && nv.stacks[nv.tab].length === 1) return;
+ __ss.back();
+ await nap(190);
+ }
+ };
+ return (async function () {
+ for (var i = 0; i < n; i++) {
+ /* the counter is global, not the loop's own: the laps
+ run in threes, and a lap that always opened the same
+ record would stop asking for anything new after the
+ first call and quietly measure nothing */
+ var which = window.__ssLapN++;
+ __ss.go('search'); await nap(90);
+ __ss.go('community'); await nap(90);
+ __ss.go('you'); await nap(90);
+ __ss.go('library'); await nap(200);
+ tap('.segtabs button', 'Albums'); await nap(260);
+ var rel = document.querySelectorAll('.grid2 .release');
+ if (rel.length) { rel[which % rel.length].click(); await nap(440); }
+ tap('.rec-acts .disc'); await nap(440);
+ tap('.mini'); await nap(380);
+ var fb = document.querySelectorAll('.fbtn');
+ for (var f = 0; f < fb.length; f++) { fb[f].click(); await nap(110); }
+ tap('.np-art'); await nap(240);
+ /* peeled one layer at a time and only if it opened: a
+ record with no cover never opens the viewer, and a
+ blind pair of backs would pop the album page instead
+ and take the rows below with it */
+ if (__ss.nav.viewer) { __ss.back(); await nap(220); }
+ if (__ss.nav.sheet) { __ss.back(); await nap(260); }
+ var trk = document.querySelector('.trk .r');
+ if (trk) { trk.click(); await nap(260); tap('.scrim'); await nap(220); }
+ tap('.rec-artist'); await nap(620);
+ await peel();
+ __ss.go('home'); await nap(160);
+ }
+ return n;
+ })();
+ };
+ (function () {
+ if (window.__ssCount) return 'already';
+ var recs = [], liveT = new Set(), liveI = new Set();
+ var ael = EventTarget.prototype.addEventListener, rel = EventTarget.prototype.removeEventListener;
+ EventTarget.prototype.addEventListener = function (t, f) {
+ try { recs.push({ t: t, f: f, w: (this === window || this === document) ? null : new WeakRef(this), dead: false }); } catch (e) {}
+ return ael.apply(this, arguments);
+ };
+ EventTarget.prototype.removeEventListener = function (t, f) {
+ try { for (var i = recs.length - 1; i >= 0; i--) if (!recs[i].dead && recs[i].t === t && recs[i].f === f) { recs[i].dead = true; break; } } catch (e) {}
+ return rel.apply(this, arguments);
+ };
+ var st = window.setTimeout, ct = window.clearTimeout, si = window.setInterval, ci = window.clearInterval;
+ window.setTimeout = function (fn, ms) {
+ var id, rest = [].slice.call(arguments, 2);
+ var w = typeof fn === 'function' ? function () { liveT.delete(id); return fn.apply(window, arguments); } : fn;
+ id = st.apply(window, [w, ms].concat(rest)); liveT.add(id); return id;
+ };
+ window.clearTimeout = function (id) { liveT.delete(id); return ct.call(window, id); };
+ window.setInterval = function () { var id = si.apply(window, arguments); liveI.add(id); return id; };
+ window.clearInterval = function (id) { liveI.delete(id); return ci.call(window, id); };
+ /* a listener is LIVE when its target is window, document, or an
+ element still in the tree; a timer is live until it fires or
+ is cleared */
+ window.__ssCount = function () {
+ var n = 0;
+ for (var i = 0; i < recs.length; i++) {
+ var r = recs[i];
+ if (r.dead) continue;
+ if (r.w === null) { n++; continue; }
+ var el = r.w.deref();
+ if (el && el.isConnected) n++;
+ }
+ return { listeners: n, timeouts: liveT.size, intervals: liveI.size };
+ };
+ /* and hands the page back exactly as it was found. A sibling
+ round appending after this block must not inherit a patched
+ EventTarget or a patched clock; __ssCount and __ssBase go
+ with them, so every later probe reads the -1 that means
+ 'nothing has measured this' rather than a frozen number. */
+ window.__ssUninstall = function () {
+ EventTarget.prototype.addEventListener = ael;
+ EventTarget.prototype.removeEventListener = rel;
+ window.setTimeout = st; window.clearTimeout = ct;
+ window.setInterval = si; window.clearInterval = ci;
+ delete window.__ssCount; delete window.__ssBase; delete window.__ssUninstall;
+ return 'removed';
+ };
+ return 'installed';
+ })()
+ `, wait: 500 },
+ { do: `__ssLap(2)`, name: "session-warm", wait: 1200 },
+ /* the baseline, taken after the warm laps have settled and their own
+ probe has run — __ssCensus is defined by the probe itself */
+ { do: `window.__ssBase = window.__ssCensus(); true`, wait: 300 },
+ /* nine laps, in threes: cdp.send() gives a step forty-five seconds to
+ answer and a lap is about five, so one call of nine times out, the
+ walk records a failure it did not have, and the probe lands in the
+ middle of a lap instead of at Home */
+ { do: `__ssLap(3)`, wait: 400 },
+ { do: `__ssLap(3)`, wait: 400 },
+ { do: `__ssLap(3)`, name: "session-late", wait: 1400 },
+ /* back to Home with nothing open, the Library on the section the walk
+ was using before this block, and the registry out of the page, so
+ whatever is appended after this finds the app — and the platform —
+ where the block above left them. This step's own probe therefore
+ reports -1 for every grew* field, which is the sentinel for
+ 'nothing has latched a baseline': session-late is where the numbers
+ are. */
+ { do: `__settle(); try { __ss.go('library'); __ss.patch({ tab: __ssLibTab }); __ss.go('home'); } catch (e) {} try { __ssUninstall(); } catch (e) {} true`, name: "session-clear", wait: 1000 },
];
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
@@ -1554,6 +1778,35 @@ const MOCK_EXPECT = {
"export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast,
"export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast),
"export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
+ /* the instrument itself, before anything is asked of it: the registry
+ is counting, the store is answering, and the walk is not sitting on a
+ toast from the block above. tallyTicks is only asked to ANSWER (-1 is
+ 'no registry'), never to be non-zero: the app's own intervals were
+ all started at boot, before the registry went in, so the only live
+ one here belongs to the mock's playback ticker — requiring it would
+ tie this guard to the mock instead of to the registry. */
+ "session-warm": (i) => i.tallyEars > 0 && i.tallyTicks >= 0 && i.tallyArt > 0 && !i.toast,
+ /* Nine laps later. The listener and interval counts are the sharp ones —
+ both measured dead flat over the nine, twice — so their headroom is
+ small on purpose; grewEars allows four for a screen that happens to
+ be mounted at the second reading and not the first, and grewTicks is
+ one-sided because a ticker that simply stopped between the readings
+ is not a leak. Both were driven with a listener and an interval
+ deliberately stranded once a lap: +9 and +9, and this line failed.
+ tallyTimers is a CEILING rather than a delta, and nine of the
+ thirty-two are real: every track start schedules a 47-second
+ recents refresh (player.svelte.js, the desktop's own shape), so a
+ walk this fast keeps a dozen of them pending at once. That window is
+ bounded by its own length and does not grow with the session, while a
+ genuinely leaking timer climbs past this ceiling as laps are added.
+ Making it a delta would need a fifty-second quiet period between the
+ two readings, which is not worth the minute — said here rather than
+ tightening the number blind. tallyArt is the bound itself; grewArt's
+ headroom is for a sibling round that puts more records in a lap, and
+ is to be raised deliberately rather than deleted. */
+ "session-late": (i) => i.grewEars <= 4 && i.grewTicks <= 0 && i.tallyTimers <= 32
+ && i.tallyArt <= 512 && i.grewArt <= 80 && i.grewCat <= 24 && i.grewNodes <= 200,
+ "session-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
};
/* the phone walk: a record by name, played for real */