Files
SoulStream/tests/mobile/smoke.mjs
T
KeelrLeek 1258b2b78d android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to
the top on a row's sheet, which is fine but is not what a finger is for. Press
and hold a row for 320ms now and it lifts, the rows below make room, and
letting go lands it — on the playlist page and on the player sheet's queue,
through one action in src/mobile/reorder.js. The Move rows stay: they are the
accessible path round 28 named to a screen reader, and they are the only way
to reorder while the filter is on. Release a press without having moved it and
the row's sheet opens exactly as a long press always opened it, which is what
keeps the queue's sheet reachable at all — a queue row has no ⋯, so
`oncontextmenu` is its only door to Play now, Move to next and Remove from
queue.

Most of the work is in not fighting the page. Nothing carries
`touch-action: none`, because a flick that starts on a row is how the list is
scrolled and `touch-action` is latched at touchstart anyway; the scroll is
refused instead by a non-passive `touchmove` listener added at the lift, which
works precisely because the finger has been still for 320ms and the browser
has not started one. A press that moves more than 8px before then is a scroll
and the gesture lets go of it. Click and contextmenu guards sit on the
container in the capture phase while a lift is live, or a released drag would
fire the row's own click and play the track. Rows are measured once at the
lift in the scroller's content space, so the edge pull — within 64px of either
edge — leaves the snapshot true, and each displaced row moves by the exact
difference between its top and its neighbour's rather than by one assumed row
height, because the queue face puts album separators between rows.

No new mutation and no new command: `moveItem` and `moveInQueue` each take a
numeric target now as well as "up" / "down" / "top", so there is still exactly
one call to `playlist_set_order` carrying the whole id order, and the existing
queue guards were already the right guards. The drop is optimistic and snaps
back with the error toast if the backend says no. Two refusals cover the
middle of a gesture — the playlist drops nothing if a refetch has replaced its
rows, and the queue drops nothing if `player.qi` has moved — and dragging is
off entirely while the playlist's filter has anything in it, because a
filtered list's visible order is not the stored order. Both walks drive it
with real pointer events and assert the order out of the store: a drag, a drag
back, a refusal under the filter, a press that still opens the sheet, and a
drop the backend is armed to reject.
2026-09-14 03:24:26 -05:00

2113 lines
170 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'),
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 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),
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 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 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 },
];
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),
/* 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,
"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 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 },
{ 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 },
{ 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 },
{ 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"),
"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,
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 + ")");
} 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 posBefore = (await cdp.evaluate(PLAYER))?.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 posAfter = (await cdp.evaluate(PLAYER))?.pos ?? 0;
const bgOk = stillPlaying && posAfter >= posBefore + 8;
if (!bgOk) problems.push("EXPECT background " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
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); });