Files
SoulStream/tests/mobile/smoke.mjs
T
KeelrLeek 33f32cb4c9 android: the long session — what the app is still holding after an hour
Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.

Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.

The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
2026-09-14 07:05:55 -05:00

2892 lines
230 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Walk the phone UI and say what it showed.
Two targets, one driver:
node tests/mobile/smoke.mjs headless Chrome + the mock library
node tests/mobile/smoke.mjs --phone the app on the connected phone, over
the WebView's own DevTools socket
MOCK: builds the phone document with relative asset paths into dist-smoke/,
opens it from file:// with src/lib/mockLibrary.js switched on, drives the
screens, screenshots each step, asserts what each should show, and fails
on any exception or console error. No Tauri there, so playback commands
reject — the point is the UI's own logic and its CSS, before the phone.
PHONE: a debug build's WebView exposes DevTools on an abstract socket;
`adb forward` puts it on a local port and the same protocol drives the
real app on the real library — real playback, real covers, real colour.
Stills are the phone's own frame (adb screencap), status bar included.
`--album <text>` picks the record to walk by title (default: the newest).
Stills land in tests/mobile/out/ (or the directory given as --out). */
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
import { CDP, adb, sleep, attachPhone, phoneStill } from "./cdp.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const ARGS = process.argv.slice(2);
const flag = (name) => { const i = ARGS.indexOf(name); return i >= 0 ? (ARGS[i + 1] ?? true) : null; };
const PHONE = ARGS.includes("--phone");
const OUT = path.resolve(flag("--out") || path.join(ROOT, "tests", "mobile", "out"));
const ALBUM = flag("--album");
const PORT = PHONE ? 9222 : 9421;
const CHROME = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
path.join(process.env.LOCALAPPDATA || "", "Google/Chrome/Application/chrome.exe"),
].find((p) => fs.existsSync(p));
/* injected beside the steps: click the first element whose text mentions
something, because the real library is a list of names, not of indices */
const HELPERS = `
window.__click = function (sel, text) {
var all = document.querySelectorAll(sel);
for (var i = 0; i < all.length; i++) {
if (!text || (all[i].textContent || '').indexOf(text) >= 0) { all[i].click(); return true; }
}
throw new Error('nothing to click for ' + sel + (text ? ' ~ "' + text + '"' : ''));
};
/* a drag, as the events a finger sends: press, hold past the action's own
320ms, walk down (or up) to just past the target row's middle, let go.
Both rows are measured BEFORE the press, because the action takes its
own snapshot at the lift and that is the geometry it will compare
against. Returns false when the list is too short to drag — a real
library is not obliged to hold three rows — so a thin walk says so in
its expectation rather than dying here. cdp.evaluate awaits promises. */
window.__drag = function (sel, from, to, holdMs) {
var rows = document.querySelectorAll(sel);
var a = rows[from], b = rows[to];
if (rows.length < 2 || !a || !b) return false;
var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
var x = Math.round(ra.left + Math.min(40, ra.width / 2));
var y0 = Math.round(ra.top + ra.height / 2);
var y1 = Math.round(rb.top + rb.height / 2 + (to > from ? 4 : -4));
function ev(type, cy) {
a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 7, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: cy }));
}
var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
return (async function () {
ev('pointerdown', y0);
await nap(holdMs || 420);
for (var i = 1; i <= 8; i++) { ev('pointermove', Math.round(y0 + (y1 - y0) * (i / 8))); await nap(24); }
await nap(60);
ev('pointerup', y1);
return true;
})();
};
/* the other half of the same gesture: pressed and released without moving,
which is still the row's sheet */
window.__hold = function (sel, i, holdMs) {
var a = document.querySelectorAll(sel)[i || 0];
if (!a) throw new Error('no row to hold for ' + sel);
var r = a.getBoundingClientRect();
var x = Math.round(r.left + Math.min(40, r.width / 2)), y = Math.round(r.top + r.height / 2);
function ev(type) {
a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 8, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: y }));
}
return new Promise(function (res) { ev('pointerdown'); setTimeout(function () { ev('pointerup'); res(true); }, holdMs || 420); });
};
window.__settle = function () {
/* put the app back at Home with nothing open, whatever it was doing */
var s = document.querySelector('.sheet.up');
if (s) { var c = s.querySelector('.np-head .iconbtn'); if (c) c.click(); }
var h = document.querySelector('.halfsheet.up');
if (h) { var sc = document.querySelector('.scrim'); if (sc) sc.click(); }
/* every tab back to its root, through the nav's own door (the history mirror stays true) */
if (window.__ss && __ss.go && __ss.nav) {
var tabs = Object.keys(__ss.nav.stacks);
for (var i = 0; i < tabs.length; i++) { if (__ss.nav.stacks[tabs[i]].length > 1) { __ss.go(tabs[i]); __ss.go(tabs[i]); } }
}
var home = document.querySelectorAll('.nav button')[0];
if (home) { home.click(); home.click(); }
return true;
};
`;
/* what the page shows, read back after every still */
const PROBE = `(function () {
var e = document.querySelector('.screen');
/* ── what a screen reader would be handed ────────────────────────────
'exposed' is the tree TalkBack actually walks: nothing under
aria-hidden or inert, nothing display:none or zero-boxed. 'aname' is
the name it would announce — aria-label, aria-labelledby, then the
visible text with the aria-hidden subtrees left out (an Icon is
aria-hidden, so an icon-only control computes to nothing at all). A
PLACEHOLDER is deliberately not a name here: it goes away the moment
the field has text in it. Every backslash in this block is doubled —
it lives inside a template literal. */
var A11Y_SEL = 'button, [role=button], a, input, select, textarea, [role=tab], [role=switch], [role=slider], [role=checkbox]';
function exposed(el) {
if (el.closest('[aria-hidden="true"], [inert]')) return false;
if (!el.getClientRects().length) return false;
var st = getComputedStyle(el);
return st.visibility !== 'hidden' && st.display !== 'none';
}
function vistext(el) {
var out = '';
for (var i = 0; i < el.childNodes.length; i++) {
var n = el.childNodes[i];
if (n.nodeType === 3) { out += n.nodeValue; continue; }
if (n.nodeType !== 1) continue;
if (n.getAttribute('aria-hidden') === 'true') continue;
if (n.tagName === 'IMG') { out += ' ' + (n.getAttribute('alt') || ''); continue; }
out += ' ' + vistext(n);
}
return out;
}
function aname(el) {
var l = el.getAttribute('aria-label');
if (l && l.trim()) return l.trim();
var by = el.getAttribute('aria-labelledby');
if (by) {
var s = '';
by.split(' ').forEach(function (id) { var n = document.getElementById(id); if (n) s += ' ' + vistext(n); });
if (s.trim()) return s.replace(/\\s+/g, ' ').trim();
}
var t = el.tagName;
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') {
if (el.id) { var lab = document.querySelector('label[for="' + el.id + '"]'); if (lab && lab.textContent.trim()) return lab.textContent.replace(/\\s+/g, ' ').trim(); }
return (el.getAttribute('title') || '').trim();
}
return vistext(el).replace(/\\s+/g, ' ').trim() || (el.getAttribute('title') || '').trim();
}
function whatis(el) { return (el.tagName.toLowerCase() + '.' + String(el.className || '').split(' ').slice(0, 2).join('.')).slice(0, 36); }
function junk(el, nm) {
if (/^[0-9 .,:;+·—–-]+$/.test(nm)) return 'digits';
if (/\\.(png|jpe?g|webp|gif|svg|mp3|flac|m4a|ogg|wav)$/i.test(nm)) return 'file';
if (/^[⋯…×✕✓→←]$/.test(nm)) return 'glyph';
if (nm.length > 200 && !el.hasAttribute('aria-expanded')) return 'essay';
return '';
}
var a11yAll = [].filter.call(document.querySelectorAll(A11Y_SEL), exposed);
var a11yNone = a11yAll.filter(function (el) { return !aname(el); });
/* geometry, for the orientation steps: box() is the rect or null, and
inside() asks whether every pixel of it is within the viewport —
which, sideways, is the whole question */
function box(sel) { var x = document.querySelector(sel); return x ? x.getBoundingClientRect() : null; }
function inside(b) { return !!b && b.height > 0 && b.top >= -1 && b.bottom <= innerHeight + 1 && b.left >= -1 && b.right <= innerWidth + 1; }
return {
screen: e ? e.className : '',
title: (document.querySelector('.gb-t') || {}).textContent || '',
mini: !!document.querySelector('.mini'),
sheet: (document.querySelector('.sheet') || {}).className || '',
half: (document.querySelector('.halfsheet') || {}).className || '',
halfText: (document.querySelector('.halfsheet .hs-body') || {}).textContent || '',
accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(),
amb: [].map.call(document.querySelectorAll('.app-bg .amb'), function (a) { return a.className + ':' + (a.style.backgroundImage ? 'img' : '-'); }).join(' '),
playing: (document.querySelector('.mini .t') || {}).textContent || '',
toast: (document.querySelector('.toast:not(.notice)') || {}).textContent || '',
live: (document.querySelector('.mini .m.live') || {}).textContent || '',
peers: document.querySelectorAll('.peer').length,
songs: document.querySelectorAll('.cat-song').length,
msgs: document.querySelectorAll('.msg').length,
lane: (document.querySelector('.sheet .lane') || {}).className || '',
top: (document.querySelector('.top .top-name') || {}).textContent || '',
tabs: document.querySelectorAll('.disco-tabs button').length,
pop: document.querySelectorAll('.row.pop').length,
fb: (document.querySelector('.stats .fb') || {}).textContent || '',
heroImg: !!document.querySelector('.top-art img'),
socs: document.querySelectorAll('.stats .soc').length,
eds: document.querySelectorAll('.eds .ed').length,
lede: !!document.querySelector('.lede'),
bars: document.querySelectorAll('.trk .lbar').length,
save: (document.querySelector('.rec-acts .save') || {}).textContent || '',
xfers: document.querySelectorAll('.row.xfer').length,
/* the shade's notice, as the app's own record of having posted it —
a notification is not a thing the page can be asked about */
xfnOn: (function () { try { return !!__ss.xfn.posted; } catch (e) { return false; } })(),
xfnClr: (function () { try { return __ss.xfn.clears; } catch (e) { return -1; } })(),
xfnMax: (function () { try { return __ss.xfn.max; } catch (e) { return -1; } })(),
xfnBad: (function () { try { return __ss.xfn.failed; } catch (e) { return -1; } })(),
xfnTitle: (function () { try { return __ss.xfn.title; } catch (e) { return ''; } })(),
xfnLine: (function () { try { return __ss.xfn.line; } catch (e) { return ''; } })(),
xfnBtn: !![].slice.call(document.querySelectorAll('.phead .all')).find(function (x) { return /Cancel all/.test(x.textContent); }),
feat: (document.querySelector('.feat .kick') || {}).textContent || '',
gaplines: document.querySelectorAll('.gapline').length,
folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }),
norm: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Volume normalization/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
gapCards: document.querySelectorAll('.rail.gaps .release').length,
news: document.querySelectorAll('.row.news').length,
secs: [].map.call(document.querySelectorAll('.screen .sec h2'), function (h) { return h.firstChild && h.firstChild.textContent ? h.firstChild.textContent.trim() : ''; }).join('|'),
xlive: (document.querySelector('.screen .sec h2') || {}).textContent || '',
plcards: document.querySelectorAll('.plgrid .release').length,
plnames: [].map.call(document.querySelectorAll('.plgrid .release .title'), function (t) { return t.textContent.trim(); }).join('|'),
plpinned: !!document.querySelector('.plsec.pinned'),
plrows: document.querySelectorAll('.pl-rows .trk').length,
pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '',
plfilter: !!document.querySelector('.plfilter input'),
plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '',
plcover: !!document.querySelector('.rec-head.pl .rec-cover img'),
qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '',
rooms: document.querySelectorAll('.screen button.row .art.hash').length,
jump: document.querySelectorAll('.screen .rail .release').length,
countline: (document.querySelector('.countline .t-sub') || {}).textContent || '',
navBadges: [].map.call(document.querySelectorAll('.nav .nb'), function (b) { return b.textContent.trim(); }).join('|'),
wifiOnly: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
firstBadge: (document.querySelector('.grid2 .release .ed-badge') || {}).textContent || '',
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
/* the ORDER, read from the stores rather than off the screen: after
a drop the page re-reads its rows, so this is what the backend
kept, not what the drag painted */
plOrder: (function () { try { return __ss.pls.pls.current.items.map(function (x) { return x.title; }).join('|'); } catch (e) { return ''; } })(),
qOrder: (function () { try { return __ss.player.queue.slice(__ss.player.qi + 1, __ss.player.qi + 9).map(function (x) { return x.t.title; }).join('|'); } catch (e) { return ''; } })(),
plNote: (document.querySelector('.pl-note') || {}).textContent || '',
imp: document.querySelectorAll('.imp-rows .trk').length,
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '',
impWait: !!document.querySelector('.imp-wait'),
impWaitLine: (document.querySelector('.imp-wait b') || {}).textContent || '',
impWarn: [].map.call(document.querySelectorAll('.imp-warn div'), function (d) { return d.textContent.trim(); }).join('|'),
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
impSrc: (document.querySelector('.imp-src') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
brwDirs: document.querySelectorAll('.brw-dir').length,
qrows: document.querySelectorAll('.q-face .row').length,
viewer: !!document.querySelector('.viewer img'),
fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length,
played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '',
lyrLines: document.querySelectorAll('.lyr .ln').length,
sync: !!document.querySelector('.syncroom'),
syCount: (document.querySelector('.sy-count') || {}).textContent || '',
syNow: (document.querySelector('.sy-now') || {}).textContent || '',
lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(),
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
recents: document.querySelectorAll('.recent-chips .chip').length,
dym: (document.querySelector('.dym') || {}).textContent || '',
sq: (document.querySelector('.sfield input') || {}).value || '',
slots: (document.querySelector('.slots .stepper .n') || {}).textContent || '',
wordfill: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Word-by-word/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
artEdge: (function () { var ps = document.querySelectorAll('.prefs[data-mode="albumart"] .pref'); var c = ps[1] && ps[1].querySelector('.chip.on'); return c ? c.textContent.trim() : ''; })(),
brwFiles: document.querySelectorAll('.brw-dir .peer-file').length,
brwHead: (document.querySelector('.brw-head .m') || {}).textContent || '',
peerLine: (document.querySelector('.pr-head .m') || {}).textContent || '',
peerDesc: (document.querySelector('.pr-about') || {}).textContent || '',
peerPic: !!document.querySelector('.pr-pic img'),
peerTags: document.querySelectorAll('.pr-tags .chip').length,
peerGaps: document.querySelectorAll('.pr-gap').length,
peerSum: (document.querySelector('.pr-sum') || {}).textContent || '',
keep: (function () { var g = document.querySelector('.prefs[data-mode="streamcache"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
rowArt: document.querySelectorAll('.screen .row .art img').length,
notice: (document.querySelector('.toast.notice') || {}).textContent || '',
promptVal: (document.querySelector('.halfsheet .prompt .field') || {}).value || '',
/* the history mirror: the entry's depth against the layers the app says the back gesture can peel */
hd: (history.state && history.state.d != null) ? history.state.d : -1,
hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(),
cardImgs: document.querySelectorAll('.rail .acard img').length,
cardPhs: document.querySelectorAll('.rail .acard svg').length,
port: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Listening port/.test(x.textContent); }); var m = r && r.querySelector('.m'); return m ? m.textContent.trim() : ''; })(),
diagRows: document.querySelectorAll('.group.diag .kv').length,
diagCpu: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /App CPU/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
diagPlays: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /Play rows/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
diagDeck: document.querySelectorAll('.group.diag-deck .row').length,
popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length,
barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length,
heroPix: !!document.querySelector('.hero .hero-img.dissolve'),
likes: [].map.call(document.querySelectorAll('.tastechips.likes .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
hates: [].map.call(document.querySelectorAll('.tastechips.hates .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
similar: document.querySelectorAll('.screen .row.similar').length,
budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'),
budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'),
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,
which kinds they were, and how long it took. A probe landing a
second and a half later still reads the launch. -1 is "the census
never sealed", -2 is "there is no census". bootCmds is the
distinct names, not the whole run of them — every step carries
it, and what is ever asked of it is whether a kind of command was
at boot at all. */
bootCalls: (function () { try { return __ss.boot.sealed ? __ss.boot.calls : -1; } catch (e) { return -2; } })(),
bootSync: (function () { try { return __ss.boot.sync; } catch (e) { return -2; } })(),
bootMs: (function () { try { return __ss.boot.frameMs == null ? -1 : __ss.boot.frameMs; } catch (e) { return -2; } })(),
bootLibMs: (function () { try { return __ss.boot.libMs == null ? -1 : __ss.boot.libMs; } catch (e) { return -2; } })(),
bootCmds: (function () { try { var c = __ss.boot.cmds, u = [], i; for (i = 0; i < c.length; i++) if (u.indexOf(c[i]) < 0) u.push(c[i]); return u.join(' '); } catch (e) { return ''; } })(),
navMs: (function () { try { var n = performance.getEntriesByType('navigation')[0]; return n ? Math.round(n.domContentLoadedEventEnd) : -1; } catch (e) { return -1; } })(),
/* ── the weather: what the screen says when a read fails, an answer
is empty, or nobody is signed in. Every one of these is a line
this round put there; before it they were an empty box, a spinner
with no end, or a Rust string in the red toast. */
slskOn: (function () { try { return __ss.slsk.connection; } catch (e) { return ''; } })(),
signin: !!document.querySelector('.signin'),
signinErr: (document.querySelector('.signin-err') || {}).textContent || '',
why: (document.querySelector('.stub.why') || {}).textContent || '',
/* the toast's one action, by its words: "Try again" or "Sign in" */
retry: (document.querySelector('.toast:not(.notice) .na') || {}).textContent || '',
again: !![].slice.call(document.querySelectorAll('.imp-foot .btn')).find(function (b) { return /Try again/.test(b.textContent); }),
roomsNote: (document.querySelector('.rooms-note') || {}).textContent || '',
offlineNote: (document.querySelector('.offline-note') || {}).textContent || '',
netAsk: !!document.querySelector('.net-ask'),
folderGone: (document.querySelector('.folder-gone') || {}).textContent || '',
lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '',
lyrTools: !!document.querySelector('.lyr-tools'),
/* ── the faces round thirty-one drew and no step had ever read ──
Home's own line and the slots behind it (how many follows it
walked, how many came back empty-handed); the catalog's note on
an artist page; whatever stub is up, whitespace collapsed; the
Gaps line, the BUTTON only — the '.gapline.done' variant is a
div and says something else entirely; the failed transfer's
sentence; and whether the lyrics lookup itself failed, as
opposed to having found nothing. Every backslash here is doubled
and there is not one backtick, comment included — this block
lives inside a template literal, and a backtick would END it
(the file still PARSES, so node --check says nothing: what it
becomes is a tagged template, and smoke.mjs throws on import). */
catDown: (document.querySelector('.sec-note.cat-down') || {}).textContent || '',
homeSlots: (function () { try { return __ss.home.order.length; } catch (e) { return -1; } })(),
homeErr: (function () { try { return __ss.home.order.filter(function (id) { var s = __ss.home.slots[id]; return !!(s && s.error); }).length; } catch (e) { return -1; } })(),
catNote: (function () { var n = [].slice.call(document.querySelectorAll('.screen .sec-note:not(.cat-down)')).find(function (x) { return /catalog/i.test(x.textContent || ''); }); return n ? n.textContent.replace(/\\s+/g, ' ').trim() : ''; })(),
stubText: ((document.querySelector('.screen .stub') || {}).textContent || '').replace(/\\s+/g, ' ').trim(),
gapLine: ((document.querySelector('button.gapline') || {}).textContent || '').replace(/\\s+/g, ' ').trim(),
xferFail: (document.querySelector('.row.xfer.failed .m') || {}).textContent || '',
lyrFailed: (function () { try { return __ss.lyr.failed ? 'yes' : 'no'; } catch (e) { return ''; } })(),
trks: document.querySelectorAll('.screen .trk').length,
/* the phone's trouble-words step leaves its eleven sentences here */
trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(),
syStrip: document.querySelectorAll('.sy-strip .sy-col').length,
syTicks: document.querySelectorAll('.sy-strip .sy-tick').length,
syEst: (document.querySelector('.sy-est') || {}).textContent || '',
syHint: (document.querySelector('.sy-hint') || {}).textContent || '',
disco: document.querySelectorAll('.grid2 .release').length,
/* the whole-discography page: its rows, its tabs (All in front), the
year headers, an opened edition rail, the cap's own button, the
first row's title, the order the head is showing and the summary
line — plus, back on the artist page, the link down to it */
dgRows: document.querySelectorAll('.dg-row').length,
dgTabs: document.querySelectorAll('.dg-tabs button').length,
dgGroups: document.querySelectorAll('.dg-group').length,
dgEds: document.querySelectorAll('.dg-eds .ed').length,
dgMore: (document.querySelector('.dg-more') || {}).textContent || '',
dgFirst: (document.querySelector('.dg-row .t') || {}).textContent || '',
dgOrder: (document.querySelector('.dg-sec .all') || {}).textContent || '',
dgSum: (document.querySelector('.dg-head .m') || {}).textContent || '',
dgAll: (document.querySelector('.more-link.dg-all') || {}).textContent || '',
/* the artist page's three lanes at once — the page, the hero seed and
the overview — so a step can say what painted and what had not
landed yet: "<page>:<seed>[+art]:<overview>:<releases>" */
artFast: (function () {
try {
var t = __ss.top();
if (!t || t.n !== 'artist') return '';
var a = __ss.catalog.artists[String(t.c.name || '').trim().toLowerCase()];
if (!a) return 'no-entry';
var h = __ss.catalog.heroSeeds[a.id] || {}, o = __ss.catalog.overviews[a.id] || {};
return a.status + ':' + (h.status || '-') + (h.banner ? '+art' : '') + ':' + (o.status || '-') + ':' + ((o.albums || []).length);
} catch (e) { return ''; }
})(),
/* the number this round exists to hold at zero, and who broke it */
a11yAnon: a11yNone.length,
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
a11yBad: a11yAll.map(function (el) { var nm = aname(el); if (!nm) return ''; var j = junk(el, nm); return j ? j + ':' + whatis(el) + ':' + nm.slice(0, 22) : ''; }).filter(Boolean).slice(0, 3).join(' | '),
/* a layer that is off-screen but still in the tree: the closed
sheets, the hidden mini bar, the nav under a sheet */
a11yGhost: (function () {
var n = 0;
[].forEach.call(document.querySelectorAll('.sheet:not(.up), .halfsheet:not(.up), .mini.hidden, .nav.gone'), function (h) {
if (h.matches(A11Y_SEL) && exposed(h)) n++;
n += [].filter.call(h.querySelectorAll(A11Y_SEL), exposed).length;
});
return n;
})(),
/* a control that LOOKS chosen and does not say so */
a11yState: (function () {
var bad = [];
[].forEach.call(document.querySelectorAll('.row .sw'), function (sw) {
var b = sw.closest('button, [role=button]');
if (!b || !exposed(b)) return;
var want = sw.classList.contains('on') ? 'true' : 'false';
if (b.getAttribute('role') !== 'switch' || b.getAttribute('aria-checked') !== want) bad.push('sw:' + aname(b).slice(0, 18));
});
[].forEach.call(document.querySelectorAll('.segtabs button, .np-faces .fbtn, .nav button'), function (b) {
if (!exposed(b)) return;
var want = b.classList.contains('on') ? 'true' : 'false';
if (b.getAttribute('role') !== 'tab' || b.getAttribute('aria-selected') !== want) bad.push('tab:' + aname(b).slice(0, 18));
});
[].forEach.call(document.querySelectorAll('button.chip'), function (b) {
if (!exposed(b) || b.classList.contains('tchip')) return;
if (!b.hasAttribute('aria-pressed')) { if (b.classList.contains('on')) bad.push('chip:' + aname(b).slice(0, 18)); return; }
if (b.getAttribute('aria-pressed') !== (b.classList.contains('on') ? 'true' : 'false')) bad.push('chip:' + aname(b).slice(0, 18));
});
return bad.slice(0, 3).join(' | ');
})(),
/* the layer on top: its role, whether it is modal, its name, and
whether the focus went with it */
dlg: (function () {
var d = document.querySelector('.viewer') || document.querySelector('.syncroom')
|| document.querySelector('.halfsheet.up') || document.querySelector('.sheet.up');
if (!d) return '';
var nm = (d.getAttribute('aria-label') || '').trim();
return (d.getAttribute('role') || '-') + '/' + (d.getAttribute('aria-modal') || '-') + '/' + nm.slice(0, 34) + '/' + (d.contains(document.activeElement) ? 'focus' : 'nofocus');
})(),
navTabs: [].map.call(document.querySelectorAll('.nav button'), function (b) { return aname(b) + (b.getAttribute('aria-selected') === 'true' ? '*' : ''); }).join('|'),
announce: ((document.querySelector('[data-live]') || {}).textContent || '').trim(),
vp: innerWidth + 'x' + innerHeight,
/* the sheet's controls: any one of them off the viewport is the landscape bug */
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
/* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height),
/* WHICH face: the queue's own, up whether or not anything is after this one */
qFace: !!document.querySelector('.sheet.up .q-face'),
/* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */
halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height),
halfIn: inside(box('.halfsheet.up')),
hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(),
hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(),
heroH: Math.round((box('.screen .hero') || { height: 0 }).height),
/* what a page offers first: Play on an artist hero, Play on a record head */
actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')),
recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(),
/* 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; })(),
};
})()`;
/* ── the walks ─────────────────────────────────────────────────────────── */
const MOCK_STEPS = [
{ name: "home", wait: 1600 },
/* a long press on a card is its sheet */
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Re-fetch the cover')`, name: "cover-refetched", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 },
/* a misspelt library name: the catalog has nothing, the library's own names correct it */
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 },
{ do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
/* By format: the hi-res lossless records come first */
{ do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 500 },
{ do: `__click('.chips .chip', 'Recently added')`, wait: 400 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 },
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "album", wait: 900 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 900 },
{ do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 },
/* the art opens the viewer; the back gesture closes it */
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 900 },
{ do: `history.back()`, name: "viewer-closed", wait: 700 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
/* a queue row's sheet: the row removed */
{ do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 },
/* the second upcoming row moved up */
{ do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", wait: 600 },
/* and dragged: the first upcoming row two places down, then back up.
The order is read off player.queue, not off the rows */
{ name: "queue-drag-arm", wait: 300 },
{ do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 900 },
{ do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 900 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Lyrics later')`, name: "lyrics-nudged", wait: 500 },
{ do: `__click('.halfsheet .row', 'Search again')`, name: "lyrics-candidates", wait: 900 },
{ do: `__click('.halfsheet .row', 'Live')`, name: "lyrics-chosen", wait: 700 },
/* pasted words, timestamped: synced lines */
{ do: `__click('.lyr-tools', null)`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Paste lyrics')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "lyrics-pasted", wait: 800 },
/* auto-sync: the app's own correction turned off for this track, then
back on — the re-read leaves the sheet up so the switch is seen */
{ do: `__click('.lyr-tools', null)`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 },
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 },
/* off again, and left off: a save returns the document with that flag at
its default, and the switch must not take it */
{ do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 },
/* the tap-to-sync room: taps become the timing, one undone, the room
peeled by the back gesture and the set offered back, then saved */
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
/* the envelope is measured on open; the estimate lays every line across
the sung stretches, and dropping it puts the room back to the tap */
{ do: `__click('.sy-est', null)`, name: "sync-estimated", wait: 700 },
{ do: `__click('.sy-est', null)`, name: "sync-cleared", wait: 600 },
{ do: `__click('.sy-pad', null)`, wait: 400 },
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
{ do: `history.back()`, wait: 600 },
{ do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 },
{ do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 },
{ do: `__click('.sy-pad', null)`, wait: 400 },
{ do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 },
{ do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 },
/* the vocal-reduced copy, and back to the recording */
{ do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 },
{ do: `__click('.lyr-tools', null)`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 },
{ do: `history.back()`, name: "after-back", wait: 900 },
{ do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 },
{ do: `history.back()`, wait: 700 },
{ do: `history.back()`, name: "library-again", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 },
/* a buddy's sheet: browse, message, remove */
{ do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 },
{ do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 },
/* the contact editor behind the peer sheet: a petname, a note, the
favourite mark — then the buddy list wearing all three, ada's own
name still under it and favourites first */
{ do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'Petname')`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 },
{ do: `__click('.halfsheet .row', 'Note')`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 },
{ do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 },
{ do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a user looked up by name: the peer sheet */
{ do: `__click('.screen .row', 'Look up a user')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'dusty_crates'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Open')`, name: "user-lookup", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* your own interests: what is declared, a like added and the server's
match re-asked, a hate added, the like taken back, a match's sheet */
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 },
{ do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 },
{ do: `__click('.tastechips.likes .tadd', null)`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 },
{ do: `__click('.tastechips.hates .tadd', null)`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 },
{ do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 },
{ do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.segtabs button', 'Rooms')`, wait: 500 },
/* the room list: filtered by name, the rest on demand */
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = 'vin'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "rooms-filtered", wait: 500 },
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 },
/* the fake network: a room with a few lines in it */
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
/* the room's roster wears the petname too, the real name beneath it */
{ do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `history.back()`, wait: 700 },
/* nothing set is nothing stored: cleared, the entry leaves the list and
a name goes back to being a name */
{ do: `__click('.segtabs button', 'Buddies')`, wait: 600 },
{ do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 },
{ do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 },
{ do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 },
/* a search answered by five peers; the best one's first file streams,
the lane rising as the bytes arrive */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 },
/* the hero's artist: the desktop's page — tabs, Popular's bars, Follow,
the latest release, fans also like — then a fan-also-likes page */
{ do: `document.querySelector('.cat-song').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-song-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a Records-rail card's long press: the record's sheet */
{ do: `(function () { var sec = [].slice.call(document.querySelectorAll('.sec')).find(function (x) { return /^\\s*Records/.test(x.textContent); }); var rail = sec && sec.nextElementSibling; rail.querySelector('.release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, name: "cat-record-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 },
{ do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 },
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 },
/* the whole discography, the page the artist page's "All" opens: the
eleven mock rows folded to ten families across six categories, the
year headers, a category tab, the one linked family opened in place,
and the sort sheet taking it to a name order — which is where the
grouping has to fall away */
{ do: `__click('.sec .all', 'All')`, name: "disco-all", wait: 1400 },
{ do: `__click('.dg-tabs button', 'Albums')`, name: "disco-albums", wait: 900 },
{ do: `__click('.dg-tabs button', 'All')`, wait: 800 },
{ do: `__click('.dg-eline', null)`, name: "disco-editions", wait: 800 },
{ do: `__click('.dg-sec .all', null)`, name: "disco-sheet", wait: 900 },
{ do: `__click('.halfsheet .chip', 'A–Z')`, name: "disco-az", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
{ do: `history.back()`, wait: 800 },
/* and the cap, on an artist with 140 records: the grid on the artist
page is a slice with the way down under it, the page itself opens on
the tab that link was showing, and sixty rows extend to all of them */
{ do: `__ss.push('artist', { name: 'Ember Parade', seed: { id: 416, name: 'Ember Parade', picture: null, album_count: 140, fans: 91000 } })`, name: "disco-many-artist", wait: 1800 },
{ do: `__click('.more-link.dg-all', 'Show all')`, name: "disco-many", wait: 1500 },
{ do: `__click('.dg-more', null)`, name: "disco-many-all", wait: 1400 },
{ do: `history.back()`, wait: 800 },
{ do: `history.back()`, wait: 900 },
/* the artist's photo: the page's ⋯ → a picture from this phone becomes
the hero, the page is asked again, then the catalog's own come back */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 },
{ do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 },
/* an artist whose exhaustive page the mock keeps for four seconds: what
is on screen a second and a half in can only be the two fast lanes */
{ do: `__ss.push('artist', { name: 'Halcyon Drift' })`, name: "artist-lanes", wait: 1200 },
{ name: "artist-lanes-page", wait: 4200 },
{ do: `history.back()`, wait: 700 },
{ do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
{ do: `history.back()`, wait: 600 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelector('.cat-song').click()`, name: "net-playing", wait: 5500 },
{ do: `document.querySelector('.mini').click()`, name: "net-sheet", wait: 3000 },
{ name: "net-arrived", wait: 7000 },
/* the now-playing sheet's ⋯: the streamed track saved into the library
(its cache is complete by now, so it files at once) */
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, name: "np-more", wait: 700 },
/* the same cache, copied out as a plain file into a picked folder —
nothing downloaded again, no library row */
{ do: `__click('.halfsheet .row', 'Copy to a folder')`, name: "np-copied", wait: 900 },
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
/* the raw peers: a peer's sheet → their shares browsed → a folder opened
→ one of its files streams */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.net-ask', null)`, name: "net-peers", wait: 1800 },
/* the hub's filters: FLAC only, then a free slot only */
{ do: `__click('.netfilters .chip', 'FLAC')`, name: "net-filter-flac", wait: 500 },
{ do: `__click('.netfilters .chip', 'Free slot')`, name: "net-filter-free", wait: 500 },
{ do: `__click('.netfilters .chip', 'Free slot')`, wait: 300 },
{ do: `__click('.netfilters .chip', 'Any format')`, wait: 400 },
{ do: `document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "peer-file-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelector('.peer-head .peer-menu').click()`, name: "peer-sheet", wait: 700 },
/* the stranger in full: the card their client serves, their picture in
the viewer, what they declared, and the comparison of their share
against this library — a record they have and it doesn't, found */
{ do: `__click('.halfsheet .row', 'About this user')`, name: "peer-screen", wait: 1400 },
{ do: `document.querySelector('.pr-pic').click()`, name: "peer-picture", wait: 900 },
{ do: `history.back()`, wait: 700 },
{ do: `__click('.screen .btn', 'Compare with my library')`, name: "peer-compare", wait: 2200 },
{ do: `__click('.pr-gap', 'Geogaddi')`, name: "peer-gap-open", wait: 1300 },
{ do: `history.back()`, name: "peer-again", wait: 1200 },
{ do: `__click('.screen .btn', 'Forget this comparison')`, name: "peer-forgotten", wait: 1100 },
{ do: `document.querySelector('.pr-head .iconbtn').click()`, name: "peer-sheet-again", wait: 700 },
{ do: `__click('.halfsheet .row', 'Browse their files')`, name: "browse", wait: 1400 },
{ do: `__click('.brw-dir .peer-folder', 'Homework')`, name: "browse-dir", wait: 600 },
{ do: `__click('.brw-dir .peer-file', 'Aerodynamic')`, name: "browse-play", wait: 2500 },
{ do: `history.back()`, wait: 700 },
/* a catalog record: a row plays through the resolver with the rest of
the record queued behind it, the next track finds its own copy on
its turn, and the disc plays the whole record from one folder */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 },
{ do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 },
/* the two guest rows the record's own anchor had no count for */
{ name: "cat-album-listeners", wait: 900 },
/* the record page's own sheet from the bar */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 },
{ do: `__ss.next()`, name: "cat-album-next", wait: 7000 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "cat-album-disc", wait: 6500 },
/* Save: the best folder by the download rules, every file fetched */
/* one track: the long press's sheet, Save to library */
{ do: `document.querySelectorAll('.tracks .trk')[3].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-track-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "cat-track-save", wait: 3500 },
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); })()`, wait: 500 },
{ do: `document.querySelector('.rec-acts .save').click()`, name: "cat-album-save", wait: 4500 },
/* the burst on the Transfers screen, reached from You */
{ do: `__settle()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 },
/* the shade's copy of this screen, driven from the store itself: two
rows written straight in — nothing fetched — so the notice is posted
with a count and the file it is on, and Cancel all is on the head;
then the rows are taken away again and the notice must go with them */
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 1300 },
/* 2600 ms on purpose: the album burst drains 3.4-5.3 s after it starts
and this lands ten seconds after the Save, so "nothing in flight" is
settled state rather than a race against a mock still finishing */
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2600 },
{ do: `history.back()`, wait: 600 },
/* Gaps: the followed shelf, its missing records dimmed */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
{ do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 },
/* Playlists: the shelf (pinned first), a page holding every row kind,
a local row playing, the stream row through the resolver, the
filter, a removal and its undo, the page's sheet — unpin, rename —
a new playlist from the shelf, a record's tracks added to it from
the record's sheet, and its deletion */
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
{ do: `__click('.plgrid .release', 'Late night')`, name: "playlist", wait: 1200 },
{ do: `document.querySelectorAll('.pl-rows .trk')[1].click()`, name: "playlist-playing", wait: 1200 },
{ do: `__click('.pl-rows .trk', 'One More Time')`, name: "playlist-stream", wait: 5000 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 },
/* reordered from the sheet: the second row up, then back down */
{ do: `document.querySelectorAll('.pl-rows .trk')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move up')`, name: "playlist-moved-up", wait: 900 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move down')`, name: "playlist-moved-down", wait: 900 },
/* and dragged: the fourth row lifted and dropped at the top, dragged
back, refused while the filter is on, still opening the sheet when a
press is released without moving, and — with the backend armed to
say no — snapping back and saying so */
{ do: `__drag('.pl-rows .trk', 3, 0)`, name: "playlist-dragged", wait: 1400 },
{ do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-dragged-back", wait: 1400 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 },
{ do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-drag-filtered", wait: 1000 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 },
{ do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `__ssMock.fail('playlist_set_order', 'database is locked')`, wait: 200 },
{ do: `__drag('.pl-rows .trk', 0, 2)`, name: "playlist-drag-refused", wait: 1400 },
{ do: `(function () { __ssMock.fail('playlist_set_order', ''); __ss.player.error = ''; return true; })()`, wait: 400 },
/* the bulk actions from the page's sheet: Add to library files the
streamed row (its cache is complete); Download all finds it cached too */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Add to library')`, name: "playlist-bulk-cached", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
/* the repair: the unknown row finds a library track and takes its identity */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "playlist-unknown-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, name: "playlist-match", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'caribou track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-match-results", wait: 700 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Track 4')`, name: "playlist-matched", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
/* the cover: the custom one removed (the mosaic), then a photo chosen */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove custom cover')`, name: "cover-removed", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Choose cover photo')`, name: "cover-set", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Rename')`, name: "playlist-renamed", wait: 1000 },
{ do: `history.back()`, name: "playlists-again", wait: 900 },
{ do: `__click('.newpl', 'New playlist')`, name: "playlist-new-prompt", wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1200 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 700 },
{ do: `__click('.halfsheet .row', 'New Playlist')`, name: "playlist-added", wait: 1200 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 700 },
{ do: `__click('.plgrid .release', 'New Playlist')`, name: "playlist-filled", wait: 1200 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, name: "playlist-delete-confirm", wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1200 },
/* the import doors: a pasted tracklist reviewed and created with the
unresolved row kept out; then a link — its stream rows verified
against the catalog while the review is up — created as it came */
{ do: `__click('.newpl', 'Import')`, name: "import-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, name: "import-paste-prompt", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nAphex Twin - Track 1\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 2200 },
{ do: `__click('.imp-form .row', 'Keep unresolved')`, name: "import-keep-off", wait: 500 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist from a link')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-link-wait", wait: 500 },
{ name: "import-link-review", wait: 3400 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 },
{ do: `history.back()`, name: "playlists-after-import", wait: 900 },
/* pasted TRACK links: the paste door reads each one off its own page —
the wait line names the door, the warning counts the links */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/aaaaaaaaaaaaaaaaaaaaaa\\nhttps://open.spotify.com/track/bbbbbbbbbbbbbbbbbbbbbb\\nhttps://open.spotify.com/track/cccccccccccccccccccccc'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 500 },
{ name: "import-links-review", wait: 2600 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the folder door: the phone's own folder picker, then the backend's walk */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-review", wait: 2200 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-folder-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
/* a share from another app: the plugin hands the text over when the
app comes to the front, and it lands in the import */
{ do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 },
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
/* the playlist-FILE door, the fifth: the phone's own document picker,
and the picked document's NAME deciding both the format and the
draft's title — then the two answers a picker can give that are not a
file, armed through the mock's fail door */
{ do: `__click('.newpl', 'Import')`, name: "import-file-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-review", wait: 2200 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-file-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
{ do: `__ssMock.fail('plugin:storage|pick_file', 'cancelled')`, wait: 200 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-cancelled", wait: 1400 },
{ do: `__ssMock.fail('plugin:storage|pick_file', 'that file is not on this phone')`, wait: 200 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-refused", wait: 1400 },
/* disarmed and the red dismissed, or every step after this one inherits
both — the toast stays until it is tapped */
{ do: `__ssMock.fail('plugin:storage|pick_file', '')`, wait: 200 },
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, name: "import-file-cleared", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 500 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 week')`, name: "you-keep", wait: 500 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 month')`, wait: 400 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 500 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, wait: 400 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 500 },
{ do: `__click('.screen .row', 'Word-by-word')`, wait: 400 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
/* the desktop's portrait refresh, beside the covers' */
{ do: `__click('.screen .row', 'Refresh artist portraits')`, name: "you-portraits", wait: 900 },
/* downloads on Wi-Fi only: the switch on, the connection made cellular, a download refused; then off */
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-on", wait: 400 },
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 },
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-off", wait: 400 },
/* the listening port: typed in, saved, read back off the config — then
a port below 1024 refused and the saved one left standing */
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50555'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 700 },
{ do: `__click('.screen .row', 'Listening port')`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 600 },
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
/* the dev flavour's diagnostics — the desktop's Ctrl+Shift+D overlay as
a screen; a normal build has no row here to tap */
{ do: `__click('.screen .row', 'Diagnostics')`, name: "diag", wait: 1500 },
{ do: `history.back()`, name: "diag-back", wait: 800 },
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 500 },
/* the tab again pops its stack to the root */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.sfield .iconbtn', null)`, name: "search-recent", wait: 600 },
{ do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1400 },
{ do: `__settle()`, wait: 600 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
/* the page overflow → the sleep timer */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 },
{ do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 },
/* ── the phone turned over. A resize, so the app keeps playing and keeps
its stack; what changes is the layout, and every one of these asserts
something that was clipped or unreachable at 915x411 before. ────── */
{ view: [915, 411], name: "landscape", wait: 900 },
{ do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
{ do: `__ss.closeSheet()`, wait: 800 },
/* a record sideways: the cover beside the title, Play in view without a
scroll, and a long sheet over it — the track's, 473px of rows and
facts — capped taller than portrait's 76% and still scrolling to its
last row. (Home's own overflow sheet is 212px tall at either cap and
would have proved nothing.) */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 },
{ do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
/* and the artist: the hero a banner rather than 300px of a 411px screen,
so it stops covering its own Play button with the mini bar */
{ do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 },
/* the side insets, faked: sideways the cutout and the gesture pill come
in from the edges, and env() cannot be emulated here — the plumbing can */
{ do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 },
{ do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 },
/* and back, which is also the override reset: portrait must be exactly
what it was before any of this */
{ view: [411, 914], wait: 800 },
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
{ do: `__ss.closeSheet()`, wait: 600 },
/* ── the weather ────────────────────────────────────────────────────
Everything above this line is the happy path; nothing in thirty
rounds of walk had ever made a command reject or taken the client
offline, so none of these lines had ever been drawn. The mock's own
doors (__ssMock.fail / answer / offline / clear) are the weather, and
every assertion below is on SETTLED state — an armed failure stays
armed, a sign-in card stays up, the folder-gone row stays — because
the probe lands after the screenshot, well past the step's own wait.
The one timed assertion is the room list, and its timeout is shortened
to 1.2 s against the 3.1 s of waits between arming it and reading it.
The last step puts the weather away and
leaves the app exactly as it found it — anything appended after this
block inherits a signed-in app on Home.
The first step settles FIRST, and it has to: the landscape block above
leaves the library tab three screens deep (record → artist), so one tap
on the Library tab lands on the artist page and `.segtabs button` would
be its discography tabs, not Albums/Artists/Tracks. It also wipes any
error the happy path left standing, so `!i.toast` below means this
block's toast and no other. */
{ do: `(function () { __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 },
{ do: `__ssMock.fail('library_track_index', 'unable to open database file')`, wait: 300 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "weather-tracks", wait: 1300 },
{ do: `__ssMock.fail('library_track_index', '')`, wait: 200 },
{ do: `__click('.imp-foot .btn', 'Try again')`, name: "weather-tracks-ok", wait: 1300 },
/* signed out, in a lift: the card is back and nothing says why in Rust */
{ do: `__ssMock.offline(true)`, wait: 600 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "weather-signedout", wait: 1100 },
/* the single most visible raw-Rust site on the phone, twice over */
{ do: `__ssMock.fail('connect_and_login', 'login failed: INVALIDPASS')`, wait: 200 },
{ do: `(function () { var p = document.querySelectorAll('.signin .field')[1]; p.value = 'hunter2'; p.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, wait: 400 },
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-badpass", wait: 1200 },
{ do: `__ssMock.fail('connect_and_login', 'could not reach server: io: failed to lookup address information: no address associated with hostname')`, wait: 200 },
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-noserver", wait: 1200 },
/* Search signed out with a query typed showed NOTHING where the network
section lives — no row, no reason. Now it says so and offers the door */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, name: "weather-search-out", wait: 1800 },
/* the room list is fire-and-forget over the wire (the client sends it and
nothing answers if the server will not), so the wait is ours: an armed
answer that resolves and emits nothing is exactly that server. Community
is left on Buddies by the walk above, so the Rooms tab is asked for by
name — the note lives under that one. */
{ do: `__ssMock.fail('connect_and_login', ''); __ssMock.offline(false)`, wait: 1200 },
{ do: `(function () { __ss.slsk.roomsWaitMs = 1200; __ss.slsk.rooms = []; __ssMock.answer('slsk_room_list', null); __ss.refreshRooms(); return true; })()`, wait: 200 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, wait: 900 },
{ do: `__click('.segtabs button', 'Rooms')`, name: "weather-rooms-late", wait: 2200 },
/* one tap on Rescan library used to empty the whole index and say Ok */
{ do: `__ssMock.fail('rescan_library', 'library-folder-missing: /storage/1A2B-3C4D/Music')`, wait: 200 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
{ do: `__click('.screen .row', 'Rescan library')`, name: "weather-rescan", wait: 1500 },
/* and the weather clears */
{ 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 faces no walk had seen ─────────────────────────────────────
Round thirty-one gave six screens an honest failure and its own
verifier was straight about what it had shipped unobserved: Home's
`catalogDown`, the lyrics `failed` note, Gaps' `gapErr`, Playlist's
`loadErr`, the catalog record's and the artist page's stubs, and
`failed · <sentence>` on a transfer row. It could drive none of them
because neither `loadHome` nor `loadLyrics` was reachable from the
`__ss` handle — both are gated on `started` / `key` and answer a
second ask with nothing at all, so no amount of tapping re-runs
them. The handle carries the two stores and their two loaders now,
and this block is what they were added for.
It sits HERE, before the first run, and not at the end of the list:
the block below reloads the page twice, and the mock's follows live
in a module-level `let` that a reload empties — after `fresh-off`
nobody is followed, and Home's `catalogDown` (which needs a follow)
and the Gaps shelf (which IS the follows) are both structurally
unreachable. Here the block inherits what the weather left behind:
signed in, on Home, Daft Punk followed, a track on the mini bar.
Every assertion is on settled state — an armed failure stays armed,
an error stub stays up, the toast stays until it is dismissed — and
the two forced loads return their promise, which `cdp.evaluate`
awaits, so the fetch is over before the step's wait even begins.
Every failure is disarmed by the step that proves the face heals,
and `faces-clear` is the proof that nothing was left armed. */
{ do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 },
/* Home with every follow's catalog page refused. `home.svelte.js` has
always set `slot.error` there; until round thirty-one nothing read
it, and until this step nothing had ever seen the line that does. */
{ do: `(function () { __ssMock.fail('catalog_artist', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadHome(true); })()`, name: "faces-home-down", wait: 1200 },
/* the same armed failure, on the other screen that asks the same
command: Gaps. Home stays poisoned through these three steps on
purpose — it is asserted above, and the second tap below is what
clears the weather for both. */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `__click('.segtabs button', 'Gaps')`, wait: 700 },
{ do: `__click('.gapline', null)`, name: "faces-gap-down", wait: 1400 },
/* a failed lookup leaves `gapRows[key]` undefined, so the second tap
genuinely asks again rather than merely opening a rail */
{ do: `__ssMock.fail('catalog_artist', '')`, wait: 300 },
{ do: `__click('.gapline', null)`, name: "faces-gap-again", wait: 1600 },
/* a catalog record. The walk has already made 302127 `ready` and
`lookupAlbum` returns early on anything but an error, so the entry is
forgotten first — the smallest honest way to say "ask again". */
{ do: `(function () { delete __ss.catalog.albums[302127]; return __ssMock.fail('catalog_album', 'io: connection reset by peer'); })()`, wait: 300 },
{ do: `__ss.push('catalogAlbum', { id: 302127, artist: 'Daft Punk', title: 'Discovery' })`, name: "faces-album-down", wait: 1500 },
{ do: `__ssMock.fail('catalog_album', '')`, wait: 300 },
{ do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-album-again", wait: 1600 },
{ do: `__ss.back()`, wait: 700 },
/* the artist page has TWO of these and they say different things: a
lookup that FAILED is "didn't answer", one that came back empty is
"had nothing to add". `bestArtistFor` is what has to fail for the
first — failing `catalog_artist` alone never reaches the page at all
and leaves the status at none, which is the other copy. */
{ do: `__ssMock.fail('catalog_search', 'io: failed to lookup address information: no address associated with hostname')`, wait: 250 },
{ do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-down", wait: 1600 },
{ do: `(function () { __ssMock.fail('catalog_search', ''); __ss.back(); return true; })()`, wait: 700 },
{ do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-none", wait: 1600 },
{ do: `__ss.back()`, wait: 700 },
/* a playlist that will not read. Id 1 survives the whole walk — it is
the one renamed to "Late night drive II"; the one deleted was New
Playlist. Try again clears `loadErr` and bumps `again`, which is
what re-arms the effect. */
{ do: `__ssMock.fail('playlist_items', 'unable to open database file')`, wait: 250 },
{ do: `__ss.push('playlist', { id: 1 })`, name: "faces-playlist-down", wait: 1400 },
{ do: `__ssMock.fail('playlist_items', '')`, wait: 300 },
{ do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-playlist-again", wait: 1400 },
{ do: `__ss.back()`, wait: 700 },
/* the lyrics face. Sheet.svelte asks with the UN-forced `loadLyrics()`,
which returns at once while `lyr.key` is already this track's — so
the face alone can never ask again, which is exactly why round
thirty-one could not drive this one. The loader, forced. */
{ do: `(function () { __ss.openSheet(); return true; })()`, wait: 900 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, wait: 900 },
{ do: `(function () { __ssMock.fail('lyrics_get', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadLyrics(true); })()`, name: "faces-lyrics-down", wait: 1200 },
{ do: `(function () { __ssMock.fail('lyrics_get', ''); return true; })()`, wait: 250 },
{ do: `__ss.loadLyrics(true)`, name: "faces-lyrics-again", wait: 1200 },
{ do: `(function () { document.querySelectorAll('.fbtn')[0].click(); __ss.closeSheet(); return true; })()`, wait: 700 },
/* a transfer that was refused, written straight into the store the way
the shade steps above do — nothing is fetched, and `failed` is in
DONE, so `xfernote.shape()` sees nothing in flight and posts no
notice. The id is far above the backend's own counter, and `list()`
sorts id-descending, so this row is the first one on the screen. */
{ do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "faces-xfer-failed", wait: 1300 },
{ do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, wait: 800 },
/* the one toast action round thirty-one armed that no step had ever
pressed. `lib.error` is written by `loadLibrary` alone —
`watchLibrary`'s own reload swallows its failure — so You → Rescan
library is the only door in the app that reaches it twice. */
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
{ do: `__ssMock.fail('library_albums', 'unable to open database file')`, wait: 250 },
{ do: `__click('.screen .row', 'Rescan library')`, name: "faces-library-toast", wait: 1500 },
{ do: `__ssMock.fail('library_albums', '')`, wait: 300 },
{ do: `__click('.toast:not(.notice) .na', 'Try again')`, name: "faces-library-again", wait: 1400 },
/* and all of it put away, so the first run below starts exactly where
it would have started without this block at all */
{ do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; __settle(); return __ss.loadHome(true); })()`, wait: 900 },
{ name: "faces-clear", wait: 1200 },
/* ── 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 },
/* ── the playlist export, three ways ──────────────────────────────
On the provisioned device the block above deliberately put back, and
on the pristine fixture its reload restored. The file is built in
Rust by the desktop's own writers and written by whichever door the
platform has, so the only thing that comes back — and the only thing
the phone can show — is where it went. */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'CSV')`, name: "export-written", wait: 1000 },
/* a sheet dismissed: the command answers null and the phone says
nothing, because nothing happened. The notice from the step above is
cleared by hand rather than waited out — note() lives 3.2 s, longer
than any wait here, and a wait that had to outlast it would be the
fragile kind. */
{ do: `__ssMock.answer('playlist_export', null); __ss.notice.text = ''; true`, wait: 300 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-cancelled", wait: 1000 },
/* and a refusal — what Android answers when the document is somewhere
the app cannot write — read back as a sentence rather than as Rust */
{ do: `__ssMock.forget('playlist_export'); __ssMock.fail('playlist_export', 'permission denied (os error 13)'); true`, wait: 300 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
{ 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"),
community: (i) => i.title.startsWith("Community"),
buddies: (i) => i.title.startsWith("Community"),
"buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText) && /Name and notes/.test(i.halfText),
"user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
/* the local contact metadata: the editor, the petname reaching the
backend, the mark sticking, the buddy list rearranged — and the
cleared entry dropped rather than stored empty */
"contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere",
"contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "),
"contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText),
"contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite",
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4 && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"taste-declared": (i) => /Boards of Canada/.test(i.likes) && i.similar === 5,
"taste-hated": (i) => i.hates === "loudness war|loudness war 2",
"taste-dropped": (i) => !/Boards of Canada/.test(i.likes) && i.similar === 4 && !i.toast,
"taste-peer": (i) => i.half.includes("up") && /hifi_hank/.test(i.halfText),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit" && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
"you-norm-off": (i) => i.norm === "off" && i.a11yState === "",
"you-norm-on": (i) => i.norm === "on",
"you-lossy": (i) => i.srcMax === "Lossy only",
"you-keep": (i) => i.keep === "1 week",
"you-slots": (i) => i.slots === "3",
"you-words": (i) => i.wordfill === "on",
"you-artedge": (i) => i.artEdge === "1000 px",
"you-portraits": (i) => /portraits refreshed/.test(i.notice),
"wifi-on": (i) => i.wifiOnly === "on",
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
"wifi-off": (i) => i.wifiOnly === "off" && !i.toast,
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port),
"you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port),
"diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1,
"diag-back": (i) => i.title.startsWith("You"),
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1,
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
"sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"),
/* ── the weather ────────────────────────────────────────────────────
Each of these fails if the round's fix is undone: the stub if a failed
read goes back to being drawn as an empty library, the sign-in lines if
the toast or the card stops translating, the search note if the block
goes back to rendering nothing at all signed out, the room note if the
spinner loses its end, the folder row if the rescan stops refusing.
Every one also asserts the RAW string is gone — that is the round. */
"weather-tracks": (i) => /^Couldn't read the library index/.test(i.why) && /library folder isn't readable/.test(i.why)
&& i.again && i.trks === 0 && !/database file/.test(i.why + i.toast),
"weather-tracks-ok": (i) => !i.why && !i.again && i.trks > 0 && !i.toast,
"weather-signedout": (i) => i.slskOn === "offline" && i.signin && !i.signinErr && !i.toast,
"weather-badpass": (i) => i.signinErr === "Soulseek refused that password." && !/INVALIDPASS/.test(i.signinErr),
"weather-noserver": (i) => /^Couldn't reach the Soulseek server/.test(i.signinErr) && !/io:|address information/.test(i.signinErr),
"weather-search-out": (i) => i.title.startsWith("Search") && /Community/.test(i.offlineNote) && !i.netAsk,
"weather-rooms-late": (i) => i.slskOn === "online" && /hasn't sent its room list/.test(i.roomsNote) && i.again,
"weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast)
&& !/library-folder-missing/.test(i.toast + i.folderGone),
"weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title),
/* ── the faces no walk had seen ─────────────────────────────────────
Each of these fails if round thirty-one's fix is undone: stop reading
`slot.error` and Home's line empties; drop the `gapErr` branch and
the Gaps line falls back to "See what's missing" and can be tapped
for ever; put `lyr.failed` back and the face says "No lyrics found"
about a lookup that never answered; take `say()` out of `stateLine`
and the raw string is on the row again. Every one asserts the
sentence a person reads AND that the Rust string is not in it — the
second half is the round-thirty-one rule, and the half that survives
any rewording of the first. */
"faces-home-down": (i) => i.homeSlots > 0 && i.homeErr === i.homeSlots
&& /^Couldn't reach the catalog, so there's nothing new/.test(i.catDown)
&& !/New from your artists/.test(i.secs),
"faces-gap-down": (i) => /Couldn't reach the catalogue — tap to try again/.test(i.gapLine) && !i.gapCards,
"faces-gap-again": (i) => /more released/.test(i.gapLine) && !/Couldn't reach/.test(i.gapLine) && i.gapCards >= 3,
"faces-album-down": (i) => /^The catalog did not answer/.test(i.why) && /The network didn't answer/.test(i.why)
&& !/io:|connection reset/.test(i.why) && i.again && !i.trks,
"faces-album-again": (i) => !i.why && !i.again && i.trks > 0,
/* the pair that proves the distinction is real and not decoration */
"faces-artist-down": (i) => /only what this phone holds is here/.test(i.catNote) && /didn't answer/.test(i.stubText)
&& !/io:|address information/.test(i.catNote + i.stubText),
"faces-artist-none": (i) => !i.catNote && /had nothing to add/.test(i.stubText) && !/didn't answer/.test(i.stubText),
"faces-playlist-down": (i) => /^Couldn't read this playlist/.test(i.why) && /library folder isn't readable/.test(i.why)
&& i.again && !i.plrows && !/database file/.test(i.why),
"faces-playlist-again": (i) => !i.why && !i.again && i.plrows > 0,
"faces-lyrics-down": (i) => i.lyrFailed === "yes" && !i.lyrLines && /^Couldn't look up the words/.test(i.lyrNote)
&& i.lyrTools && !/io:|address information/.test(i.lyrNote),
"faces-lyrics-again": (i) => i.lyrFailed === "no" && i.lyrLines >= 3 && !i.lyrNote,
"faces-xfer-failed": (i) => i.title.startsWith("Transfers")
&& /failed · They refused the transfer \(File not shared\)/.test(i.xferFail)
&& !/transfer rejected/.test(i.xferFail),
"faces-library-toast": (i) => /library folder isn't readable right now/.test(i.toast) && i.retry === "Try again"
&& !/database file/.test(i.toast),
"faces-library-again": (i) => i.title.startsWith("You") && !i.toast && !i.retry,
/* the disarm, proved: no toast, no armed action, no stub, Home's slots
healthy and its rails back — so the first run below is not quietly
running on something this block left standing */
"faces-clear": (i) => !i.toast && !i.why && !i.catDown && !i.retry
&& i.homeSlots > 0 && i.homeErr === 0
&& /New from your artists/.test(i.secs) && /^(Good |Still)/.test(i.title),
/* sideways: the chrome budget, then every surface that was clipped. The
before-value of each is in the rig's now.json — 46% of the height as
chrome, a 879px cover with the transport 687px below the fold, a
lyrics face collapsed to nothing, a 300px hero, and the record's own
Play button off the bottom of a 411px screen. */
landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title),
"landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0,
"landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0,
/* the queue face by name, not by its rows: the track this walk is on is
the LAST queueable row of Late night drive (the missing and the
unresolved ones never reach the queue), so there is nothing up next
and `qrows` is 0 here — the assertion is that the queue's own face has
the cover's column, not that something is after this song */
"landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0,
"landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn,
"landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0,
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState
/* and the boot budget, read off __ss.boot. Each clause earns its
place: the census must have counted something (a census that
breaks and reports -1 or -2 would sail through a one-sided
test); it must not have counted the world; the boot chain must
still be going out in one round rather than waiting on itself,
which is the one clause that is not a clock at all — it was 10
before round 32 and is 23 after, so a chain put back in a queue
lands under the floor; the shell must have sealed on a frame
rather than on the 1200 ms fallback; the library must still land
inside the launch rather than behind something slow; and round
12's eight-second art prewarm must still be nowhere near boot.
The call ceiling is deliberately loose — the boot chain issues
about 25 commands and the first screen asks for its covers on
top, and the point there is to catch a round that pulls a
per-artist walk or an index to boot, not to police a handful of
calls. */
&& i.bootCalls >= 14 && i.bootCalls <= 60
&& i.bootSync >= 20
&& i.bootMs >= 0 && i.bootMs < 1200
&& i.bootLibMs >= 0 && i.bootLibMs < 600
&& !/library_art_prewarm/.test(i.bootCmds),
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
"search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
album: (i) => i.screen.includes("tr-push") && !i.mini && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle),
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
"sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow && i.syStrip === 64 && /Estimate the timing/.test(i.syEst),
"sync-estimated": (i) => i.sync && i.syCount === "3 / 3" && i.syTicks === 3 && /Estimated/.test(i.syHint) && /Clear the estimate/.test(i.syEst),
"sync-cleared": (i) => i.sync && i.syCount === "0 / 3" && i.syTicks === 0 && /Estimate the timing/.test(i.syEst),
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
"sync-resumed": (i) => i.sync && i.syCount === "1 / 3",
"sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow),
"sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast,
/* the save's own payload carries no auto-sync flag: the switch kept its */
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
viewer: (i) => i.viewer && /^button\/-\/Close artwork viewer/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
"track-sheet": (i) => i.half.includes("up") && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"after-back": (i) => !i.sheet.includes("up"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
"queue-moved-up": (i) => !i.half.includes("up") && !!i.qfirst && i.qrows === globalThis.__qrows - 1,
"library-again": (i) => i.screen.includes("tr-pop"),
/* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming
record leads), the news, the back catalogue, recommendations,
complete the set, following — the desktop's page */
"home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs),
room: (i) => i.screen.includes("tr-push") && i.msgs >= 2 && !i.a11yAnon && !i.a11yBad,
"rooms-filtered": (i) => i.rooms === 1,
"rooms-more": (i) => i.rooms >= 95,
/* the artist with a picture shows it on the hero and its card; the
namesake under Deezer's empty-hash placeholder shows the app's own */
"cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1,
"cat-song-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to record/.test(i.halfText),
"cat-record-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Go to record/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2,
"cat-artist-follow": (i) => i.fb.includes("Following"),
"cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,
/* the whole discography: All in front of the six categories the eleven
mock rows file into, the ten families they fold to, a year header per
year, the desktop's own summary line and the order the head shows.
Two of those rows are dated relative to today (dayIso(-9), dayIso(21)),
so a run across a New Year splits their year in two — hence the range
rather than a number. */
"disco-all": (i) => i.dgTabs === 7 && i.dgRows === 10 && i.dgGroups >= 8 && i.dgGroups <= 9
&& /^10 releases/.test(i.dgSum) && /Newest/.test(i.dgOrder)
&& !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"disco-albums": (i) => i.dgRows === 4 && i.dgGroups === 4,
/* Discovery and its Deluxe — the one edition_group in the mock, and the
thing a flat list of releases loses */
"disco-editions": (i) => i.dgEds === 2 && i.dgRows === 10,
"disco-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText),
/* the rule this page has to get right: a name order cannot be grouped by
year, so the grouping falls away with it */
"disco-az": (i) => i.dgFirst === "Alive 1997" && i.dgGroups === 0 && /A–Z/.test(i.dgOrder),
"disco-many-artist": (i) => i.title.startsWith("Ember Parade") && i.disco === 12 && /Show all 140/.test(i.dgAll),
"disco-many": (i) => i.dgRows === 60 && /\+ 80 more/.test(i.dgMore),
"disco-many-all": (i) => i.dgRows === 140 && !i.dgMore,
"cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4,
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText),
"artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice),
"artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix,
"artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),
/* the exhaustive page has NOT landed; the banner and the first four
releases have — three albums on the Albums tab, a single on its own */
"artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2,
/* and the page wins when it does land: the classified six */
"artist-lanes-page": (i) => /^ready:/.test(i.artFast) && i.heroPix && i.disco === 6 && i.tabs === 2,
"net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live),
"net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
"net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"),
"np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"np-copied": (i) => !i.half.includes("up") && /1 file copied/.test(i.notice),
"np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
"net-filter-flac": (i) => i.peers === 3,
"net-filter-free": (i) => i.peers === 2,
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),
"peer-screen": (i) => i.title.startsWith("hifi_hank") && /Ripping my own vinyl/.test(i.peerDesc) && i.peerPic && i.peerTags === 6 && /slot is free/.test(i.peerLine) && /served/.test(i.peerLine) && i.peerGaps === 0,
"peer-picture": (i) => i.viewer,
"peer-compare": (i) => i.peerGaps === 6 && /in your library/.test(i.peerSum),
"peer-gap-open": (i) => i.title.startsWith("Search") && i.sq === "Boards of Canada Geogaddi",
"peer-again": (i) => i.title.startsWith("hifi_hank") && i.peerGaps === 6,
"peer-forgotten": (i) => i.peerGaps === 0 && !i.peerSum && /forgotten/i.test(i.notice),
"peer-sheet-again": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && !/About this user/.test(i.halfText),
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
"browse-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10 && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
"cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14,
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs),
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
/* xfnMax >= 5 can only have come from the real album burst, and it is
monotonic, so it is not a race; the title and the line come from the
two injected rows (990001 is the newest transferring one, so it is
deterministically the current file); xfnBad === 0 proves the call
reached a handler instead of being swallowed */
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13,
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn,
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
"library-by-format": (i) => /^FLAC 24/.test(i.firstBadge),
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3,
playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter,
"playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast,
"playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
"playlist-filter": (i) => i.plrows === 1,
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice) && /Removed/.test(i.announce) && /Undo/.test(i.announce),
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
/* the order after a drop, from the store the page publishes — which is
what the backend kept, the page having re-read its rows by now */
"playlist-dragged": (i) => i.plOrder === "Track 4|Track 1|Track 2|Track 3|One More Time|ID" && i.plfirst.startsWith("Track 4"),
"playlist-dragged-back": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plfirst.startsWith("Track 1"),
/* a filtered list's visible order is not the stored one, so the drag is
refused outright and the note under the field says why */
"playlist-drag-filtered": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plrows === 4 && /Clear the search/.test(i.plNote),
/* pressed and released without moving: still the row's sheet */
"playlist-drag-held": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
/* the optimistic order snapped back, and the app said so */
"playlist-drag-refused": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && !!i.toast,
"queue-drag-arm": (i) => { globalThis.__q0 = i.qOrder; return i.qrows >= 3 && !!i.qOrder; },
"queue-dragged": (i) => { const a = String(globalThis.__q0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); },
"queue-dragged-back": (i) => !!i.qOrder && i.qOrder === globalThis.__q0,
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"playlist-match": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText) && !/Remove from playlist/.test(i.halfText),
"playlist-match-results": (i) => /Track 4/.test(i.halfText) && /Caribou/.test(i.halfText),
"playlist-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice),
"playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
"cover-removed": (i) => !i.plcover && /mosaic/.test(i.notice),
"cover-set": (i) => i.plcover && /Cover set/.test(i.notice),
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
"playlist-renamed": (i) => i.title.startsWith("Late night drive II"),
"playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames),
"playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal) && !i.a11yAnon,
"playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0,
"playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText),
"playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice),
"playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5,
"playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames),
"import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText) && /Folder of audio files/.test(i.halfText),
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "" && !i.a11yAnon,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn),
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2,
/* the wait card is transient: the still can take longer than the mock's fetch */
"import-link-wait": (i) => i.title.startsWith("Import") && (i.impWait || i.imp === 5),
"import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn),
"import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5,
"playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,
"import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine),
"import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn),
"import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4,
"share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5,
"share-cancel": (i) => i.title.startsWith("Your library"),
"import-file-sheet": (i) => i.half.includes("up") && /Playlist file/.test(i.halfText),
/* impSrc is the round's own assertion, and the "#" is the whole of it:
the cache copy on disk is "Warehouse set _3.m3u8", so a draft read
off the PATH says that and a draft read off the picked document's
display name says this. Both this and the title below go wrong the
moment the name stops travelling beside the path. */
"import-file-review": (i) => i.title.startsWith("Import") && /M3U/.test(i.impFmt) && i.impSrc === "Warehouse set #3.m3u8"
&& i.imp === 3 && /1 in library/.test(i.impCounts) && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-file-created": (i) => i.title.startsWith("Warehouse set #3") && i.plrows === 3,
"import-file-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"import-file-refused": (i) => !i.half.includes("up") && /not on this phone/.test(i.toast),
"import-file-cleared": (i) => !i.toast,
"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 playlist export ──────────────────────────────────────────
Where the file went is the only thing the command answers, so the
notice naming it IS the feature; a dismissed sheet has to leave the
screen exactly as quiet as it found it; and a refusal has to arrive
as a sentence, because "permission denied (os error 13)" is the one
string Android hands back when the document is out of reach. */
"export-open": (i) => i.title.startsWith("Late night drive") && i.plrows >= 1 && !i.toast,
"export-sheet": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
"export-written": (i) => !i.half.includes("up") && /Exported as .+\.csv/.test(i.notice) && !i.toast,
"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 */
const albumText = ALBUM ? JSON.stringify(ALBUM) : "null";
const PHONE_STEPS = [
{ do: `__settle()`, wait: 900 },
{ name: "home", wait: 300 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
/* the Library root remembers its tab across runs — back to Albums first */
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `__click('.grid2 .release', ${albumText})`, name: "album", wait: 1400 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 3200 },
{ do: `document.querySelector('.gbar .iconbtn').click()`, name: "library-ambience", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-ambience", wait: 1200 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1400 },
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 1200 },
{ do: `history.back()`, name: "viewer-closed", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
/* and the queue, on the real one: a row down two and back up. This
writes the session key and re-arms the preload, nothing else */
{ name: "queue-drag-arm", wait: 400 },
{ do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 1000 },
{ do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 1000 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()`, wait: 700 },
/* auto-sync off and straight back on — the cache entry ends as it began */
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 },
/* the tap room on the real clock: two taps, one undone, then discarded —
nothing is written to Kyle's own lyrics for this track */
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 },
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
/* the real DSP on a real file: the envelope is a whole-file decode, so
give it room. Estimating writes nothing — the room is discarded next */
{ do: `(function () { var b = document.querySelector('.sy-est'); if (b && !b.disabled) b.click(); })()`, name: "sync-estimated", wait: 2500 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 },
{ do: `history.back()`, name: "after-back", wait: 1000 },
/* a reload keeps the audio thread: the session comes back LIVE */
{ do: `location.reload()`, name: "after-reload", wait: 4000 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'the'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 1200 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 1200 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
/* the artist page: the library's shell at once, the catalog dissolving in */
/* button.row: the page header is a .row too, and it is a div */
/* playlists on the real core: one made here, a record's tracks added
from the record's sheet, a row played, the playlist deleted again —
the phone's library is left as it was */
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
{ do: `__click('.newpl', 'New playlist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Walk test'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1500 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
{ do: `(function () { var all = document.querySelectorAll('.grid2 .release'); var c = null; for (var i = 0; i < all.length; i++) { if (!${albumText} || all[i].textContent.indexOf(${albumText}) >= 0) { c = all[i]; break; } } if (!c) throw new Error('no album card'); c.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 900 },
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 800 },
{ do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
/* dragged on the real core: the second row to the top and straight
back, so the playlist is exactly as it was when it is deleted four
steps from here. Nothing outside the walk's own playlist is touched */
{ name: "playlist-drag-arm", wait: 400 },
{ do: `__drag('.pl-rows .trk', 1, 0)`, name: "playlist-dragged", wait: 2000 },
{ do: `__drag('.pl-rows .trk', 0, 1)`, name: "playlist-dragged-back", wait: 2000 },
/* the walk's own playlist, on the real core: a row's sheet, the row
removed and put back by Undo — the first time that stash has made
the round trip through real SQLite — the same sheet off a real
press, and the filter. Everything here acts on "Walk test", which
the walk made four steps ago and deletes eight steps below, and the
order is the one the drags above restored. */
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 900 },
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1600 },
/* a press released without moving is still the row's sheet — on real
touch geometry this time, not a synthesised contextmenu */
{ do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 },
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 },
/* a filter that matches nothing: the rows go and the stub says so. The
drag-refusal note hangs off the UNFILTERED list, so it is up here
whatever the filter matched — which is what makes this readable
against a playlist made out of whichever record the walk picked. */
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'zzqq'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 700 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter-cleared", wait: 700 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
/* export, on the real core: the file is built by the desktop's own
writers and handed to Android's create-document sheet. That sheet is
a system activity the walk cannot tap, so BACK dismisses it — the
cancel path is what gets walked, and NOTHING is written to this
phone's storage. The playlist is the "Walk test" one the walk itself
makes, and deletes three steps below. */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-door", wait: 800 },
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "export-dismissed", wait: 2000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
/* the paste door on the real core: two lines that resolve to a record
in the library, reviewed, created, deleted again */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 },
/* the review's one switch, which renders only when a line did not
resolve: off, and the button's count drops with it; on again, so the
draft the next step creates is the three rows it has always been.
Found and clicked by hand rather than through `__click`, which
THROWS when the row is not there — a paste that resolved completely
has nothing to switch, and that is not a failure. */
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-off", wait: 700 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-on", wait: 700 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 },
/* the unknown row repaired from the real library */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
/* the pasted-links door on the real core: one track link, and the wait
line says which door read the paste; the review is left as it came */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 },
{ name: "import-links-review", wait: 9000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the folder door on the real core, one record's own folder — the
argument the command grew, walked for real; nothing is created */
{ do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
/* the picker itself is a system sheet the walk cannot tap: open it,
dismiss it with BACK, and check the cancel path leaves nothing */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },
/* the file door's picker, the same way: opened, dismissed with BACK.
Nothing is read, nothing is copied into the cache, nothing is made */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-picker", wait: 2600 },
{ adb: ["shell", "input", "keyevent", "4"], name: "import-file-dismissed", wait: 2000 },
/* the share sheet: a Spotify playlist link sent by "another app" (an
intent on the plugin's activity) lands in the import — the review is
left without creating anything */
{ adb: ["shell", "am", "start", "-a", "android.intent.action.SEND", "-t", "text/plain", "--es", "android.intent.extra.TEXT", "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "-n", "com.soulstream.app/com.soulstream.share.ShareActivity"], name: "share-import", wait: 9000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
/* You: the settings save for real (camelCase args) — the switch and a
chip, each read back from the config, each restored */
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 900 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 900 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, name: "you-slots-back", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words-off", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 },
/* the listening port on the real core, camelCase for real: a spare
number, then 50300 — the client's own default, so the phone ends the
walk on exactly the port an unset config would already have used */
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 900 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50310'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 1200 },
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
/* the shade, for real. The walk starts no download — two rows go
straight into the transfer store, which is the notice's only input —
and the phone's own notification is read back out of dumpsys, then
the rows are taken away and it has to be gone. Nothing is fetched
into Kyle's library, and the ids are far above the backend's counter */
{ do: `__click('.screen .row', 'Transfers')`, name: "shade-open", wait: 1200 },
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 2400, probe: shadeNotice },
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2400, probe: shadeNotice },
{ do: `history.back()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
/* the real config's contact list, read back over the real IPC */
{ do: `__ss.loadContacts()`, name: "contacts-read", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
{ name: "artist", wait: 4500 },
/* the page's ⋯ on the real core: the hero door is there and knows the
artist's id — it opens the system picker, so the walk only looks */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
/* a discography card the library does NOT hold opens the catalog record */
{ do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 },
{ do: `history.back()`, wait: 800 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
/* the translator, on the real build: the backend's own vocabulary in,
plain sentences out. No side effects — nothing is signed out (the
login throttle is a remembered trap), nothing is asked of the network,
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 },
/* the whole discography on the real core, on the same artist the walk
already opened above — so the catalog store is warm and the page has
its releases the moment it is pushed. It reads and it sorts; nothing
is downloaded, played, joined, sent, signed in to or written. */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
{ do: `__click('.screen button.row', null)`, wait: 500 },
{ wait: 2500 },
{ do: `__click('.sec .all', 'All')`, name: "dg-page", wait: 1600 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "dg-sheet", wait: 1000 },
{ do: `__click('.halfsheet .chip', 'A–Z')`, name: "dg-az", wait: 1000 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
{ do: `history.back()`, wait: 800 },
{ do: `history.back()`, wait: 900 },
/* ── what the device had never been asked ──────────────────────────
Every step below was proven in the mock and nowhere else: the
library's own shape and its sort, a library TRACK's sheet (the
phone had only ever held a CARD), Search's memory, a queue row's
sheet, Home's feed, the sleep timer, the stream cache's chip, the
Wi-Fi-only guard and a port the core has to refuse.
It navigates itself from Home, so it needs no particular screen in
front of it, and every write it makes it takes back inside itself:
the Library sort is nav state (the entry's own `c.sort`), the sleep
timer is put out by the step after the one that reads it, the cache
chip and the Wi-Fi switch are read first and restored last, and the
refused port writes nothing at all. Nothing here downloads, joins,
sends, signs in, or touches Kyle's library. */
{ do: `__settle()`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, name: "library", wait: 900 },
{ do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 700 },
{ do: `__click('.chips .chip', 'Recently added')`, wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 900 },
/* Gaps is tolerant on purpose: follows live in the library folder and
the phone's own folder starts without any, so the tab can only be
asked to render and to stay quiet. Opening it asks the network for
nothing — each shelf's lookup is behind that shelf's own tap. */
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 1200 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 600 },
{ do: `document.querySelector('.grid2 .release').click()`, wait: 1600 },
/* a LOCAL track's own sheet, off a real path: Play next, Add to queue,
Add to playlist, and the Format/File/Folder block underneath them.
None of it is tapped — the scrim closes it, the page is popped. */
{ do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 900 },
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 },
{ do: `history.back()`, wait: 800 },
/* Search's memory, which on the device is this WebView's localStorage.
The chips are the walk's OWN earlier query: the field commits on
Enter or when the screen is left, and the walk left it several
screens ago. Clearing the field is what brings the empty page —
and its recents — back up. */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 800 },
{ do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, name: "search-recent", wait: 900 },
{ do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1600 },
{ do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, wait: 700 },
/* a queue row's sheet. The walk has dragged these rows for two rounds
and never opened one. Tolerant: the walked track can be the last
queueable row, and then there is no row to press. Nothing in the
sheet is tapped — its rows all mutate the queue. */
{ do: `(function () { var m = document.querySelector('.mini'); if (m) m.click(); return true; })()`, wait: 1200 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue-face", wait: 900 },
{ do: `(function () { var r = document.querySelector('.q-face .row'); if (r) r.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return !!r; })()`, name: "queue-row-sheet", wait: 900 },
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
{ do: `__ss.closeSheet()`, wait: 700 },
/* Home's own feed on the real core, and then the sleep timer — the one
thing in this block with a life of its own. It is set, read back off
the More sheet's Sleep-timer subtitle (which IS `sleepLabel()`, and
the only proof the phone can give that the timer took), and put out
by the very next step, with nothing between the two: a walk that
died in that gap would leave a pause waiting on his playback — so
it is the SHORTEST chip in the sheet, not a comfortable one. */
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-feed", wait: 1200 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 900 },
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 800 },
{ do: `__click('.halfsheet .chip', '15 min')`, name: "sleep-set", wait: 800 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-armed", wait: 900 },
{ do: `(function () { function pick(sel, re) { return [].slice.call(document.querySelectorAll(sel)).find(function (x) { return re.test((x.textContent || '').trim()); }); } var r = pick('.halfsheet .row', /Sleep timer/); if (r) r.click(); return new Promise(function (res) { setTimeout(function () { var c = pick('.halfsheet .chip', /^Off$/); if (c) c.click(); res(!!c); }, 400); }); })()`, name: "sleep-off", wait: 900 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-cleared", wait: 900 },
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 },
/* You: the stream cache's retention chip, read first and put back
last. The value is stashed IN THE PAGE as well as in the harness,
because a step's script is fixed before the walk starts and cannot
carry the harness's copy. The last chip is never clicked — "Forever"
does not lengthen the cache, it saves streamed tracks into the
library — so the move is to the neighbour above, or below it when
there is no room above. And when Forever is the chip that is ON,
nothing is touched at all: setRetention("forever") sends the FLAG
and not the number, so a move away and back would put the switch
back and leave `stream_retention_days` on whatever the move wrote.
CONDITIONAL, then, on his own setting — and the expectation knows
which case it is in from the label `you-keep-arm` stashed. */
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 900 },
{ name: "you-keep-arm", wait: 500 },
{ do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var at = cs.findIndex(function (c) { return c.classList.contains('on'); }); if (at < 0) return ''; window.__keep0 = cs[at].textContent.trim(); if (at === cs.length - 1) return window.__keep0; var pick = (at + 1 < cs.length - 1) ? cs[at + 1] : cs[at - 1]; if (pick) pick.click(); return window.__keep0; })()`, name: "you-keep", wait: 900 },
{ do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var back = cs.find(function (c) { return c.textContent.trim() === window.__keep0; }); if (back && !back.classList.contains('on')) back.click(); return window.__keep0; })()`, name: "you-keep-back", wait: 900 },
/* the one guard that exists only because this is a phone. The switch
is this device's localStorage, not the config; `cellular()` reads
`navigator.connection`, so a page-level shadow is the whole fake and
a delete is the whole restore — set and removed one step apart, and
a reload would drop it anyway. If defineProperty throws in the
WebView the catch swallows it, the download is allowed, and the step
fails loudly rather than doing anything. The switch goes back to
whatever Kyle had it on. */
{ name: "wifi-arm", wait: 500 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); window.__wifi0 = r.querySelector('.sw.on') ? 'on' : 'off'; if (window.__wifi0 === 'off') r.click(); return window.__wifi0; })()`, name: "wifi-on", wait: 800 },
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 800 },
{ do: `(function () { try { delete navigator.connection; } catch (e) {} var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); __ss.player.error = ''; return true; })()`, name: "wifi-cleared", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); var now = r.querySelector('.sw.on') ? 'on' : 'off'; if (now !== window.__wifi0) r.click(); return window.__wifi0; })()`, name: "wifi-restored", wait: 800 },
/* a port the core has to refuse. Nothing is written, and 50300 — the
client's own default, which the port steps above already left
standing — is still standing afterwards. */
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 1000 },
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 600 },
{ do: `__settle()`, wait: 900 },
/* the one face of the six that a real build can show without arming
anything: `__ssMock` does not exist here. One already-terminal row
goes into the transfer store — the same thing the shade steps above
do — and comes straight back out. Nothing is downloaded, no room is
joined, no message is sent, nobody is signed in, and neither the
config nor the library is written. A `failed` row is not in flight,
so no notice is posted either. The id is far above the backend's own
counter and `list()` sorts id-descending, so it is the first row on
the screen even if Kyle's own session is carrying real failures. */
{ do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "trouble-xfer", wait: 1400 },
{ do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, name: "trouble-xfer-gone", wait: 1000 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
];
const PHONE_EXPECT = {
/* the phone's own launch, held wider on purpose: a real one signs in,
restores a session and may walk the follows, none of which the mock
does, and its library reads are real SQLite rather than an object in
memory. What is asserted here is the shape — a census that counted
something, sealed, still went out in one round, and did not drag the
art prewarm back to boot. The milliseconds are printed rather than
policed: every step's JSON carries bootMs and bootLibMs, so the
device's real figures land in the report the first time this walk
runs. */
home: (i) => i.bootCalls >= 14 && i.bootCalls <= 90 && i.bootSync >= 20 && i.bootMs >= 0 && !/library_art_prewarm/.test(i.bootCmds),
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost,
"album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
/* the walked album's track may genuinely have no words, so the room's
own rows are conditional — the discard is not */
"lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"),
"lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"),
"sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)),
"sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)),
"sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)),
"sync-discarded": (i) => !i.sync && !i.toast,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yGhost,
"after-back": (i) => !i.sheet.includes("up"),
"after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast,
search: (i) => i.title.startsWith("Search"),
"hold-sheet": (i) => i.half.includes("up"),
tracks: (i) => i.title.startsWith("Your library"),
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
"artist-cold": (i) => i.screen.includes("has-hero"),
/* ── round forty: the four steps that could not fail ───────────────
`library-ambience`, `queue`, `lyrics` and `community` have been
named phone steps with no key in this map since the rounds that
wrote them. The driver reads `expect[s.name] ? expect[s.name](info)
: true`, so each one was a screenshot that always passed — four of
eighty-eight. All four have a mock expectation; none had a phone
one. Each is written to hold against Kyle's own library rather than
a fixture. */
"library-ambience": (i) => i.title.startsWith("Your library") && i.amb.includes("on:img"),
queue: (i) => i.sheet.includes("up") && i.qFace,
/* the walked track may genuinely have no words on the device, so this
asks for the face and any one of its three honest states */
lyrics: (i) => i.sheet.includes("up") && (i.lyrLines >= 1 || !!i.lyrNote || i.lyrTools),
/* tolerant of the sign-in card, which is what a signed-out phone shows
here — but NOT of a toast: a sign-in that failed raises one on this
screen, and that is a finding rather than a step to loosen */
community: (i) => i.title.startsWith("Community") && !i.toast,
/* ── the walk's own playlist, on real SQLite ─────────────────────
The row count is stashed by the sheet step rather than assumed: the
playlist is made out of whichever record the walk picked, so its
length is his library's business — and a probe that read nothing
would otherwise pass every clause below it. */
"playlist-row-sheet": (i) => { globalThis.__plN = i.plrows; return i.half.includes("up") && /Remove from playlist/.test(i.halfText); },
"playlist-removed": (i) => i.plrows === globalThis.__plN - 1 && /Removed/.test(i.notice) && /Undo/.test(i.announce),
/* the undo stash round-tripped through the real backend for the first
time: the row is back, in the place it was taken from. `__pl0` is
the order `playlist-drag-arm` stashed before the two drags, which
`playlist-dragged-back` has already put back. */
"playlist-undone": (i) => i.plrows === globalThis.__plN && !i.notice && (globalThis.__plN < 2 || i.plOrder === globalThis.__pl0),
/* CONDITIONAL: `lift()` refuses a list of fewer than two rows, so a
one-track record — which makes a one-row playlist — has no press to
release. The two drag steps above tolerate exactly the same thing. */
"playlist-drag-held": (i) => globalThis.__plN < 2 || (i.half.includes("up") && /Remove from playlist/.test(i.halfText)),
/* CONDITIONAL in its last clause: the drag-refusal note needs more
than one row to drag. The note itself hangs off the UNFILTERED
list, so a filter that matched nothing still shows it. */
"playlist-filter": (i) => i.plrows === 0 && /Nothing here matches/.test(i.stubText) && (globalThis.__plN < 2 || /Clear the search/.test(i.plNote)),
"playlist-filter-cleared": (i) => i.plrows === globalThis.__plN && !i.plNote,
/* ── the review's Keep switch ────────────────────────────────────
CONDITIONAL: the row renders only when something did not resolve,
and a paste that resolved completely leaves `keepOn` false anyway.
The second step is what guarantees `import-created` below still
sees the three rows it has always seen. */
"import-keep-off": (i) => !i.keepOn && /Create playlist \(\d+ tracks?\)/.test(i.impBtn),
"import-keep-on": (i) => /Create playlist \(3 tracks\)/.test(i.impBtn) && i.imp === 3,
/* ── the library's own shape, on the device for the first time ──── */
/* `a11yBad` is left out for the reason `track-sheet` leaves it out: a
card's name is [title, artist, badge] off his own tags, and `junk()`
calls a name of nothing but digits bad — "1999, 311" is a real
record. The mock asserts it against a fixture, where it belongs.
The structural three stay: nameless, ghost and mis-stated. */
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 1 && /hours|min/.test(i.countline)
&& !i.a11yAnon && !i.a11yGhost && !i.a11yState,
/* the sort lives on the Library entry (`c.sort`), not in the config,
so it needs no putting back — the chip after it does that anyway.
What a real library's top format IS cannot be asserted; that the
grid re-sorted and still badges every card can be. */
"library-by-format": (i) => !!i.firstBadge && i.fmtBadges >= 1 && !i.toast && !i.a11yState,
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 1 && !i.toast,
/* CONDITIONAL: follows live in the library folder and the phone's own
folder starts without any, so a phone with no follows can only be
asked for the stub */
gaps: (i) => i.title.startsWith("Your library") && !i.toast && (i.gaplines >= 1 || !!i.stubText),
/* a LOCAL track's sheet: the three things it offers, the file block
underneath it off a real path, and the layer named and holding
focus. `a11yBad` is left out deliberately — it reads the names of
real rows, and a record of his is allowed to be called anything. */
"track-sheet": (i) => i.half.includes("up") && /Play next/.test(i.halfText) && /Add to queue/.test(i.halfText)
&& /Add to playlist/.test(i.halfText) && /Format/.test(i.halfText) && /Folder/.test(i.halfText)
&& /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yGhost,
/* the recents are localStorage on the device, written by the walk's
own earlier search when that screen was left */
"search-recent": (i) => i.title.startsWith("Search") && i.sq === "" && i.recents >= 1,
"search-recent-pick": (i) => i.title.startsWith("Search") && !!i.sq,
"queue-face": (i) => i.sheet.includes("up") && i.qFace,
/* CONDITIONAL: the walked track can be the last queueable row, and
then there is nothing to press. Asserted exactly when there is. */
"queue-row-sheet": (i) => !i.half.includes("up") || (/Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText)),
/* Home's feed on a phone whose library folder holds no follows: the
store answered, and not one of its slots came back with an error */
"home-feed": (i) => /^(Good |Still)/.test(i.title) && !i.toast && i.homeSlots >= 0 && i.homeErr === 0,
"home-more": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText),
"sleep-sheet": (i) => i.half.includes("up") && /Pause after/.test(i.halfText) && /1½ hours/.test(i.halfText),
/* every chip in that sheet goes through `act()`, which closes it — so
this says nothing at all about the timer. The step after it does. */
"sleep-set": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title),
/* the proof: the More sheet's Sleep-timer subtitle IS `sleepLabel()`,
and the only thing that can make it read "N min left" is a
`player.sleepAt` in the future */
"sleep-armed": (i) => i.half.includes("up") && /min left/.test(i.halfText),
"sleep-off": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title),
"sleep-cleared": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText) && !/min left/.test(i.halfText),
/* the retention chip: read, moved, put back. The harness's stash and
the page's own (`window.__keep0`) hold the same value for the same
reason the drag steps keep two — a step's script is fixed before
the walk starts and cannot carry this one. */
"you-keep-arm": (i) => { globalThis.__keepWas = i.keep; return !!i.keep; },
/* CONDITIONAL: the step moves nothing when "Forever" is the chip that
is on, because the way back writes the flag and not the number. The
label is what says which case this is. */
"you-keep": (i) => !!i.keep && (/^Forever/.test(globalThis.__keepWas) ? i.keep === globalThis.__keepWas : i.keep !== globalThis.__keepWas),
"you-keep-back": (i) => i.keep === globalThis.__keepWas,
/* the Wi-Fi-only guard, which exists only because this is a phone */
"wifi-arm": (i) => { globalThis.__wifiWas = i.wifiOnly; return !!i.wifiOnly; },
"wifi-on": (i) => i.wifiOnly === "on",
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
/* the shadow removed and the sentence taken off the bar, with the
switch still where the step above put it */
"wifi-cleared": (i) => !i.toast && i.wifiOnly === "on",
"wifi-restored": (i) => i.wifiOnly === globalThis.__wifiWas,
/* a port the core refuses: nothing written, and the number the walk
already left standing still standing */
"you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50300 /.test(i.port),
"sync-estimated": (i) => !i.toast && (!i.sync
|| (/Clear the estimate/.test(i.syEst)
? i.syTicks > 0 && i.syStrip === 64
: /Estimate the timing|Reading the song|No estimate/.test(i.syEst))),
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax && !i.a11yAnon && !i.a11yGhost && !i.a11yState,
"you-norm-off": (i) => i.norm === "off" && !i.toast,
"you-norm-on": (i) => i.norm === "on" && !i.toast,
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,
"you-nolimit": (i) => i.srcMax === "No limit" && !i.toast,
"you-slots": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-slots-back": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-words": (i) => i.wordfill === "on" && !i.toast,
"you-words-off": (i) => i.wordfill === "off" && !i.toast,
"you-artedge": (i) => i.artEdge === "1000 px" && !i.toast,
"you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast,
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"shade-open": (i) => i.title.startsWith("Transfers") && !i.toast,
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText),
/* the dump has to have been read for the negative to mean anything: one
that came back empty would pass "no downloads channel" on nothing at
all. A format change fails the step above, loudly, first */
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans),
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
"playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast,
"playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,
/* the drags, tolerant of a real library the walk did not choose: a
one-track record makes a one-row playlist, and a one-row list has
nowhere to drag to. Anything longer is asserted exactly, against the
order stashed one step earlier — and the real core may answer faster
than the mock does, so every one of these reads settled state. The
tolerance stops at the rows: once there are enough of them the stash
must be there too, or a probe that reads nothing would pass silently */
"playlist-drag-arm": (i) => { globalThis.__pl0 = i.plOrder; return i.plrows >= 1 && !!i.plOrder; },
"playlist-dragged": (i) => { if (i.plrows < 2) return true; const a = String(globalThis.__pl0 || "").split("|"); if (a.length < 2) return false; const [x] = a.splice(1, 1); a.unshift(x); return i.plOrder === a.join("|"); },
"playlist-dragged-back": (i) => i.plrows < 2 || (!!i.plOrder && i.plOrder === globalThis.__pl0),
"queue-drag-arm": (i) => { globalThis.__pq0 = i.qOrder; return i.qrows < 3 || !!i.qOrder; },
"queue-dragged": (i) => { if (i.qrows < 3) return true; const a = String(globalThis.__pq0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); },
"queue-dragged-back": (i) => i.qrows < 3 || (!!i.qOrder && i.qOrder === globalThis.__pq0),
"playlist-playing": (i) => i.mini && !!i.playing && !i.toast,
"playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 3 && !i.toast,
"import-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"import-match-results": (i) => /Aerodynamic/.test(i.halfText),
"import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast,
"import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast,
/* the real core can answer one track link faster than the probe lands,
so the wait card is allowed to be gone already — but then the review
it turned into has to be there. Either way the door did something. */
"import-links-wait": (i) => i.title.startsWith("Import") && (i.impWait ? /pasted links/.test(i.impWaitLine) : i.imp > 0 || i.impErr),
"import-links-review": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0,
"import-folder-picker": (i) => !i.half.includes("up") && !i.toast,
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"import-file-picker": (i) => !i.half.includes("up") && !i.toast,
"import-file-dismissed": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1
&& /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast),
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
/* eleven real backend strings through the one translator on the device:
every output a sentence, and not one of them still carrying Rust */
"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,
/* the export door on the real core. The sheet the second step opens is
Android's own, so nothing here asserts that it is UP: the core can
build a ten-row M3U8 and hand off before the probe lands, and a step
that demanded the picker would be the one that failed for being too
fast. What is asserted is the app's own half sheet closing, nothing
going red either side of the system sheet, and — after BACK — no
notice, because a dismissed sheet wrote nothing to name. */
"export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
"export-picker": (i) => !i.half.includes("up") && !i.toast,
"export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice,
/* the real core answers faster than the mock does, and it answers with
Kyle's own artists, so none of this asserts a count, a title or a
state with a window shorter than the still: only that the page is
there, listing something, in the order the sheet was just told to
use, and that nothing went wrong saying so */
"dg-page": (i) => i.dgTabs >= 1 && i.dgRows >= 1 && !i.toast && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
"dg-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText) && !i.toast,
"dg-az": (i) => i.dgRows >= 1 && /A–Z/.test(i.dgOrder) && !i.toast,
/* the row's own words on the device, and then its absence. The "gone"
clause is written against THIS row's sentence rather than against
`xferFail` being empty: a real failed transfer of Kyle's sitting in
the list is not a reason to fail the step, and the real core
answering faster than a mock would cannot change either reading —
both are settled state, written and removed by the step itself. The
screen underneath is deliberately not named: sibling rounds append
their own phone steps before `end` too, so whichever screen the back
lands on is theirs, not this round's. What IS asserted is that the
back popped at all — a sheet left up would close instead and the bar
would still say Transfers. */
"trouble-xfer": (i) => i.title.startsWith("Transfers")
&& /failed · They refused the transfer \(File not shared\)/.test(i.xferFail)
&& !/transfer rejected/.test(i.xferFail),
"trouble-xfer-gone": (i) => !/^Transfers/.test(i.title) && !/File not shared/.test(i.xferFail),
end: (i) => /^(Good |Still)/.test(i.title),
};
/* What OUR package has in the shade right now: the channels its live
* notifications sit on, and the downloads one's own title and text.
* `--noredact` is what makes the extras print their values rather than a
* placeholder. Nobody else's notifications are read — `seen` counts the
* dump's records only so that "no downloads channel" cannot pass by the
* dump having come back empty. */
function shadeNotice() {
try {
const dump = adb("shell", "dumpsys", "notification", "--noredact").toString();
const blocks = dump.split("NotificationRecord(").slice(1);
const chans = [];
let title = "", text = "";
for (const block of blocks) {
const head = block.slice(0, 6000);
if (!/pkg=com\.soulstream\.app\b/.test(head)) continue;
/* Notification.toString() carries `channel=<id>` on the record's
own first line; older dumps spell it as the channel object */
let ch = (head.match(/channel=([\w.\-]+)/) || [])[1] || "";
if (!ch || ch === "NotificationChannel") ch = (head.match(/mId='([^']*)'/) || [])[1] || ch;
if (ch) chans.push(ch);
if (ch !== "downloads") continue;
/* the extras print as `<key>=<class> (<value>)`, and the class is
whatever CharSequence the builder kept — String on this one,
but a Spannable on a device that styles it, so the class is
matched loosely and stray quotes are trimmed */
const extra = (key) => {
const m = head.match(new RegExp(key.replace(/\./g, "\\.") + "=[A-Za-z]+ \\(([^\\n]*?)\\)\\s*\\n"));
return m ? m[1].replace(/^"|"$/g, "") : "";
};
title = extra("android.title");
text = extra("android.text");
}
return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text };
} catch (e) {
return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" };
}
}
/* The system's view of the app's media session: who owns it and what the
* playback state says. From `dumpsys media_session`. */
function mediaSessionState() {
try {
const dump = adb("shell", "dumpsys", "media_session").toString();
/* sessions are listed as blocks; find ours by package, then the first
playback state after it */
const at = dump.search(/com\.soulstream\.app/);
if (at < 0) return { owner: "", state: "" };
const block = dump.slice(Math.max(0, at - 400), at + 3000);
const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []);
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };
} catch { return { owner: "", state: "" }; }
}
/* The audio mixer's underrun count for the app's fast track: a dropout the
* ear hears is a number here. Read before and after the walk; a rise during
* it is a finding. */
let appUidCache = null;
function appUid() {
if (appUidCache) return appUidCache;
try { appUidCache = (adb("shell", "dumpsys", "package", "com.soulstream.app").toString().match(/\buid=(\d+)/) || [])[1] || null; } catch { appUidCache = null; }
return appUidCache;
}
/* SoulStream's own tracks only: the mixer's thread counters are system-wide
and any app's notification sound moves them. Each track row of the dump
carries its client "pid/ uid" and an Underruns column (third from the
end, before Flushed and BitPerfect). */
function audioUnderruns() {
try {
const uid = appUid();
if (!uid) return null;
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
let n = 0;
for (const line of dump.split("\n")) {
const m = line.match(/(\d+)\/\s+(\d+)\s+\d+\s+\d+\s/);
if (!m || m[2] !== uid) continue;
const t = line.trim().split(/\s+/);
const u = Number(t[t.length - 3]);
if (Number.isFinite(u)) n += u;
}
return n;
} catch { return null; }
}
async function main() {
fs.mkdirSync(OUT, { recursive: true });
const problems = [];
let cdp = null; // reassigned by the restart phase
let underrunsBefore = null;
let underrunRise = 0; /* summed across the restart: a relaunch starts fresh tracks at zero */
let chrome = null;
let steps, expect, still, prefix;
if (PHONE && ARGS.includes("--only-lock")) {
/* the lock-screen card alone: whatever is on the bar plays, the phone
sleeps, wakes to its lock screen, and that is the still. Leaves the
phone locked. */
const attached = await attachPhone(PORT);
cdp = attached.cdp;
await cdp.send("Runtime.enable");
const st = await cdp.evaluate(`(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, status: p.status } : null; })()`);
if (!st) { console.log("nothing on the bar to show on the lock screen"); process.exit(1); }
if (st.status !== "playing") { await cdp.evaluate(`window.__ss && __ss.toggle()`); await sleep(2500); }
cdp.close();
cdp = null;
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
await sleep(2000);
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
await sleep(2500);
fs.mkdirSync(OUT, { recursive: true });
await phoneStill(path.join(OUT, "phone-lockscreen.png"));
console.log(" -- lockscreen still taken (" + st.title + "); the phone is locked now");
process.exit(0);
}
if (PHONE) {
const attached = await attachPhone(PORT);
cdp = attached.cdp;
const pid = attached.pid;
underrunsBefore = audioUnderruns();
steps = PHONE_STEPS; expect = PHONE_EXPECT; prefix = "phone-";
still = phoneStill;
console.log("attached to the phone's WebView (pid " + pid + ")");
/* A walk follows a fresh install, so the app is always cold here: the
shell mounts, the library loads, Soulseek logs in and the share
index is built. Attaching only proves the WebView answers — it says
nothing about the app being up. Starting anyway means the first
steps run against a half-mounted page and get reported as broken
features, which is exactly how an hour went once. Wait for the
handle the walk actually drives. */
const upFrom = Date.now();
let up = false;
while (Date.now() - upFrom < 90_000) {
try {
if (await cdp.evaluate(`!!(window.__ss && document.querySelector('.app'))`) === true) { up = true; break; }
} catch { /* a page mid-navigation answers nothing; ask again */ }
await sleep(1000);
}
if (!up) throw new Error("the app never came up on the phone (no __ss and no .app after 90s)");
console.log("the app is up (" + ((Date.now() - upFrom) / 1000).toFixed(1) + "s after attaching)");
} else {
if (!CHROME) throw new Error("Chrome not found");
console.log("building dist-smoke/ …");
/* unminified, so a warning's frames name the function */
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./", "--minify", "false"], { cwd: ROOT, shell: true, encoding: "utf8" });
if (b.status !== 0) { console.error(b.stdout, b.stderr); throw new Error("vite build failed"); }
/* a profile per run: a Chrome left behind by an interrupted run keeps
its crashpad file open, and removing that profile throws EBUSY */
const profile = path.join(os.tmpdir(), "ss-smoke-profile-" + process.pid);
for (const d of fs.readdirSync(os.tmpdir())) {
if (d.startsWith("ss-smoke-profile")) {
try { fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true }); } catch { /* still held */ }
}
}
chrome = spawn(CHROME, [
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--mute-audio",
"--allow-file-access-from-files", `--user-data-dir=${profile}`,
`--remote-debugging-port=${PORT}`, "--window-size=411,914", "about:blank",
], { stdio: "ignore" });
cdp = await CDP.attach(PORT);
steps = MOCK_STEPS; expect = MOCK_EXPECT; prefix = "";
still = async (file) => {
const shot = await cdp.send("Page.captureScreenshot", { format: "png" });
fs.writeFileSync(file, Buffer.from(shot.result.data, "base64"));
};
}
try {
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Log.enable");
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
cdp.on["Runtime.consoleAPICalled"] = (p) => {
if (p.type !== "error" && p.type !== "warning") return;
/* the reload step's own consequence: a pending IPC answer with nobody left to receive it */
if (p.args.some((x) => /Couldn't find callback id/.test(String(x.value ?? "")))) return;
const frames = (p.stackTrace?.callFrames || []).slice(0, 5)
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
};
cdp.on["Log.entryAdded"] = (p) => {
if (p.entry.level !== "error") return;
if (/navigator\.vibrate/.test(p.entry.text || "")) return;
problems.push("LOG " + p.entry.text + " " + (p.entry.url || ""));
};
/* after a reload the WebView may present a NEW page target: the old
socket then never answers. Attach again, keeping the handlers. */
const reattach = async () => {
const on = cdp.on;
try { cdp.close(); } catch { /* already gone */ }
const a = PHONE ? await attachPhone(PORT) : { cdp: await CDP.attach(PORT) };
cdp = a.cdp;
cdp.on = on;
await cdp.send("Page.enable");
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", metrics);
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
await sleep(600);
}
for (const s of steps) {
/* a phone step can be an adb command — an intent, a key */
if (s.adb) {
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
}
/* a mock step can turn the phone over: the viewport, through the
same override the walk boots with. A real device's orientation
is not the harness's to set, so a view step on the phone walk
is a failure rather than a no-op. send() resolves with the
whole reply and does not reject on a protocol error, so a
refused override has to be read out of it. */
if (s.view) {
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
else {
try {
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); }
}
}
if (s.do) {
try {
/* the helpers live in the page; a reload step wipes them */
if (s.do.includes("__")) await cdp.evaluate(HELPERS);
await cdp.evaluate(s.do);
} catch (e) { problems.push("STEP " + (s.name || s.do) + ": " + e.message); }
}
await sleep(s.wait || 500);
if (s.do === "location.reload()") {
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`));
const info = await cdp.evaluate(PROBE);
/* a step may want the system's own view as well; dumpsys is slow,
so only the steps that ask for it pay for it */
if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }
const ok = expect[s.name] ? expect[s.name](info) : true;
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));
}
/* ── the restart half of session restore, on the phone only ──────
A reload kept the audio thread (tested above); a real restart
loses it. The track must come back on the bar, paused, and play
must pick it up where it was rather than at zero. */
if (PHONE) {
const PLAYER = `(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, pos: Math.round(p.position), status: p.status, needs: !!p.needsResource } : null; })()`;
const before = await cdp.evaluate(PLAYER);
cdp.close();
if (underrunsBefore != null) { const now = audioUnderruns(); if (now != null) underrunRise += Math.max(0, now - underrunsBefore); }
adb("shell", "am", "force-stop", "com.soulstream.app");
await sleep(800);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
underrunsBefore = null; /* re-read once the relaunch plays */
/* a relaunch restores a PAUSED track, which starts the media service
as a foreground service that must promote itself at once — on
2026-09-13 it did not, and Android killed the app ten seconds
later (a Save died with it). Wait past that window BEFORE
resuming, and the pid must be the same one afterwards. */
adb("logcat", "-c");
await sleep(13000);
underrunsBefore = audioUnderruns();
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
const alive = !!pid2 && !fatal;
if (!alive) problems.push("EXPECT survives-paused-restore " + JSON.stringify({ pid: pid2, fatal }));
console.log((alive ? " ok " : " FAIL ") + "paused-restore".padEnd(16) + " " + JSON.stringify({ pid: pid2 || null, fatal }));
adb("forward", "--remove-all");
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
cdp = await CDP.attach(PORT);
await cdp.send("Runtime.enable");
await still(path.join(OUT, `${prefix}restart.png`));
const after = await cdp.evaluate(PLAYER);
const restored = !!before && !!after && after.title === before.title && after.status === "paused" && after.needs && Math.abs(after.pos - before.pos) <= 15;
if (!restored) problems.push("EXPECT restart " + JSON.stringify({ before, after }));
console.log((restored ? " ok " : " FAIL ") + "restart".padEnd(16) + " " + JSON.stringify({ before, after }));
await cdp.evaluate(`window.__ss && __ss.toggle()`);
await sleep(3500);
const resumed = await cdp.evaluate(PLAYER);
await still(path.join(OUT, `${prefix}resumed.png`));
const okResume = !!resumed && resumed.status === "playing" && !resumed.needs && (!after || resumed.pos >= after.pos - 1);
if (!okResume) problems.push("EXPECT resumed " + JSON.stringify(resumed));
console.log((okResume ? " ok " : " FAIL ") + "resumed".padEnd(16) + " " + JSON.stringify(resumed));
/* ── the media session, end to end ─────────────────────────
The system must hold a session for the app with the track
playing; a media key must reach the player through it; the
audio must keep moving with the app in the background. */
await sleep(1500);
const sess = mediaSessionState();
const sessOk = /com\.soulstream\.app/.test(sess.owner) && /PLAYING/.test(sess.state);
if (!sessOk) problems.push("EXPECT media-session " + JSON.stringify(sess));
console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess));
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
await sleep(1500);
const keyPaused = await cdp.evaluate(PLAYER);
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PLAY");
await sleep(2000);
const keyPlaying = await cdp.evaluate(PLAYER);
const keysOk = keyPaused?.status === "paused" && keyPlaying?.status === "playing";
if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying }));
console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status }));
const bgBefore = await cdp.evaluate(PLAYER);
const posBefore = bgBefore?.pos ?? 0;
cdp.close();
adb("shell", "input", "keyevent", "KEYCODE_HOME");
await sleep(10000);
const stillPlaying = /PLAYING/.test(mediaSessionState().state);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
await sleep(2500);
cdp = await CDP.attach(PORT, { phone: true });
await cdp.send("Runtime.enable");
const bgAfter = await cdp.evaluate(PLAYER);
const posAfter = bgAfter?.pos ?? 0;
const crossed = !!(bgBefore && bgAfter && bgBefore.title !== bgAfter.title);
const bgOk = stillPlaying && (crossed || posAfter >= posBefore + 8);
const bgSeen = { posBefore, posAfter, stillPlaying, crossed, from: bgBefore?.title ?? null, to: bgAfter?.title ?? null };
if (!bgOk) problems.push("EXPECT background " + JSON.stringify(bgSeen));
console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen));
await still(path.join(OUT, `${prefix}shade.png`));
/* ── the network: a search, then a peer's copy played as it
arrives. Only with the app signed in (the stored account signs
in at boot); skipped, not failed, when it is not. ────────── */
const online = await cdp.evaluate(`!!(window.__ss && __ss.slsk && __ss.slsk.connection === 'online')`);
if (!online) {
console.log(" -- network skipped: not signed in on the phone");
} else {
const QUERY = flag("--query") || "daft punk";
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__settle()`);
await sleep(700);
await cdp.evaluate(`document.querySelectorAll('.nav button')[1].click()`);
await sleep(600);
await cdp.evaluate(`(function () { var i = document.querySelector('.sfield input'); i.value = ${JSON.stringify(QUERY)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
const CAT = `(function () { return { songs: document.querySelectorAll('.cat-song').length, records: document.querySelectorAll('.rail .release').length, artists: document.querySelectorAll('.cat-artist').length }; })()`;
let catInfo = null;
for (let i = 0; i < 30; i++) {
await sleep(500);
catInfo = await cdp.evaluate(CAT);
if (catInfo.songs >= 1) break;
}
await sleep(600);
catInfo = await cdp.evaluate(CAT);
await still(path.join(OUT, `${prefix}cat-results.png`));
const resOk = catInfo.songs >= 1;
if (!resOk) problems.push("EXPECT cat-results " + JSON.stringify(catInfo));
console.log((resOk ? " ok " : " FAIL ") + "cat-results".padEnd(16) + " " + JSON.stringify(catInfo));
if (resOk) {
/* the hero's page: an artist's (tabs, Popular) or a record's */
const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (document.querySelector('.gb-t') || {}).textContent || '', tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`;
const topName = (await cdp.evaluate(HERO)).top;
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.top .btn-ghost', null)`);
/* the catalog page lands when the provider answers — polled, since a fixed wait read a slow answer as a failure */
let hero = null;
for (let i = 0; i < 24; i++) {
await sleep(500);
hero = await cdp.evaluate(HERO);
if (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1) break;
}
await still(path.join(OUT, `${prefix}cat-hero-page.png`));
const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1);
if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero));
console.log((heroOk ? " ok " : " FAIL ") + "cat-hero-page".padEnd(16) + " " + JSON.stringify({ top: topName, title: hero.title, tabs: hero.tabs, pop: hero.pop, socials: hero.socs, hero: hero.screen.includes("has-hero") }));
await cdp.evaluate(`history.back()`);
await sleep(1200);
/* the first song, through the resolver: the ladder, a
peer's copy, the lane rising before the file is here */
const STREAM = `(function () { var p = window.__ss && __ss.player; if (!p || !p.track) return null; return { title: p.track.title, from: p.track.stream && p.track.stream.username, status: p.status, phase: p.phase, pos: Math.round(p.position), coverage: +(p.coverage || 0).toFixed(2), received: p.stream && p.stream.received, total: p.stream && p.stream.total, failed: p.stream && p.stream.failed, error: p.error || '', live: (document.querySelector('.mini .m.live') || {}).textContent || '' }; })()`;
await cdp.evaluate(`document.querySelector('.cat-song').click()`);
let st = null;
for (let i = 0; i < 60; i++) {
await sleep(1000);
st = await cdp.evaluate(STREAM);
if (st && st.status === "playing" && st.pos >= 3) break;
if (st && (st.failed || st.error)) break;
}
await still(path.join(OUT, `${prefix}net-playing.png`));
console.log(" stream " + JSON.stringify(st));
await cdp.evaluate(`document.querySelector('.mini').click()`);
await sleep(2500);
await still(path.join(OUT, `${prefix}net-sheet.png`));
const st2 = await cdp.evaluate(STREAM);
const partial = !!st2 && st2.received > 0 && (st2.received < st2.total || st2.coverage < 1 || st2.phase === "rising");
const streamOk = !!st && !!st2 && st.status === "playing" && st2.status === "playing" && !!st2.from && st2.pos >= st.pos && !st2.failed && !st2.error;
if (!streamOk) problems.push("EXPECT net-stream " + JSON.stringify({ st, st2 }));
console.log((streamOk ? " ok " : " FAIL ") + "net-stream".padEnd(16) + " " + JSON.stringify({ from: st2?.from, status: st2?.status, phase: st2?.phase, pos: st2?.pos, coverage: st2?.coverage, received: st2?.received, total: st2?.total, live: st?.live, partialWhileWatched: partial }));
/* the raw peers, on request */
await cdp.evaluate(`document.querySelector('.sheet .np-head .iconbtn').click()`);
await sleep(700);
await cdp.evaluate(`document.querySelector('.net-ask').click()`);
const NET = `(function () { return { peers: document.querySelectorAll('.peer').length, files: document.querySelectorAll('.peer-file').length }; })()`;
let netInfo = null;
for (let i = 0; i < 30; i++) {
await sleep(500);
netInfo = await cdp.evaluate(NET);
if (netInfo.peers >= 4) break;
}
await still(path.join(OUT, `${prefix}net-peers.png`));
const peersOk = !!netInfo && netInfo.peers >= 1;
if (!peersOk) problems.push("EXPECT net-peers " + JSON.stringify(netInfo));
console.log((peersOk ? " ok " : " FAIL ") + "net-peers".padEnd(16) + " " + JSON.stringify(netInfo));
/* a peer's sheet → their shares browsed (one request; a big
share takes seconds) → back. A share that will not open
is reported by the screen, not a failure of the port. */
try {
await cdp.evaluate(`document.querySelector('.peer-head .peer-menu').click()`);
await sleep(700);
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.halfsheet .row', 'Browse their files')`);
const BRW = `(function () { return { title: (document.querySelector('.gb-t') || {}).textContent || '', dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
let brw = null;
for (let i = 0; i < 40; i++) { await sleep(500); brw = await cdp.evaluate(BRW); if (brw.dirs > 0 || brw.err) break; }
await still(path.join(OUT, `${prefix}net-browse.png`));
const brwOk = !!brw && (brw.dirs > 0 || brw.err);
if (!brwOk) problems.push("EXPECT net-browse " + JSON.stringify(brw));
console.log((brwOk ? " ok " : " FAIL ") + "net-browse".padEnd(16) + " " + JSON.stringify(brw));
await cdp.evaluate(`history.back()`);
await sleep(800);
} catch (e) { problems.push("STEP net-browse: " + e.message); }
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__settle()`);
await sleep(800);
}
}
/* the lock-screen card: opt in, because it locks the phone and a
fingerprint has to open it again */
if (ARGS.includes("--lockscreen")) {
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
await sleep(2000);
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
await sleep(2500);
await still(path.join(OUT, `${prefix}lockscreen.png`));
console.log(" -- lockscreen still taken; the phone is locked now");
}
}
} finally {
if (cdp) cdp.close();
if (chrome) chrome.kill();
}
if (PHONE) {
const after = audioUnderruns();
const rise = underrunRise + (underrunsBefore != null && after != null ? Math.max(0, after - underrunsBefore) : 0);
const grew = rise > 0;
if (grew) problems.push(`AUDIO SoulStream's tracks underran ${rise} time${rise === 1 ? "" : "s"} during the walk`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` SoulStream's tracks: ${rise} underrun${rise === 1 ? "" : "s"} during the walk`);
}
/* the two toasts a Tauri-less page always shows are not findings */
const real = PHONE ? problems : problems.filter((p) => !/transformCallback|reading 'invoke'/.test(p));
console.log("\n" + (real.length ? real.length + " problem(s):\n" + real.map((p) => " - " + p.slice(0, 400)).join("\n") : "no exceptions, no console errors, every step as expected"));
process.exit(real.length ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });