/* 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 ` 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: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(), 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 — "::". setupWrites is the number of times the store has stamped setup_done, and a PREVIEW must leave it where it found it: that is the whole proof that the door in You is not a door onto Kyle's own config, and the phone walk asserts it on his phone. */ setupUp: !!document.querySelector('.halfsheet.up .setup'), setupCards: [].map.call(document.querySelectorAll('.halfsheet.up .setup .sheet-sec'), function (s) { return s.textContent.trim(); }).join('|'), setupState: (function () { try { return __ss.setupState(); } catch (e) { return ''; } })(), setupWrites: (function () { try { return __ss.setup.writes; } catch (e) { return -1; } })(), /* the note the blocked screen carries, and the rows it offers: a row goes away because the config changed, not because a flag was flipped, which is why the count is the thing worth reading */ setupNotes: document.querySelectorAll('.screen .setup-note').length, setupRows: document.querySelectorAll('.screen .setup-note .row').length, setupNoteText: [].map.call(document.querySelectorAll('.screen .setup-note .row .t'), function (t) { return t.textContent.trim(); }).join('|'), signIn: ((document.querySelector('.signin .signin-t') || {}).textContent || '').trim(), /* the boot census, latched at the shell's first frame and never re-opened: how many commands the app asked for before it had drawn anything, how many of those left in the shell's own turn, which kinds they were, and how long it took. A probe landing a second and a half later still reads the launch. -1 is "the census never sealed", -2 is "there is no census". bootCmds is the distinct names, not the whole run of them — every step carries it, and what is ever asked of it is whether a kind of command was at boot at all. */ bootCalls: (function () { try { return __ss.boot.sealed ? __ss.boot.calls : -1; } catch (e) { return -2; } })(), bootSync: (function () { try { return __ss.boot.sync; } catch (e) { return -2; } })(), bootMs: (function () { try { return __ss.boot.frameMs == null ? -1 : __ss.boot.frameMs; } catch (e) { return -2; } })(), bootLibMs: (function () { try { return __ss.boot.libMs == null ? -1 : __ss.boot.libMs; } catch (e) { return -2; } })(), bootCmds: (function () { try { var c = __ss.boot.cmds, u = [], i; for (i = 0; i < c.length; i++) if (u.indexOf(c[i]) < 0) u.push(c[i]); return u.join(' '); } catch (e) { return ''; } })(), navMs: (function () { try { var n = performance.getEntriesByType('navigation')[0]; return n ? Math.round(n.domContentLoadedEventEnd) : -1; } catch (e) { return -1; } })(), /* ── the weather: what the screen says when a read fails, an answer is empty, or nobody is signed in. Every one of these is a line this round put there; before it they were an empty box, a spinner with no end, or a Rust string in the red toast. */ slskOn: (function () { try { return __ss.slsk.connection; } catch (e) { return ''; } })(), signin: !!document.querySelector('.signin'), signinErr: (document.querySelector('.signin-err') || {}).textContent || '', why: (document.querySelector('.stub.why') || {}).textContent || '', /* the toast's one action, by its words: "Try again" or "Sign in" */ retry: (document.querySelector('.toast:not(.notice) .na') || {}).textContent || '', again: !![].slice.call(document.querySelectorAll('.imp-foot .btn')).find(function (b) { return /Try again/.test(b.textContent); }), roomsNote: (document.querySelector('.rooms-note') || {}).textContent || '', offlineNote: (document.querySelector('.offline-note') || {}).textContent || '', netAsk: !!document.querySelector('.net-ask'), folderGone: (document.querySelector('.folder-gone') || {}).textContent || '', lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '', lyrTools: !!document.querySelector('.lyr-tools'), /* ── the faces round thirty-one drew and no step had ever read ── Home's own line and the slots behind it (how many follows it walked, how many came back empty-handed); the catalog's note on an artist page; whatever stub is up, whitespace collapsed; the Gaps line, the BUTTON only — the '.gapline.done' variant is a div and says something else entirely; the failed transfer's sentence; and whether the lyrics lookup itself failed, as opposed to having found nothing. Every backslash here is doubled and there is not one backtick, comment included — this block lives inside a template literal, and a backtick would END it (the file still PARSES, so node --check says nothing: what it becomes is a tagged template, and smoke.mjs throws on import). */ catDown: (document.querySelector('.sec-note.cat-down') || {}).textContent || '', homeSlots: (function () { try { return __ss.home.order.length; } catch (e) { return -1; } })(), homeErr: (function () { try { return __ss.home.order.filter(function (id) { var s = __ss.home.slots[id]; return !!(s && s.error); }).length; } catch (e) { return -1; } })(), catNote: (function () { var n = [].slice.call(document.querySelectorAll('.screen .sec-note:not(.cat-down)')).find(function (x) { return /catalog/i.test(x.textContent || ''); }); return n ? n.textContent.replace(/\\s+/g, ' ').trim() : ''; })(), stubText: ((document.querySelector('.screen .stub') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), gapLine: ((document.querySelector('button.gapline') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), xferFail: (document.querySelector('.row.xfer.failed .m') || {}).textContent || '', lyrFailed: (function () { try { return __ss.lyr.failed ? 'yes' : 'no'; } catch (e) { return ''; } })(), trks: document.querySelectorAll('.screen .trk').length, /* the phone's trouble-words step leaves its eleven sentences here */ trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(), syStrip: document.querySelectorAll('.sy-strip .sy-col').length, syTicks: document.querySelectorAll('.sy-strip .sy-tick').length, syEst: (document.querySelector('.sy-est') || {}).textContent || '', syHint: (document.querySelector('.sy-hint') || {}).textContent || '', disco: document.querySelectorAll('.grid2 .release').length, /* the whole-discography page: its rows, its tabs (All in front), the year headers, an opened edition rail, the cap's own button, the first row's title, the order the head is showing and the summary line — plus, back on the artist page, the link down to it */ dgRows: document.querySelectorAll('.dg-row').length, dgTabs: document.querySelectorAll('.dg-tabs button').length, dgGroups: document.querySelectorAll('.dg-group').length, dgEds: document.querySelectorAll('.dg-eds .ed').length, dgMore: (document.querySelector('.dg-more') || {}).textContent || '', dgFirst: (document.querySelector('.dg-row .t') || {}).textContent || '', dgOrder: (document.querySelector('.dg-sec .all') || {}).textContent || '', dgSum: (document.querySelector('.dg-head .m') || {}).textContent || '', dgAll: (document.querySelector('.more-link.dg-all') || {}).textContent || '', /* the artist page's three lanes at once — the page, the hero seed and the overview — so a step can say what painted and what had not landed yet: ":[+art]::" */ 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('.fchrome.back'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(), miniLeft: Math.round((box('.mini') || { left: -1 }).left), /* ── the long session: what the app is still HOLDING ───────────── Minutes is all a walk has ever been. These are the numbers that decide what hours look like: the thumbnail map and its side tables (from the store itself — they are module-private, so artTally is the only way to see them), the catalog's ten session maps, the queue, the transfer list, the DOM, and the live listeners and timers. The listener and timer counts come from a registry that a step installs IN THE PAGE, not from here: they are -1 until it is installed, which means every step before the session block, every step after it puts itself away again, and the whole phone walk, where it is never installed at all. They count what was registered AFTER the install and is still ATTACHED — window, document, or an element that is connected — so the absolute number is small and depends on how much of the walk ran first; it is the DELTA across the laps that means anything. Attached rather than add-minus-remove because that counter is a liar in this app: {#key nav.seq} replaces the .screen element on every navigation, so each new screen gets its own scroll handler and the old element is simply dropped. grew* is the same number against whatever the block latched into window.__ssBase, and -1 when nothing has. Every backslash here is doubled and there is not one backtick, comment included — this block lives inside a template literal. */ tallyArt: (function () { /* one census per probe, kept in __ssLast for the fields below: a field of an object literal cannot see its siblings, and this insert is the only part of the probe this round owns. All of it is a read; nothing here fetches or evicts. */ window.__ssCensus = function () { var out = { art: 0, chars: 0, accents: 0, pending: 0, cat: 0, queue: 0, xfer: 0, nodes: 0, ears: -1, timers: -1, ticks: -1 }; try { var a = __ss.artTally(); out.art = a.art; out.chars = a.chars; out.accents = a.accents; out.pending = a.pending; /* waiting is a SUBSET of pending, never a second queue */ } catch (e) {} try { var c = __ss.catalog; var ks = ['artists', 'albums', 'extras', 'profiles', 'seeds', 'albumInfo', 'popular', 'heroSeeds', 'overviews', 'listeners']; for (var i = 0; i < ks.length; i++) out.cat += Object.keys(c[ks[i]] || {}).length; } catch (e) {} try { out.queue = __ss.player.queue.length; } catch (e) {} try { out.xfer = Object.keys(__ss.transfers.items).length; } catch (e) {} try { out.nodes = document.getElementsByTagName('*').length; } catch (e) {} try { var k = window.__ssCount(); out.ears = k.listeners; out.timers = k.timeouts; out.ticks = k.intervals; } catch (e) {} return out; }; window.__ssLast = window.__ssCensus(); return window.__ssLast.art; })(), tallyChars: (function () { try { return window.__ssLast.chars; } catch (e) { return 0; } })(), tallyAcc: (function () { try { return window.__ssLast.accents; } catch (e) { return 0; } })(), tallyPend: (function () { try { return window.__ssLast.pending; } catch (e) { return 0; } })(), tallyCat: (function () { try { return window.__ssLast.cat; } catch (e) { return 0; } })(), tallyQueue: (function () { try { return window.__ssLast.queue; } catch (e) { return 0; } })(), tallyXfer: (function () { try { return window.__ssLast.xfer; } catch (e) { return 0; } })(), tallyNodes: (function () { try { return window.__ssLast.nodes; } catch (e) { return 0; } })(), tallyEars: (function () { try { return window.__ssLast.ears; } catch (e) { return -1; } })(), tallyTimers: (function () { try { return window.__ssLast.timers; } catch (e) { return -1; } })(), tallyTicks: (function () { try { return window.__ssLast.ticks; } catch (e) { return -1; } })(), grewArt: (function () { try { return window.__ssLast.art - window.__ssBase.art; } catch (e) { return -1; } })(), grewCat: (function () { try { return window.__ssLast.cat - window.__ssBase.cat; } catch (e) { return -1; } })(), grewEars: (function () { try { return window.__ssLast.ears - window.__ssBase.ears; } catch (e) { return -1; } })(), grewTimers: (function () { try { return window.__ssLast.timers - window.__ssBase.timers; } catch (e) { return -1; } })(), grewTicks: (function () { try { return window.__ssLast.ticks - window.__ssBase.ticks; } catch (e) { return -1; } })(), grewNodes: (function () { try { return window.__ssLast.nodes - window.__ssBase.nodes; } catch (e) { return -1; } })(), screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(), }; })()`; /* ── the walks ─────────────────────────────────────────────────────────── */ const MOCK_STEPS = [ { name: "home", wait: 1600 }, /* a long press on a card is its sheet */ { do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 }, { do: `__click('.halfsheet .row', 'Re-fetch the cover')`, name: "cover-refetched", wait: 800 }, { do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 }, { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 }, /* a misspelt library name: the catalog has nothing, the library's own names correct it */ { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 }, { do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 }, { do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 }, /* By format: the hi-res lossless records come first */ { do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 500 }, { do: `__click('.chips .chip', 'Recently added')`, wait: 400 }, { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 }, { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 }, { do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "album", wait: 900 }, { do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 900 }, { do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 600 }, { do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 }, /* the art opens the viewer; the back gesture closes it */ { do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 900 }, { do: `history.back()`, name: "viewer-closed", wait: 700 }, { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 }, /* a queue row's sheet: the row removed */ { do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 }, { do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 }, /* the second upcoming row moved up */ { do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 }, { do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", wait: 600 }, /* and dragged: the first upcoming row two places down, then back up. The order is read off player.queue, not off the rows */ { name: "queue-drag-arm", wait: 300 }, { do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 900 }, { do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 900 }, { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 }, /* the lyrics tools: nudged half a second, another candidate chosen */ { do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 }, { do: `__click('.halfsheet .row', 'Lyrics later')`, name: "lyrics-nudged", wait: 500 }, { do: `__click('.halfsheet .row', 'Search again')`, name: "lyrics-candidates", wait: 900 }, { do: `__click('.halfsheet .row', 'Live')`, name: "lyrics-chosen", wait: 700 }, /* pasted words, timestamped: synced lines */ { do: `__click('.lyr-tools', null)`, wait: 600 }, { do: `__click('.halfsheet .row', 'Paste lyrics')`, wait: 600 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "lyrics-pasted", wait: 800 }, /* auto-sync: the app's own correction turned off for this track, then back on — the re-read leaves the sheet up so the switch is seen */ { do: `__click('.lyr-tools', null)`, wait: 600 }, { do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 }, { do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 }, /* off again, and left off: a save returns the document with that flag at its default, and the switch must not take it */ { do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 }, /* the tap-to-sync room: taps become the timing, one undone, the room peeled by the back gesture and the set offered back, then saved */ { do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 }, /* the envelope is measured on open; the estimate lays every line across the sung stretches, and dropping it puts the room back to the tap */ { do: `__click('.sy-est', null)`, name: "sync-estimated", wait: 700 }, { do: `__click('.sy-est', null)`, name: "sync-cleared", wait: 600 }, { do: `__click('.sy-pad', null)`, wait: 400 }, { do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 }, { do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 }, { do: `history.back()`, wait: 600 }, { do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 }, { do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 }, { do: `__click('.sy-pad', null)`, wait: 400 }, { do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 }, { do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 }, /* the vocal-reduced copy, and back to the recording */ { do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 }, { do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 }, { do: `__click('.lyr-tools', null)`, wait: 700 }, { do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 }, { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 }, { do: `history.back()`, name: "after-back", wait: 900 }, { do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 }, { do: `history.back()`, wait: 700 }, { do: `history.back()`, name: "library-again", wait: 800 }, { do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 }, /* a buddy's sheet: browse, message, remove */ { do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 }, { do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 }, /* the contact editor behind the peer sheet: a petname, a note, the favourite mark — then the buddy list wearing all three, ada's own name still under it and favourites first */ { do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 }, { do: `__click('.halfsheet .row', 'Petname')`, wait: 500 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 }, { do: `__click('.halfsheet .row', 'Note')`, wait: 500 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 }, { do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 }, { do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, /* a user looked up by name: the peer sheet */ { do: `__click('.screen .row', 'Look up a user')`, wait: 600 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'dusty_crates'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Open')`, name: "user-lookup", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, /* your own interests: what is declared, a like added and the server's match re-asked, a hate added, the like taken back, a match's sheet */ { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 }, { do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 }, { do: `__click('.tastechips.likes .tadd', null)`, wait: 500 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 }, { do: `__click('.tastechips.hates .tadd', null)`, wait: 500 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 }, { do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 }, { do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, { do: `__click('.segtabs button', 'Rooms')`, wait: 500 }, /* the room list: filtered by name, the rest on demand */ { do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = 'vin'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "rooms-filtered", wait: 500 }, { do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, { do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 }, /* the fake network: a room with a few lines in it */ { do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 }, /* the room's roster wears the petname too, the real name beneath it */ { do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, { do: `history.back()`, wait: 700 }, /* nothing set is nothing stored: cleared, the entry leaves the list and a name goes back to being a name */ { do: `__click('.segtabs button', 'Buddies')`, wait: 600 }, { do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 }, { do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 }, { do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 }, /* a search answered by five peers; the best one's first file streams, the lane rising as the bytes arrive */ { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 }, { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 }, /* the hero's artist: the desktop's page — tabs, Popular's bars, Follow, the latest release, fans also like — then a fan-also-likes page */ { do: `document.querySelector('.cat-song').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-song-sheet", wait: 600 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, /* a Records-rail card's long press: the record's sheet */ { do: `(function () { var sec = [].slice.call(document.querySelectorAll('.sec')).find(function (x) { return /^\\s*Records/.test(x.textContent); }); var rail = sec && sec.nextElementSibling; rail.querySelector('.release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, name: "cat-record-sheet", wait: 600 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, { do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 }, { do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 }, { do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, /* the whole discography, the page the artist page's "All" opens: the eleven mock rows folded to ten families across six categories, the year headers, a category tab, the one linked family opened in place, and the sort sheet taking it to a name order — which is where the grouping has to fall away */ { do: `__click('.sec .all', 'All')`, name: "disco-all", wait: 1400 }, { do: `__click('.dg-tabs button', 'Albums')`, name: "disco-albums", wait: 900 }, { do: `__click('.dg-tabs button', 'All')`, wait: 800 }, { do: `__click('.dg-eline', null)`, name: "disco-editions", wait: 800 }, { do: `__click('.dg-sec .all', null)`, name: "disco-sheet", wait: 900 }, { do: `__click('.halfsheet .chip', 'A–Z')`, name: "disco-az", wait: 900 }, { do: `document.querySelector('.scrim').click()`, wait: 700 }, { do: `history.back()`, wait: 800 }, /* and the cap, on an artist with 140 records: the grid on the artist page is a slice with the way down under it, the page itself opens on the tab that link was showing, and sixty rows extend to all of them */ { do: `__ss.push('artist', { name: 'Ember Parade', seed: { id: 416, name: 'Ember Parade', picture: null, album_count: 140, fans: 91000 } })`, name: "disco-many-artist", wait: 1800 }, { do: `__click('.more-link.dg-all', 'Show all')`, name: "disco-many", wait: 1500 }, { do: `__click('.dg-more', null)`, name: "disco-many-all", wait: 1400 }, { do: `history.back()`, wait: 800 }, { do: `history.back()`, wait: 900 }, /* the artist's photo: the page's ⋯ → a picture from this phone becomes the hero, the page is asked again, then the catalog's own come back */ { do: `document.querySelector('.fchrome.more').click()`, name: "artist-more", wait: 700 }, { do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 }, { do: `document.querySelector('.fchrome.more').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 }, /* the mock hands the finished file over on its own timer, with a still page in front of it the whole way — nothing here can shorten this */ { name: "net-arrived", wait: 7000, hold: true }, /* 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, hold: true }, { do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 }, { do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900, hold: true }, /* 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.querySelector('.fchrome.more').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.querySelector('.fchrome.more').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.querySelector('.fchrome.more').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.querySelector('.fchrome.more').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.querySelector('.fchrome.more').click()`, wait: 600 }, { do: `__click('.halfsheet .row', 'Remove custom cover')`, name: "cover-removed", wait: 1000 }, { do: `document.querySelector('.fchrome.more').click()`, wait: 600 }, { do: `__click('.halfsheet .row', 'Choose cover photo')`, name: "cover-set", wait: 1000 }, { do: `document.querySelector('.fchrome.more').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.querySelector('.fchrome.more').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.querySelector('.phead .iconbtn.muted').click()`, name: "home-more", wait: 700 }, { do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 }, { do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 }, /* ── the phone turned over. A resize, so the app keeps playing and keeps its stack; what changes is the layout, and every one of these asserts something that was clipped or unreachable at 915x411 before. ────── */ { view: [915, 411], name: "landscape", wait: 900 }, { do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 }, { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 }, { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 }, { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 }, { do: `__ss.closeSheet()`, wait: 800 }, /* a record sideways: the cover beside the title, Play in view without a scroll, and a long sheet over it — the track's, 473px of rows and facts — capped taller than portrait's 76% and still scrolling to its last row. (Home's own overflow sheet is 212px tall at either cap and would have proved nothing.) */ { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 }, { do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 }, { do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 }, { do: `document.querySelector('.scrim').click()`, wait: 700 }, /* and the artist: the hero a banner rather than 300px of a 411px screen, so it stops covering its own Play button with the mini bar */ { do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 }, { do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 }, /* the side insets, faked: sideways the cutout and the gesture pill come in from the edges, and env() cannot be emulated here — the plumbing can */ { do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 }, { do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 }, /* and back, which is also the override reset: portrait must be exactly what it was before any of this */ { view: [411, 914], wait: 800 }, { do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 }, { do: `__ss.closeSheet()`, wait: 600 }, /* ── the weather ──────────────────────────────────────────────────── Everything above this line is the happy path; nothing in thirty rounds of walk had ever made a command reject or taken the client offline, so none of these lines had ever been drawn. The mock's own doors (__ssMock.fail / answer / offline / clear) are the weather, and every assertion below is on SETTLED state — an armed failure stays armed, a sign-in card stays up, the folder-gone row stays — because the probe lands after the screenshot, well past the step's own wait. The one timed assertion is the room list, and its timeout is shortened to 1.2 s against the 3.1 s of waits between arming it and reading it. The last step puts the weather away and leaves the app exactly as it found it — anything appended after this block inherits a signed-in app on Home. The first step settles FIRST, and it has to: the landscape block above leaves the library tab three screens deep (record → artist), so one tap on the Library tab lands on the artist page and `.segtabs button` would be its discography tabs, not Albums/Artists/Tracks. It also wipes any error the happy path left standing, so `!i.toast` below means this block's toast and no other. */ { do: `(function () { __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 }, { do: `__ssMock.fail('library_track_index', 'unable to open database file')`, wait: 300 }, { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "weather-tracks", wait: 1300 }, { do: `__ssMock.fail('library_track_index', '')`, wait: 200 }, { do: `__click('.imp-foot .btn', 'Try again')`, name: "weather-tracks-ok", wait: 1300 }, /* signed out, in a lift: the card is back and nothing says why in Rust */ { do: `__ssMock.offline(true)`, wait: 600 }, { do: `document.querySelectorAll('.nav button')[3].click()`, name: "weather-signedout", wait: 1100 }, /* the single most visible raw-Rust site on the phone, twice over */ { do: `__ssMock.fail('connect_and_login', 'login failed: INVALIDPASS')`, wait: 200 }, { do: `(function () { var p = document.querySelectorAll('.signin .field')[1]; p.value = 'hunter2'; p.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, wait: 400 }, { do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-badpass", wait: 1200 }, { do: `__ssMock.fail('connect_and_login', 'could not reach server: io: failed to lookup address information: no address associated with hostname')`, wait: 200 }, { do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-noserver", wait: 1200 }, /* Search signed out with a query typed showed NOTHING where the network section lives — no row, no reason. Now it says so and offers the door */ { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 }, { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, name: "weather-search-out", wait: 1800 }, /* the room list is fire-and-forget over the wire (the client sends it and nothing answers if the server will not), so the wait is ours: an armed answer that resolves and emits nothing is exactly that server. Community is left on Buddies by the walk above, so the Rooms tab is asked for by name — the note lives under that one. */ { do: `__ssMock.fail('connect_and_login', ''); __ssMock.offline(false)`, wait: 1200 }, { do: `(function () { __ss.slsk.roomsWaitMs = 1200; __ss.slsk.rooms = []; __ssMock.answer('slsk_room_list', null); __ss.refreshRooms(); return true; })()`, wait: 200 }, { do: `document.querySelectorAll('.nav button')[3].click()`, wait: 900 }, { do: `__click('.segtabs button', 'Rooms')`, name: "weather-rooms-late", wait: 2200 }, /* one tap on Rescan library used to empty the whole index and say Ok */ { do: `__ssMock.fail('rescan_library', 'library-folder-missing: /storage/1A2B-3C4D/Music')`, wait: 200 }, { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, { do: `__click('.screen .row', 'Rescan library')`, name: "weather-rescan", wait: 1500 }, /* and the weather clears */ { do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 }, { do: `__settle()`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 }, /* ── the faces no walk had seen ───────────────────────────────────── Round thirty-one gave six screens an honest failure and its own verifier was straight about what it had shipped unobserved: Home's `catalogDown`, the lyrics `failed` note, Gaps' `gapErr`, Playlist's `loadErr`, the catalog record's and the artist page's stubs, and `failed · ` on a transfer row. It could drive none of them because neither `loadHome` nor `loadLyrics` was reachable from the `__ss` handle — both are gated on `started` / `key` and answer a second ask with nothing at all, so no amount of tapping re-runs them. The handle carries the two stores and their two loaders now, and this block is what they were added for. It sits HERE, before the first run, and not at the end of the list: the block below reloads the page twice, and the mock's follows live in a module-level `let` that a reload empties — after `fresh-off` nobody is followed, and Home's `catalogDown` (which needs a follow) and the Gaps shelf (which IS the follows) are both structurally unreachable. Here the block inherits what the weather left behind: signed in, on Home, Daft Punk followed, a track on the mini bar. Every assertion is on settled state — an armed failure stays armed, an error stub stays up, the toast stays until it is dismissed — and the two forced loads return their promise, which `cdp.evaluate` awaits, so the fetch is over before the step's wait even begins. Every failure is disarmed by the step that proves the face heals, and `faces-clear` is the proof that nothing was left armed. */ { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 }, /* Home with every follow's catalog page refused. `home.svelte.js` has always set `slot.error` there; until round thirty-one nothing read it, and until this step nothing had ever seen the line that does. */ { do: `(function () { __ssMock.fail('catalog_artist', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadHome(true); })()`, name: "faces-home-down", wait: 1200 }, /* the same armed failure, on the other screen that asks the same command: Gaps. Home stays poisoned through these three steps on purpose — it is asserted above, and the second tap below is what clears the weather for both. */ { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, { do: `__click('.segtabs button', 'Gaps')`, wait: 700 }, { do: `__click('.gapline', null)`, name: "faces-gap-down", wait: 1400 }, /* a failed lookup leaves `gapRows[key]` undefined, so the second tap genuinely asks again rather than merely opening a rail */ { do: `__ssMock.fail('catalog_artist', '')`, wait: 300 }, { do: `__click('.gapline', null)`, name: "faces-gap-again", wait: 1600 }, /* a catalog record. The walk has already made 302127 `ready` and `lookupAlbum` returns early on anything but an error, so the entry is forgotten first — the smallest honest way to say "ask again". */ { do: `(function () { delete __ss.catalog.albums[302127]; return __ssMock.fail('catalog_album', 'io: connection reset by peer'); })()`, wait: 300 }, { do: `__ss.push('catalogAlbum', { id: 302127, artist: 'Daft Punk', title: 'Discovery' })`, name: "faces-album-down", wait: 1500 }, { do: `__ssMock.fail('catalog_album', '')`, wait: 300 }, { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-album-again", wait: 1600 }, { do: `__ss.back()`, wait: 700 }, /* the artist page has TWO of these and they say different things: a lookup that FAILED is "didn't answer", one that came back empty is "had nothing to add". `bestArtistFor` is what has to fail for the first — failing `catalog_artist` alone never reaches the page at all and leaves the status at none, which is the other copy. */ { do: `__ssMock.fail('catalog_search', 'io: failed to lookup address information: no address associated with hostname')`, wait: 250 }, { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-down", wait: 1600 }, { do: `(function () { __ssMock.fail('catalog_search', ''); __ss.back(); return true; })()`, wait: 700 }, { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-none", wait: 1600 }, { do: `__ss.back()`, wait: 700 }, /* a playlist that will not read. Id 1 survives the whole walk — it is the one renamed to "Late night drive II"; the one deleted was New Playlist. Try again clears `loadErr` and bumps `again`, which is what re-arms the effect. */ { do: `__ssMock.fail('playlist_items', 'unable to open database file')`, wait: 250 }, { do: `__ss.push('playlist', { id: 1 })`, name: "faces-playlist-down", wait: 1400 }, { do: `__ssMock.fail('playlist_items', '')`, wait: 300 }, { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-playlist-again", wait: 1400 }, { do: `__ss.back()`, wait: 700 }, /* the lyrics face. Sheet.svelte asks with the UN-forced `loadLyrics()`, which returns at once while `lyr.key` is already this track's — so the face alone can never ask again, which is exactly why round thirty-one could not drive this one. The loader, forced. */ { do: `(function () { __ss.openSheet(); return true; })()`, wait: 900 }, { do: `document.querySelectorAll('.fbtn')[1].click()`, wait: 900 }, { do: `(function () { __ssMock.fail('lyrics_get', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadLyrics(true); })()`, name: "faces-lyrics-down", wait: 1200 }, { do: `(function () { __ssMock.fail('lyrics_get', ''); return true; })()`, wait: 250 }, { do: `__ss.loadLyrics(true)`, name: "faces-lyrics-again", wait: 1200 }, { do: `(function () { document.querySelectorAll('.fbtn')[0].click(); __ss.closeSheet(); return true; })()`, wait: 700 }, /* a transfer that was refused, written straight into the store the way the shade steps above do — nothing is fetched, and `failed` is in DONE, so `xfernote.shape()` sees nothing in flight and posts no notice. The id is far above the backend's own counter, and `list()` sorts id-descending, so this row is the first one on the screen. */ { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "faces-xfer-failed", wait: 1300 }, { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, wait: 800 }, /* the one toast action round thirty-one armed that no step had ever pressed. `lib.error` is written by `loadLibrary` alone — `watchLibrary`'s own reload swallows its failure — so You → Rescan library is the only door in the app that reaches it twice. */ { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, { do: `__ssMock.fail('library_albums', 'unable to open database file')`, wait: 250 }, { do: `__click('.screen .row', 'Rescan library')`, name: "faces-library-toast", wait: 1500 }, { do: `__ssMock.fail('library_albums', '')`, wait: 300 }, { do: `__click('.toast:not(.notice) .na', 'Try again')`, name: "faces-library-again", wait: 1400 }, /* and all of it put away, so the first run below starts exactly where it would have started without this block at all */ { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; __settle(); return __ss.loadHome(true); })()`, wait: 900 }, { name: "faces-clear", wait: 1200 }, /* ── the first run, on a device that has just been installed ────── The mock is the whole backend here, so it can answer as one: no records, no playlists, no account, no all-files permission, and the library root still the sandbox folder Android hands a fresh install. The flag rides in the URL fragment — a reload cannot lose that the way a file:// document's storage might — and in localStorage beside it, for a page opened by hand. This block is LAST on purpose. The reload wipes every module's state (the mock's playlists, its contacts, its one fake transfer), so nothing the two hundred steps above built survives into it, and it puts the walk back on a provisioned device at the end so that nothing appended after it is quietly running against an empty one. A reload is not a reinstall: the webview's own storage comes back with it, so the session the walk left behind is cleared by hand — a now-playing bar on a phone with nothing in it is the one piece of the pretence that would show in the stills. Removing the key is not enough on its own, and a `pagehide` listener is not either: the player writes the session on pagehide, on visibilitychange AND on a ten-second timer while a track plays (player.svelte.js), and a CDP-driven reload is not obliged to fire pagehide at all — so the first attempt at this step came back with the bar still up. The clearing therefore shuts the door instead of chasing the writers: setItem is taught to refuse that one key for the few hundred milliseconds this page has left, so nothing can put it back. */ { do: `(function () { var K = 'ss.mobile.session.v1'; var set = Storage.prototype.setItem; Storage.prototype.setItem = function (k) { if (k === K) return; return set.apply(this, arguments); }; try { localStorage.removeItem(K); } catch (e) {} localStorage.setItem('mockFresh', '1'); location.hash = 'fresh'; })(); true`, wait: 400 }, { do: `location.reload()`, name: "fresh-boot", wait: 4500 }, /* the tour's Soulseek step navigates to the card and stops there — nothing in the walk types a username or a password */ { do: `__click('.halfsheet .row', 'Sign in on Community')`, name: "fresh-community", wait: 1400 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-home", wait: 1000 }, { do: `document.querySelectorAll('.nav button')[2].click()`, name: "fresh-albums", wait: 900 }, /* the Artists tab had no empty state at all before this round */ { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "fresh-artists", wait: 900 }, { do: `document.querySelectorAll('.nav button')[1].click()`, name: "fresh-search", wait: 900 }, /* the preview door: it reads the state and writes nothing */ { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, { do: `__click('.screen .row', 'Set up this phone again')`, name: "fresh-preview", wait: 1100 }, { do: `__click('.halfsheet .row', 'Choose a folder')`, name: "fresh-folder", wait: 2000 }, { do: `document.querySelector('.scrim').click()`, wait: 700 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 }, { do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 }, { do: `location.reload()`, name: "fresh-off", wait: 4500 }, /* ── the playlist export, three ways ────────────────────────────── On the provisioned device the block above deliberately put back, and on the pristine fixture its reload restored. The file is built in Rust by the desktop's own writers and written by whichever door the platform has, so the only thing that comes back — and the only thing the phone can show — is where it went. */ { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, { do: `__click('.segtabs button', 'Playlists')`, wait: 800 }, { do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 }, { do: `document.querySelector('.fchrome.more').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.querySelector('.fchrome.more').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.querySelector('.fchrome.more').click()`, wait: 700 }, { do: `__click('.halfsheet .row', 'Export')`, wait: 700 }, { do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 }, { do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 }, /* ── the long session ───────────────────────────────────────────── Every walk before this one was minutes long. A phone runs the app for hours, and nothing in the port had ever been looked at for a leak. This block is both the instrument and the assertion. The registry is installed by a STEP rather than through Page.addScriptToEvaluateOnNewDocument, and that is load-bearing: the first-run block above reloads three times and reattach() opens a NEW DevTools session, so a script registered on the old one is gone for the rest of the run. Installed from here it covers exactly the laps the assertion is about, and the last step takes it out again. It wraps addEventListener/removeEventListener (holding a WeakRef to anything that is not window or document) and the four timer functions, and it counts only what was registered AFTER it went in and is still ATTACHED. Attached, because the naive counter — add minus remove — climbs about twelve a lap in this app and none of it is a leak: {#key nav.seq} replaces the .screen element on every navigation and the old element's scroll handler goes with it. After the install, because nothing can be registered before it: the absolute count is therefore small (two after the whole walk, eight from a settled boot) and only the DELTA across the laps is the measurement. A listener or an interval stranded once a lap shows as +9 in one and +9 in the other; it was driven to check. Then two laps to warm, a reading latched into __ssBase, nine more laps, and a second reading. A lap is the five tabs, the Library, a record, Play, the mini bar, all three faces of the sheet, the artwork viewer, a row's half sheet, the artist page (a catalog lookup) and back — about five seconds each, and fifty in all. The lap count is what makes the assertion sharp: a one-per-lap leak shows as +9 against a headroom of four, and trimming it to save time blunts the only thing this block is for. Nothing here reaches the network or the disk beyond what any other mock step does, and the last step puts the Library back on whichever section the walk left it on. */ { do: ` window.__ssLibTab = (function () { try { return __ss.nav.stacks.library[0].c.tab || 'albums'; } catch (e) { return 'albums'; } })(); window.__ssLapN = 0; window.__ssLap = function (n) { var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }; var tap = function (sel, text) { var all = document.querySelectorAll(sel); for (var k = 0; k < all.length; k++) if (!text || (all[k].textContent || '').indexOf(text) >= 0) { all[k].click(); return true; } return false; }; /* the app's own back, never history.back(): walking off the history mirror leaves file:// and the page is simply gone */ var peel = async function () { for (var b = 0; b < 8; b++) { var nv = __ss.nav; if (!nv.timing && !nv.viewer && !nv.half && !nv.sheet && nv.stacks[nv.tab].length === 1) return; __ss.back(); await nap(190); } }; return (async function () { for (var i = 0; i < n; i++) { /* the counter is global, not the loop's own: the laps run in threes, and a lap that always opened the same record would stop asking for anything new after the first call and quietly measure nothing */ var which = window.__ssLapN++; __ss.go('search'); await nap(90); __ss.go('community'); await nap(90); __ss.go('you'); await nap(90); __ss.go('library'); await nap(200); tap('.segtabs button', 'Albums'); await nap(260); var rel = document.querySelectorAll('.grid2 .release'); if (rel.length) { rel[which % rel.length].click(); await nap(440); } tap('.rec-acts .disc'); await nap(440); tap('.mini'); await nap(380); var fb = document.querySelectorAll('.fbtn'); for (var f = 0; f < fb.length; f++) { fb[f].click(); await nap(110); } tap('.np-art'); await nap(240); /* peeled one layer at a time and only if it opened: a record with no cover never opens the viewer, and a blind pair of backs would pop the album page instead and take the rows below with it */ if (__ss.nav.viewer) { __ss.back(); await nap(220); } if (__ss.nav.sheet) { __ss.back(); await nap(260); } var trk = document.querySelector('.trk .r'); if (trk) { trk.click(); await nap(260); tap('.scrim'); await nap(220); } tap('.rec-artist'); await nap(620); await peel(); __ss.go('home'); await nap(160); } return n; })(); }; (function () { if (window.__ssCount) return 'already'; var recs = [], liveT = new Set(), liveI = new Set(); var ael = EventTarget.prototype.addEventListener, rel = EventTarget.prototype.removeEventListener; EventTarget.prototype.addEventListener = function (t, f) { try { recs.push({ t: t, f: f, w: (this === window || this === document) ? null : new WeakRef(this), dead: false }); } catch (e) {} return ael.apply(this, arguments); }; EventTarget.prototype.removeEventListener = function (t, f) { try { for (var i = recs.length - 1; i >= 0; i--) if (!recs[i].dead && recs[i].t === t && recs[i].f === f) { recs[i].dead = true; break; } } catch (e) {} return rel.apply(this, arguments); }; var st = window.setTimeout, ct = window.clearTimeout, si = window.setInterval, ci = window.clearInterval; window.setTimeout = function (fn, ms) { var id, rest = [].slice.call(arguments, 2); var w = typeof fn === 'function' ? function () { liveT.delete(id); return fn.apply(window, arguments); } : fn; id = st.apply(window, [w, ms].concat(rest)); liveT.add(id); return id; }; window.clearTimeout = function (id) { liveT.delete(id); return ct.call(window, id); }; window.setInterval = function () { var id = si.apply(window, arguments); liveI.add(id); return id; }; window.clearInterval = function (id) { liveI.delete(id); return ci.call(window, id); }; /* a listener is LIVE when its target is window, document, or an element still in the tree; a timer is live until it fires or is cleared */ window.__ssCount = function () { var n = 0; for (var i = 0; i < recs.length; i++) { var r = recs[i]; if (r.dead) continue; if (r.w === null) { n++; continue; } var el = r.w.deref(); if (el && el.isConnected) n++; } return { listeners: n, timeouts: liveT.size, intervals: liveI.size }; }; /* and hands the page back exactly as it was found. A sibling round appending after this block must not inherit a patched EventTarget or a patched clock; __ssCount and __ssBase go with them, so every later probe reads the -1 that means 'nothing has measured this' rather than a frozen number. */ window.__ssUninstall = function () { EventTarget.prototype.addEventListener = ael; EventTarget.prototype.removeEventListener = rel; window.setTimeout = st; window.clearTimeout = ct; window.setInterval = si; window.clearInterval = ci; delete window.__ssCount; delete window.__ssBase; delete window.__ssUninstall; return 'removed'; }; return 'installed'; })() `, wait: 500 }, { do: `__ssLap(2)`, name: "session-warm", wait: 1200 }, /* the baseline, taken after the warm laps have settled and their own probe has run — __ssCensus is defined by the probe itself */ { do: `window.__ssBase = window.__ssCensus(); true`, wait: 300 }, /* nine laps, in threes: cdp.send() gives a step forty-five seconds to answer and a lap is about five, so one call of nine times out, the walk records a failure it did not have, and the probe lands in the middle of a lap instead of at Home */ { do: `__ssLap(3)`, wait: 400 }, { do: `__ssLap(3)`, wait: 400 }, { do: `__ssLap(3)`, name: "session-late", wait: 1400 }, /* back to Home with nothing open, the Library on the section the walk was using before this block, and the registry out of the page, so whatever is appended after this finds the app — and the platform — where the block above left them. This step's own probe therefore reports -1 for every grew* field, which is the sentinel for 'nothing has latched a baseline': session-late is where the numbers are. */ { do: `__settle(); try { __ss.go('library'); __ss.patch({ tab: __ssLibTab }); __ss.go('home'); } catch (e) {} try { __ssUninstall(); } catch (e) {} true`, name: "session-clear", wait: 1000 }, ]; const MOCK_EXPECT = { tracks: (i) => i.title.startsWith("Your library"), community: (i) => i.title.startsWith("Community"), buddies: (i) => i.title.startsWith("Community"), "buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText) && /Name and notes/.test(i.halfText), "user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText), /* the local contact metadata: the editor, the petname reaching the backend, the mark sticking, the buddy list rearranged — and the cleared entry dropped rather than stored empty */ "contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere", "contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "), "contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText), "contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite", "buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast, "room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText), "contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast, taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4 && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, "taste-declared": (i) => /Boards of Canada/.test(i.likes) && i.similar === 5, "taste-hated": (i) => i.hates === "loudness war|loudness war 2", "taste-dropped": (i) => !/Boards of Canada/.test(i.likes) && i.similar === 4 && !i.toast, "taste-peer": (i) => i.half.includes("up") && /hifi_hank/.test(i.halfText), "home-more": (i) => i.half.includes("up"), you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit" && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, "you-norm-off": (i) => i.norm === "off" && i.a11yState === "", "you-norm-on": (i) => i.norm === "on", "you-lossy": (i) => i.srcMax === "Lossy only", "you-keep": (i) => i.keep === "1 week", "you-slots": (i) => i.slots === "3", "you-words": (i) => i.wordfill === "on", "you-artedge": (i) => i.artEdge === "1000 px", "you-portraits": (i) => /portraits refreshed/.test(i.notice), "wifi-on": (i) => i.wifiOnly === "on", "wifi-refused": (i) => /Wi-Fi/.test(i.toast), "wifi-off": (i) => i.wifiOnly === "off" && !i.toast, "you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port), "you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast, "you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port), "diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1, "diag-back": (i) => i.title.startsWith("You"), "history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText), "history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice), "search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1, "search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top), "sleep-sheet": (i) => i.half.includes("up"), "sleep-set": (i) => !i.half.includes("up"), /* ── the weather ──────────────────────────────────────────────────── Each of these fails if the round's fix is undone: the stub if a failed read goes back to being drawn as an empty library, the sign-in lines if the toast or the card stops translating, the search note if the block goes back to rendering nothing at all signed out, the room note if the spinner loses its end, the folder row if the rescan stops refusing. Every one also asserts the RAW string is gone — that is the round. */ "weather-tracks": (i) => /^Couldn't read the library index/.test(i.why) && /library folder isn't readable/.test(i.why) && i.again && i.trks === 0 && !/database file/.test(i.why + i.toast), "weather-tracks-ok": (i) => !i.why && !i.again && i.trks > 0 && !i.toast, "weather-signedout": (i) => i.slskOn === "offline" && i.signin && !i.signinErr && !i.toast, "weather-badpass": (i) => i.signinErr === "Soulseek refused that password." && !/INVALIDPASS/.test(i.signinErr), "weather-noserver": (i) => /^Couldn't reach the Soulseek server/.test(i.signinErr) && !/io:|address information/.test(i.signinErr), "weather-search-out": (i) => i.title.startsWith("Search") && /Community/.test(i.offlineNote) && !i.netAsk, "weather-rooms-late": (i) => i.slskOn === "online" && /hasn't sent its room list/.test(i.roomsNote) && i.again, "weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast) && !/library-folder-missing/.test(i.toast + i.folderGone), "weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title), /* ── the faces no walk had seen ───────────────────────────────────── Each of these fails if round thirty-one's fix is undone: stop reading `slot.error` and Home's line empties; drop the `gapErr` branch and the Gaps line falls back to "See what's missing" and can be tapped for ever; put `lyr.failed` back and the face says "No lyrics found" about a lookup that never answered; take `say()` out of `stateLine` and the raw string is on the row again. Every one asserts the sentence a person reads AND that the Rust string is not in it — the second half is the round-thirty-one rule, and the half that survives any rewording of the first. */ "faces-home-down": (i) => i.homeSlots > 0 && i.homeErr === i.homeSlots && /^Couldn't reach the catalog, so there's nothing new/.test(i.catDown) && !/New from your artists/.test(i.secs), "faces-gap-down": (i) => /Couldn't reach the catalogue — tap to try again/.test(i.gapLine) && !i.gapCards, "faces-gap-again": (i) => /more released/.test(i.gapLine) && !/Couldn't reach/.test(i.gapLine) && i.gapCards >= 3, "faces-album-down": (i) => /^The catalog did not answer/.test(i.why) && /The network didn't answer/.test(i.why) && !/io:|connection reset/.test(i.why) && i.again && !i.trks, "faces-album-again": (i) => !i.why && !i.again && i.trks > 0, /* the pair that proves the distinction is real and not decoration */ "faces-artist-down": (i) => /only what this phone holds is here/.test(i.catNote) && /didn't answer/.test(i.stubText) && !/io:|address information/.test(i.catNote + i.stubText), "faces-artist-none": (i) => !i.catNote && /had nothing to add/.test(i.stubText) && !/didn't answer/.test(i.stubText), "faces-playlist-down": (i) => /^Couldn't read this playlist/.test(i.why) && /library folder isn't readable/.test(i.why) && i.again && !i.plrows && !/database file/.test(i.why), "faces-playlist-again": (i) => !i.why && !i.again && i.plrows > 0, "faces-lyrics-down": (i) => i.lyrFailed === "yes" && !i.lyrLines && /^Couldn't look up the words/.test(i.lyrNote) && i.lyrTools && !/io:|address information/.test(i.lyrNote), "faces-lyrics-again": (i) => i.lyrFailed === "no" && i.lyrLines >= 3 && !i.lyrNote, "faces-xfer-failed": (i) => i.title.startsWith("Transfers") && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) && !/transfer rejected/.test(i.xferFail), "faces-library-toast": (i) => /library folder isn't readable right now/.test(i.toast) && i.retry === "Try again" && !/database file/.test(i.toast), "faces-library-again": (i) => i.title.startsWith("You") && !i.toast && !i.retry, /* the disarm, proved: no toast, no armed action, no stub, Home's slots healthy and its rails back — so the first run below is not quietly running on something this block left standing */ "faces-clear": (i) => !i.toast && !i.why && !i.catDown && !i.retry && i.homeSlots > 0 && i.homeErr === 0 && /New from your artists/.test(i.secs) && /^(Good |Still)/.test(i.title), /* sideways: the chrome budget, then every surface that was clipped. The before-value of each is in the rig's now.json — 46% of the height as chrome, a 879px cover with the transport 687px below the fold, a lyrics face collapsed to nothing, a 300px hero, and the record's own Play button off the bottom of a 411px screen. */ landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title), "landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0, "landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0, /* the queue face by name, not by its rows: the track this walk is on is the LAST queueable row of Late night drive (the missing and the unresolved ones never reach the queue), so there is nothing up next and `qrows` is 0 here — the assertion is that the queue's own face has the cover's column, not that something is after this song */ "landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0, "landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn, "landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0, "landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn, "landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26, "portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24, home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState /* and the boot budget, read off __ss.boot. Each clause earns its place: the census must have counted something (a census that breaks and reports -1 or -2 would sail through a one-sided test); it must not have counted the world; the boot chain must still be going out in one round rather than waiting on itself, which is the one clause that is not a clock at all — it was 10 before round 32 and is 23 after, so a chain put back in a queue lands under the floor; the shell must have sealed on a frame rather than on the 1200 ms fallback; the library must still land inside the launch rather than behind something slow; and round 12's eight-second art prewarm must still be nowhere near boot. The call ceiling is deliberately loose — the boot chain issues about 25 commands and the first screen asks for its covers on top, and the point there is to catch a round that pulls a per-artist walk or an index to boot, not to police a handful of calls. */ && i.bootCalls >= 14 && i.bootCalls <= 60 && i.bootSync >= 20 && i.bootMs >= 0 && i.bootMs < 1200 && i.bootLibMs >= 0 && i.bootLibMs < 600 && !/library_art_prewarm/.test(i.bootCmds), "hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText), "cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice), "search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"), "search-dym": (i) => i.dym === "shpongle", "search-dym-pick": (i) => i.sq === "shpongle" && !i.dym, search: (i) => i.title.startsWith("Search"), library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, album: (i) => i.screen.includes("tr-push") && !i.mini && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3, "lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText), "lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle), "lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText), "lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3, "lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2, "lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off", "lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on", "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow && i.syStrip === 64 && /Estimate the timing/.test(i.syEst), "sync-estimated": (i) => i.sync && i.syCount === "3 / 3" && i.syTicks === 3 && /Estimated/.test(i.syHint) && /Clear the estimate/.test(i.syEst), "sync-cleared": (i) => i.sync && i.syCount === "0 / 3" && i.syTicks === 0 && /Estimate the timing/.test(i.syEst), "sync-tapped": (i) => i.sync && i.syCount === "2 / 3", "sync-undone": (i) => i.sync && i.syCount === "1 / 3", "sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText), "sync-resumed": (i) => i.sync && i.syCount === "1 / 3", "sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow), "sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast, /* the save's own payload carries no auto-sync flag: the switch kept its */ "sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off", "lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast, "lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast, viewer: (i) => i.viewer && /^button\/-\/Close artwork viewer/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost, "viewer-closed": (i) => !i.viewer && i.sheet.includes("up"), "album-playing": (i) => i.mini, "track-sheet": (i) => i.half.includes("up") && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, "after-back": (i) => !i.sheet.includes("up"), artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; }, "queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText), "queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1, "queue-moved-up": (i) => !i.half.includes("up") && !!i.qfirst && i.qrows === globalThis.__qrows - 1, "library-again": (i) => i.screen.includes("tr-pop"), /* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming record leads), the news, the back catalogue, recommendations, complete the set, following — the desktop's page */ "home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs), room: (i) => i.screen.includes("tr-push") && i.msgs >= 2 && !i.a11yAnon && !i.a11yBad, "rooms-filtered": (i) => i.rooms === 1, "rooms-more": (i) => i.rooms >= 95, /* the artist with a picture shows it on the hero and its card; the namesake under Deezer's empty-hash placeholder shows the app's own */ "cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1, "cat-song-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to record/.test(i.halfText), "cat-record-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Go to record/.test(i.halfText) && /Go to artist/.test(i.halfText), "cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2, "cat-artist-follow": (i) => i.fb.includes("Following"), "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2, /* the whole discography: All in front of the six categories the eleven mock rows file into, the ten families they fold to, a year header per year, the desktop's own summary line and the order the head shows. Two of those rows are dated relative to today (dayIso(-9), dayIso(21)), so a run across a New Year splits their year in two — hence the range rather than a number. */ "disco-all": (i) => i.dgTabs === 7 && i.dgRows === 10 && i.dgGroups >= 8 && i.dgGroups <= 9 && /^10 releases/.test(i.dgSum) && /Newest/.test(i.dgOrder) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, "disco-albums": (i) => i.dgRows === 4 && i.dgGroups === 4, /* Discovery and its Deluxe — the one edition_group in the mock, and the thing a flat list of releases loses */ "disco-editions": (i) => i.dgEds === 2 && i.dgRows === 10, "disco-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText), /* the rule this page has to get right: a name order cannot be grouped by year, so the grouping falls away with it */ "disco-az": (i) => i.dgFirst === "Alive 1997" && i.dgGroups === 0 && /A–Z/.test(i.dgOrder), "disco-many-artist": (i) => i.title.startsWith("Ember Parade") && i.disco === 12 && /Show all 140/.test(i.dgAll), "disco-many": (i) => i.dgRows === 60 && /\+ 80 more/.test(i.dgMore), "disco-many-all": (i) => i.dgRows === 140 && !i.dgMore, "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4, "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText), "artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice), "artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix, "artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice), /* the exhaustive page has NOT landed; the banner and the first four releases have — three albums on the Albums tab, a single on its own */ "artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2, /* and the page wins when it does land: the classified six */ "artist-lanes-page": (i) => /^ready:/.test(i.artFast) && i.heroPix && i.disco === 6 && i.tabs === 2, "net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live), "net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"), "net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"), "np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText), "np-copied": (i) => !i.half.includes("up") && /1 file copied/.test(i.notice), "np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice), "net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3, "net-filter-flac": (i) => i.peers === 3, "net-filter-free": (i) => i.peers === 2, "peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText), "peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText), "peer-screen": (i) => i.title.startsWith("hifi_hank") && /Ripping my own vinyl/.test(i.peerDesc) && i.peerPic && i.peerTags === 6 && /slot is free/.test(i.peerLine) && /served/.test(i.peerLine) && i.peerGaps === 0, "peer-picture": (i) => i.viewer, "peer-compare": (i) => i.peerGaps === 6 && /in your library/.test(i.peerSum), "peer-gap-open": (i) => i.title.startsWith("Search") && i.sq === "Boards of Canada Geogaddi", "peer-again": (i) => i.title.startsWith("hifi_hank") && i.peerGaps === 6, "peer-forgotten": (i) => i.peerGaps === 0 && !i.peerSum && /forgotten/i.test(i.notice), "peer-sheet-again": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && !/About this user/.test(i.halfText), browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead), "browse-dir": (i) => i.brwFiles >= 8, "browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10 && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, "cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14, "cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText), "cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText), "cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"), "cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs), transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11, /* xfnMax >= 5 can only have come from the real album burst, and it is monotonic, so it is not a race; the title and the line come from the two injected rows (990001 is the newest transferring one, so it is deterministically the current file); xfnBad === 0 proves the call reached a handler instead of being swallowed */ "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13, "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn, gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1, "gaps-open": (i) => i.gapCards >= 3, playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned, "library-by-format": (i) => /^FLAC 24/.test(i.firstBadge), artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3, playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter, "playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast, "playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live), "playlist-filter": (i) => i.plrows === 1, "playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText), "playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice) && /Removed/.test(i.announce) && /Undo/.test(i.announce), "playlist-undone": (i) => i.plrows === 6 && !i.notice, "playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"), "playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"), /* the order after a drop, from the store the page publishes — which is what the backend kept, the page having re-read its rows by now */ "playlist-dragged": (i) => i.plOrder === "Track 4|Track 1|Track 2|Track 3|One More Time|ID" && i.plfirst.startsWith("Track 4"), "playlist-dragged-back": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plfirst.startsWith("Track 1"), /* a filtered list's visible order is not the stored one, so the drag is refused outright and the note under the field says why */ "playlist-drag-filtered": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plrows === 4 && /Clear the search/.test(i.plNote), /* pressed and released without moving: still the row's sheet */ "playlist-drag-held": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText), /* the optimistic order snapped back, and the app said so */ "playlist-drag-refused": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && !!i.toast, "queue-drag-arm": (i) => { globalThis.__q0 = i.qOrder; return i.qrows >= 3 && !!i.qOrder; }, "queue-dragged": (i) => { const a = String(globalThis.__q0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); }, "queue-dragged-back": (i) => !!i.qOrder && i.qOrder === globalThis.__q0, "playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"), "playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline), "playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText), "playlist-match": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText) && !/Remove from playlist/.test(i.halfText), "playlist-match-results": (i) => /Track 4/.test(i.halfText) && /Caribou/.test(i.halfText), "playlist-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice), "playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText), "playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind), "cover-removed": (i) => !i.plcover && /mosaic/.test(i.notice), "cover-set": (i) => i.plcover && /Cover set/.test(i.notice), "playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive", "playlist-renamed": (i) => i.title.startsWith("Late night drive II"), "playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames), "playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal) && !i.a11yAnon, "playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0, "playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText), "playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice), "playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5, "playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText), "playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames), "import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText) && /Folder of audio files/.test(i.halfText), "import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "" && !i.a11yAnon, "import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn), "import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn), "import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2, /* the wait card is transient: the still can take longer than the mock's fetch */ "import-link-wait": (i) => i.title.startsWith("Import") && (i.impWait || i.imp === 5), "import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn), "import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5, "playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5, "import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine), "import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn), "import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn), "import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4, "share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5, "share-cancel": (i) => i.title.startsWith("Your library"), "import-file-sheet": (i) => i.half.includes("up") && /Playlist file/.test(i.halfText), /* impSrc is the round's own assertion, and the "#" is the whole of it: the cache copy on disk is "Warehouse set _3.m3u8", so a draft read off the PATH says that and a draft read off the picked document's display name says this. Both this and the title below go wrong the moment the name stops travelling beside the path. */ "import-file-review": (i) => i.title.startsWith("Import") && /M3U/.test(i.impFmt) && i.impSrc === "Warehouse set #3.m3u8" && i.imp === 3 && /1 in library/.test(i.impCounts) && /Create playlist \(3 tracks\)/.test(i.impBtn), "import-file-created": (i) => i.title.startsWith("Warehouse set #3") && i.plrows === 3, "import-file-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, "import-file-refused": (i) => !i.half.includes("up") && /not on this phone/.test(i.toast), "import-file-cleared": (i) => !i.toast, "cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), "cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live), "cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live), /* ── the first run ──────────────────────────────────────────────── Every one of these is on state that STAYS: a sheet that is up, a counter, a field in the config, a row that is present or absent. Nothing here waits on a notice or a picker — both windows are shorter than the step's own wait plus the screenshot. */ "fresh-boot": (i) => i.setupUp && i.setupCards === "Where your music lives|Soulseek" && i.setupState === "private:out:todo" && i.setupWrites === 0 && /^dialog\/true\/Welcome to SoulStream\/focus$/.test(i.dlg) /* and nothing of the walk before it came through the reload: a phone that has just been installed has no session to restore */ && !i.mini && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState, /* the tour's own door to the sign-in, and the stamp that a tour which ran leaves behind whichever door closed it */ "fresh-community": (i) => !i.setupUp && i.title.startsWith("Community") && i.signIn === "Sign in to Soulseek" && i.setupWrites === 1 && i.setupState === "private:out:done", "fresh-home": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 2 && i.setupNoteText === "Choose your library folder|Sign in on Soulseek", "fresh-albums": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2 && i.setupNoteText === "Choose your library folder|Sign in on Soulseek", "fresh-artists": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2, "fresh-search": (i) => i.title.startsWith("Search") && i.setupNotes === 1 && i.setupRows === 2, /* the preview reads the real config and adds nothing to the counter */ "fresh-preview": (i) => i.setupUp && i.setupWrites === 1 && i.setupCards === "Where your music lives|Soulseek", /* a folder picked from the tour: the config moved, the permission the new folder needs is offered, and still nothing was stamped */ "fresh-folder": (i) => i.setupUp && i.setupState === "chosen:out:done" && i.setupWrites === 1 && /Allow all files access/.test(i.halfText), /* and the note loses the row it answered — because the config changed, not because a flag was flipped */ "fresh-fixed": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 1 && i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1, /* the provisioned device is back, and it is NOT offered a first run */ "fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast, /* ── the playlist export ────────────────────────────────────────── Where the file went is the only thing the command answers, so the notice naming it IS the feature; a dismissed sheet has to leave the screen exactly as quiet as it found it; and a refusal has to arrive as a sentence, because "permission denied (os error 13)" is the one string Android hands back when the document is out of reach. */ "export-open": (i) => i.title.startsWith("Late night drive") && i.plrows >= 1 && !i.toast, "export-sheet": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText), "export-written": (i) => !i.half.includes("up") && /Exported as .+\.csv/.test(i.notice) && !i.toast, "export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast, "export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast), "export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"), /* the instrument itself, before anything is asked of it: the registry is counting, the store is answering, and the walk is not sitting on a toast from the block above. tallyTicks is only asked to ANSWER (-1 is 'no registry'), never to be non-zero: the app's own intervals were all started at boot, before the registry went in, so the only live one here belongs to the mock's playback ticker — requiring it would tie this guard to the mock instead of to the registry. */ "session-warm": (i) => i.tallyEars > 0 && i.tallyTicks >= 0 && i.tallyArt > 0 && !i.toast, /* Nine laps later. The listener and interval counts are the sharp ones — both measured dead flat over the nine, twice — so their headroom is small on purpose; grewEars allows four for a screen that happens to be mounted at the second reading and not the first, and grewTicks is one-sided because a ticker that simply stopped between the readings is not a leak. Both were driven with a listener and an interval deliberately stranded once a lap: +9 and +9, and this line failed. tallyTimers is a CEILING rather than a delta, and nine of the thirty-two are real: every track start schedules a 47-second recents refresh (player.svelte.js, the desktop's own shape), so a walk this fast keeps a dozen of them pending at once. That window is bounded by its own length and does not grow with the session, while a genuinely leaking timer climbs past this ceiling as laps are added. Making it a delta would need a fifty-second quiet period between the two readings, which is not worth the minute — said here rather than tightening the number blind. tallyArt is the bound itself; grewArt's headroom is for a sibling round that puts more records in a lap, and is to be raised deliberately rather than deleted. */ "session-late": (i) => i.grewEars <= 4 && i.grewTicks <= 0 && i.tallyTimers <= 32 && i.tallyArt <= 512 && i.grewArt <= 80 && i.grewCat <= 24 && i.grewNodes <= 200, "session-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"), }; /* the phone walk: a record by name, played for real */ const albumText = ALBUM ? JSON.stringify(ALBUM) : "null"; const PHONE_STEPS = [ { do: `__settle()`, wait: 900 }, { name: "home", wait: 300 }, { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, /* the Library root remembers its tab across runs — back to Albums first */ { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, { do: `__click('.grid2 .release', ${albumText})`, name: "album", wait: 1400 }, { do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 3200 }, { do: `document.querySelector('.fchrome.back').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: 2600 }, { do: `__click('.segtabs button', 'Playlists')`, wait: 800 }, { do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 }, /* dragged on the real core: the second row to the top and straight back, so the playlist is exactly as it was when it is deleted four steps from here. Nothing outside the walk's own playlist is touched */ { name: "playlist-drag-arm", wait: 400 }, { do: `__drag('.pl-rows .trk', 1, 0)`, name: "playlist-dragged", wait: 2000 }, { do: `__drag('.pl-rows .trk', 0, 1)`, name: "playlist-dragged-back", wait: 2000 }, /* the walk's own playlist, on the real core: a row's sheet, the row removed and put back by Undo — the first time that stash has made the round trip through real SQLite — the same sheet off a real press, and the filter. Everything here acts on "Walk test", which the walk made four steps ago and deletes eight steps below, and the order is the one the drags above restored. */ { do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 900 }, { do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 }, { do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1600 }, /* a press released without moving is still the row's sheet — on real touch geometry this time, not a synthesised contextmenu */ { do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 }, { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, /* a filter that matches nothing: the rows go and the stub says so. The drag-refusal note hangs off the UNFILTERED list, so it is up here whatever the filter matched — which is what makes this readable against a playlist made out of whichever record the walk picked. */ { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'zzqq'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 700 }, { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter-cleared", wait: 700 }, { do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 }, /* export, on the real core: the file is built by the desktop's own writers and handed to Android's create-document sheet. That sheet is a system activity the walk cannot tap, so BACK dismisses it — the cancel path is what gets walked, and NOTHING is written to this phone's storage. The playlist is the "Walk test" one the walk itself makes, and deletes three steps below. */ { do: `document.querySelector('.fchrome.more').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.querySelector('.fchrome.more').click()`, name: "playlist-more", wait: 800 }, { do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 }, /* the paste door on the real core: two lines that resolve to a record in the library, reviewed, created, deleted again */ { do: `__click('.newpl', 'Import')`, wait: 700 }, { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 }, /* the review's one switch, which renders only when a line did not resolve: off, and the button's count drops with it; on again, so the draft the next step creates is the three rows it has always been. Found and clicked by hand rather than through `__click`, which THROWS when the row is not there — a paste that resolved completely has nothing to switch, and that is not a failure. */ { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-off", wait: 700 }, { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-on", wait: 700 }, { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 }, /* the unknown row repaired from the real library */ { do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 }, { do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 }, { do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 }, { do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 }, { do: `document.querySelector('.fchrome.more').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.querySelector('.fchrome.more').click()`, name: "artist-more", wait: 900 }, { do: `document.querySelector('.scrim').click()`, wait: 600 }, /* a discography card the library does NOT hold opens the catalog record */ { do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 }, { do: `history.back()`, wait: 800 }, { do: `history.back()`, wait: 800 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 }, /* the translator, on the real build: the backend's own vocabulary in, plain sentences out. No side effects — nothing is signed out (the login throttle is a remembered trap), nothing is asked of the network, nothing is written. It also catches a build where trouble.js simply did not ship: __ss.say would not be there and the line stays empty. */ { do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 }, /* the first run's preview door, on Kyle's own phone. It READS his state — his folder, his account — and must write nothing at all: the walk never clears his config, never re-runs setup and never stamps the flag, and `setupWrites` staying at zero is what says so. Driven through __ss rather than the row in You so the step cannot miss it on a long screen, and closed by the scrim, which on a preview is the same no-op as the button. Nothing is typed and nothing is picked. */ { do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 }, { do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 }, /* the whole discography on the real core, on the same artist the walk already opened above — so the catalog store is warm and the page has its releases the moment it is pushed. It reads and it sorts; nothing is downloaded, played, joined, sent, signed in to or written. */ { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 }, { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, { do: `__click('.screen button.row', null)`, wait: 500 }, { wait: 2500 }, { do: `__click('.sec .all', 'All')`, name: "dg-page", wait: 1600 }, { do: `document.querySelector('.fchrome.more').click()`, name: "dg-sheet", wait: 1000 }, { do: `__click('.halfsheet .chip', 'A–Z')`, name: "dg-az", wait: 1000 }, { do: `document.querySelector('.scrim').click()`, wait: 700 }, { do: `history.back()`, wait: 800 }, { do: `history.back()`, wait: 900 }, /* ── what the device had never been asked ────────────────────────── Every step below was proven in the mock and nowhere else: the library's own shape and its sort, a library TRACK's sheet (the phone had only ever held a CARD), Search's memory, a queue row's sheet, Home's feed, the sleep timer, the stream cache's chip, the Wi-Fi-only guard and a port the core has to refuse. It navigates itself from Home, so it needs no particular screen in front of it, and every write it makes it takes back inside itself: the Library sort is nav state (the entry's own `c.sort`), the sleep timer is put out by the step after the one that reads it, the cache chip and the Wi-Fi switch are read first and restored last, and the refused port writes nothing at all. Nothing here downloads, joins, sends, signs in, or touches Kyle's library. */ { do: `__settle()`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, name: "library", wait: 900 }, { do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 700 }, { do: `__click('.chips .chip', 'Recently added')`, wait: 600 }, { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 900 }, /* Gaps is tolerant on purpose: follows live in the library folder and the phone's own folder starts without any, so the tab can only be asked to render and to stay quiet. Opening it asks the network for nothing — each shelf's lookup is behind that shelf's own tap. */ { do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 1200 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 600 }, { do: `document.querySelector('.grid2 .release').click()`, wait: 1600 }, /* a LOCAL track's own sheet, off a real path: Play next, Add to queue, Add to playlist, and the Format/File/Folder block underneath them. None of it is tapped — the scrim closes it, the page is popped. */ { do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 900 }, { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, { do: `history.back()`, wait: 800 }, /* Search's memory, which on the device is this WebView's localStorage. The chips are the walk's OWN earlier query: the field commits on Enter or when the screen is left, and the walk left it several screens ago. Clearing the field is what brings the empty page — and its recents — back up. */ { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 800 }, { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, name: "search-recent", wait: 900 }, { do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1600 }, { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, wait: 700 }, /* a queue row's sheet. The walk has dragged these rows for two rounds and never opened one. Tolerant: the walked track can be the last queueable row, and then there is no row to press. Nothing in the sheet is tapped — its rows all mutate the queue. */ { do: `(function () { var m = document.querySelector('.mini'); if (m) m.click(); return true; })()`, wait: 1200 }, { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue-face", wait: 900 }, { do: `(function () { var r = document.querySelector('.q-face .row'); if (r) r.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return !!r; })()`, name: "queue-row-sheet", wait: 900 }, { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 }, { do: `__ss.closeSheet()`, wait: 700 }, /* Home's own feed on the real core, and then the sleep timer — the one thing in this block with a life of its own. It is set, read back off the More sheet's Sleep-timer subtitle (which IS `sleepLabel()`, and the only proof the phone can give that the timer took), and put out by the very next step, with nothing between the two: a walk that died in that gap would leave a pause waiting on his playback — so it is the SHORTEST chip in the sheet, not a comfortable one. */ { do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-feed", wait: 1200 }, { do: `document.querySelector('.phead .iconbtn.muted').click()`, name: "home-more", wait: 900 }, { do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 800 }, { do: `__click('.halfsheet .chip', '15 min')`, name: "sleep-set", wait: 800 }, { do: `document.querySelector('.phead .iconbtn.muted').click()`, name: "sleep-armed", wait: 900 }, { do: `(function () { function pick(sel, re) { return [].slice.call(document.querySelectorAll(sel)).find(function (x) { return re.test((x.textContent || '').trim()); }); } var r = pick('.halfsheet .row', /Sleep timer/); if (r) r.click(); return new Promise(function (res) { setTimeout(function () { var c = pick('.halfsheet .chip', /^Off$/); if (c) c.click(); res(!!c); }, 400); }); })()`, name: "sleep-off", wait: 900 }, { do: `document.querySelector('.phead .iconbtn.muted').click()`, name: "sleep-cleared", wait: 900 }, { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, /* You: the stream cache's retention chip, read first and put back last. The value is stashed IN THE PAGE as well as in the harness, because a step's script is fixed before the walk starts and cannot carry the harness's copy. The last chip is never clicked — "Forever" does not lengthen the cache, it saves streamed tracks into the library — so the move is to the neighbour above, or below it when there is no room above. And when Forever is the chip that is ON, nothing is touched at all: setRetention("forever") sends the FLAG and not the number, so a move away and back would put the switch back and leave `stream_retention_days` on whatever the move wrote. CONDITIONAL, then, on his own setting — and the expectation knows which case it is in from the label `you-keep-arm` stashed. */ { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 900 }, { name: "you-keep-arm", wait: 500 }, { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var at = cs.findIndex(function (c) { return c.classList.contains('on'); }); if (at < 0) return ''; window.__keep0 = cs[at].textContent.trim(); if (at === cs.length - 1) return window.__keep0; var pick = (at + 1 < cs.length - 1) ? cs[at + 1] : cs[at - 1]; if (pick) pick.click(); return window.__keep0; })()`, name: "you-keep", wait: 900 }, { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var back = cs.find(function (c) { return c.textContent.trim() === window.__keep0; }); if (back && !back.classList.contains('on')) back.click(); return window.__keep0; })()`, name: "you-keep-back", wait: 900 }, /* the one guard that exists only because this is a phone. The switch is this device's localStorage, not the config; `cellular()` reads `navigator.connection`, so a page-level shadow is the whole fake and a delete is the whole restore — set and removed one step apart, and a reload would drop it anyway. If defineProperty throws in the WebView the catch swallows it, the download is allowed, and the step fails loudly rather than doing anything. The switch goes back to whatever Kyle had it on. */ { name: "wifi-arm", wait: 500 }, { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); window.__wifi0 = r.querySelector('.sw.on') ? 'on' : 'off'; if (window.__wifi0 === 'off') r.click(); return window.__wifi0; })()`, name: "wifi-on", wait: 800 }, { do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 800 }, { do: `(function () { try { delete navigator.connection; } catch (e) {} var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); __ss.player.error = ''; return true; })()`, name: "wifi-cleared", wait: 800 }, { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); var now = r.querySelector('.sw.on') ? 'on' : 'off'; if (now !== window.__wifi0) r.click(); return window.__wifi0; })()`, name: "wifi-restored", wait: 800 }, /* a port the core has to refuse. Nothing is written, and 50300 — the client's own default, which the port steps above already left standing — is still standing afterwards. */ { do: `__click('.screen .row', 'Listening port')`, wait: 800 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 1000 }, { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 600 }, { do: `__settle()`, wait: 900 }, /* the one face of the six that a real build can show without arming anything: `__ssMock` does not exist here. One already-terminal row goes into the transfer store — the same thing the shade steps above do — and comes straight back out. Nothing is downloaded, no room is joined, no message is sent, nobody is signed in, and neither the config nor the library is written. A `failed` row is not in flight, so no notice is posted either. The id is far above the backend's own counter and `list()` sorts id-descending, so it is the first row on the screen even if Kyle's own session is carrying real failures. */ { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "trouble-xfer", wait: 1400 }, { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, name: "trouble-xfer-gone", wait: 1000 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 }, ]; const PHONE_EXPECT = { /* the phone's own launch, held wider on purpose: a real one signs in, restores a session and may walk the follows, none of which the mock does, and its library reads are real SQLite rather than an object in memory. What is asserted here is the shape — a census that counted something, sealed, still went out in one round, and did not drag the art prewarm back to boot. The milliseconds are printed rather than policed: every step's JSON carries bootMs and bootLibMs, so the device's real figures land in the report the first time this walk runs. */ home: (i) => i.bootCalls >= 14 && i.bootCalls <= 90 && i.bootSync >= 20 && i.bootMs >= 0 && !/library_art_prewarm/.test(i.bootCmds), album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost, "album-playing": (i) => i.mini && !i.toast, "lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText), "lyrics-nudged": (i) => i.half.includes("up") && !i.toast, /* the walked album's track may genuinely have no words, so the room's own rows are conditional — the discard is not */ "lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"), "lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"), "sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)), "sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)), "sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)), "sync-discarded": (i) => !i.sync && !i.toast, viewer: (i) => i.viewer, "viewer-closed": (i) => !i.viewer && i.sheet.includes("up"), "home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"), sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yGhost, "after-back": (i) => !i.sheet.includes("up"), "after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast, search: (i) => i.title.startsWith("Search"), "hold-sheet": (i) => i.half.includes("up"), tracks: (i) => i.title.startsWith("Your library"), "contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast, "artist-cold": (i) => i.screen.includes("has-hero"), /* ── round forty: the four steps that could not fail ─────────────── `library-ambience`, `queue`, `lyrics` and `community` have been named phone steps with no key in this map since the rounds that wrote them. The driver reads `expect[s.name] ? expect[s.name](info) : true`, so each one was a screenshot that always passed — four of eighty-eight. All four have a mock expectation; none had a phone one. Each is written to hold against Kyle's own library rather than a fixture. */ "library-ambience": (i) => i.title.startsWith("Your library") && i.amb.includes("on:img"), queue: (i) => i.sheet.includes("up") && i.qFace, /* the walked track may genuinely have no words on the device, so this asks for the face and any one of its three honest states */ lyrics: (i) => i.sheet.includes("up") && (i.lyrLines >= 1 || !!i.lyrNote || i.lyrTools), /* tolerant of the sign-in card, which is what a signed-out phone shows here — but NOT of a toast: a sign-in that failed raises one on this screen, and that is a finding rather than a step to loosen */ community: (i) => i.title.startsWith("Community") && !i.toast, /* ── the walk's own playlist, on real SQLite ───────────────────── The row count is stashed by the sheet step rather than assumed: the playlist is made out of whichever record the walk picked, so its length is his library's business — and a probe that read nothing would otherwise pass every clause below it. */ "playlist-row-sheet": (i) => { globalThis.__plN = i.plrows; return i.half.includes("up") && /Remove from playlist/.test(i.halfText); }, "playlist-removed": (i) => i.plrows === globalThis.__plN - 1 && /Removed/.test(i.notice) && /Undo/.test(i.announce), /* the undo stash round-tripped through the real backend for the first time: the row is back, in the place it was taken from. `__pl0` is the order `playlist-drag-arm` stashed before the two drags, which `playlist-dragged-back` has already put back. */ "playlist-undone": (i) => i.plrows === globalThis.__plN && !i.notice && (globalThis.__plN < 2 || i.plOrder === globalThis.__pl0), /* CONDITIONAL: `lift()` refuses a list of fewer than two rows, so a one-track record — which makes a one-row playlist — has no press to release. The two drag steps above tolerate exactly the same thing. */ "playlist-drag-held": (i) => globalThis.__plN < 2 || (i.half.includes("up") && /Remove from playlist/.test(i.halfText)), /* CONDITIONAL in its last clause: the drag-refusal note needs more than one row to drag. The note itself hangs off the UNFILTERED list, so a filter that matched nothing still shows it. */ "playlist-filter": (i) => i.plrows === 0 && /Nothing here matches/.test(i.stubText) && (globalThis.__plN < 2 || /Clear the search/.test(i.plNote)), "playlist-filter-cleared": (i) => i.plrows === globalThis.__plN && !i.plNote, /* ── the review's Keep switch ──────────────────────────────────── CONDITIONAL: the row renders only when something did not resolve, and a paste that resolved completely leaves `keepOn` false anyway. The second step is what guarantees `import-created` below still sees the three rows it has always seen. */ "import-keep-off": (i) => !i.keepOn && /Create playlist \(\d+ tracks?\)/.test(i.impBtn), "import-keep-on": (i) => /Create playlist \(3 tracks\)/.test(i.impBtn) && i.imp === 3, /* ── the library's own shape, on the device for the first time ──── */ /* `a11yBad` is left out for the reason `track-sheet` leaves it out: a card's name is [title, artist, badge] off his own tags, and `junk()` calls a name of nothing but digits bad — "1999, 311" is a real record. The mock asserts it against a fixture, where it belongs. The structural three stay: nameless, ghost and mis-stated. */ library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 1 && /hours|min/.test(i.countline) && !i.a11yAnon && !i.a11yGhost && !i.a11yState, /* the sort lives on the Library entry (`c.sort`), not in the config, so it needs no putting back — the chip after it does that anyway. What a real library's top format IS cannot be asserted; that the grid re-sorted and still badges every card can be. */ "library-by-format": (i) => !!i.firstBadge && i.fmtBadges >= 1 && !i.toast && !i.a11yState, artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 1 && !i.toast, /* CONDITIONAL: follows live in the library folder and the phone's own folder starts without any, so a phone with no follows can only be asked for the stub */ gaps: (i) => i.title.startsWith("Your library") && !i.toast && (i.gaplines >= 1 || !!i.stubText), /* a LOCAL track's sheet: the three things it offers, the file block underneath it off a real path, and the layer named and holding focus. `a11yBad` is left out deliberately — it reads the names of real rows, and a record of his is allowed to be called anything. */ "track-sheet": (i) => i.half.includes("up") && /Play next/.test(i.halfText) && /Add to queue/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Format/.test(i.halfText) && /Folder/.test(i.halfText) && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yGhost, /* the recents are localStorage on the device, written by the walk's own earlier search when that screen was left */ "search-recent": (i) => i.title.startsWith("Search") && i.sq === "" && i.recents >= 1, "search-recent-pick": (i) => i.title.startsWith("Search") && !!i.sq, "queue-face": (i) => i.sheet.includes("up") && i.qFace, /* CONDITIONAL: the walked track can be the last queueable row, and then there is nothing to press. Asserted exactly when there is. */ "queue-row-sheet": (i) => !i.half.includes("up") || (/Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText)), /* Home's feed on a phone whose library folder holds no follows: the store answered, and not one of its slots came back with an error */ "home-feed": (i) => /^(Good |Still)/.test(i.title) && !i.toast && i.homeSlots >= 0 && i.homeErr === 0, "home-more": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText), "sleep-sheet": (i) => i.half.includes("up") && /Pause after/.test(i.halfText) && /1½ hours/.test(i.halfText), /* every chip in that sheet goes through `act()`, which closes it — so this says nothing at all about the timer. The step after it does. */ "sleep-set": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), /* the proof: the More sheet's Sleep-timer subtitle IS `sleepLabel()`, and the only thing that can make it read "N min left" is a `player.sleepAt` in the future */ "sleep-armed": (i) => i.half.includes("up") && /min left/.test(i.halfText), "sleep-off": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), "sleep-cleared": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText) && !/min left/.test(i.halfText), /* the retention chip: read, moved, put back. The harness's stash and the page's own (`window.__keep0`) hold the same value for the same reason the drag steps keep two — a step's script is fixed before the walk starts and cannot carry this one. */ "you-keep-arm": (i) => { globalThis.__keepWas = i.keep; return !!i.keep; }, /* CONDITIONAL: the step moves nothing when "Forever" is the chip that is on, because the way back writes the flag and not the number. The label is what says which case this is. */ "you-keep": (i) => !!i.keep && (/^Forever/.test(globalThis.__keepWas) ? i.keep === globalThis.__keepWas : i.keep !== globalThis.__keepWas), "you-keep-back": (i) => i.keep === globalThis.__keepWas, /* the Wi-Fi-only guard, which exists only because this is a phone */ "wifi-arm": (i) => { globalThis.__wifiWas = i.wifiOnly; return !!i.wifiOnly; }, "wifi-on": (i) => i.wifiOnly === "on", "wifi-refused": (i) => /Wi-Fi/.test(i.toast), /* the shadow removed and the sentence taken off the bar, with the switch still where the step above put it */ "wifi-cleared": (i) => !i.toast && i.wifiOnly === "on", "wifi-restored": (i) => i.wifiOnly === globalThis.__wifiWas, /* a port the core refuses: nothing written, and the number the walk already left standing still standing */ "you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50300 /.test(i.port), "sync-estimated": (i) => !i.toast && (!i.sync || (/Clear the estimate/.test(i.syEst) ? i.syTicks > 0 && i.syStrip === 64 : /Estimate the timing|Reading the song|No estimate/.test(i.syEst))), you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax && !i.a11yAnon && !i.a11yGhost && !i.a11yState, "you-norm-off": (i) => i.norm === "off" && !i.toast, "you-norm-on": (i) => i.norm === "on" && !i.toast, "you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast, "you-nolimit": (i) => i.srcMax === "No limit" && !i.toast, "you-slots": (i) => /^\d+$/.test(i.slots) && !i.toast, "you-slots-back": (i) => /^\d+$/.test(i.slots) && !i.toast, "you-words": (i) => i.wordfill === "on" && !i.toast, "you-words-off": (i) => i.wordfill === "off" && !i.toast, "you-artedge": (i) => i.artEdge === "1000 px" && !i.toast, "you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast, "you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast, "you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast, "you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast, "shade-open": (i) => i.title.startsWith("Transfers") && !i.toast, "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText), /* the dump has to have been read for the negative to mean anything: one that came back empty would pass "no downloads channel" on nothing at all. A format change fails the step above, loudly, first */ "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans), playlists: (i) => i.title.startsWith("Your library"), "playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast, "playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText), /* the notice is transient and the device writes nine rows faster than the probe can land on it — so either the notice is still up, or the sheet has closed behind a finished add. playlist-filled proves the content either way. */ "playlist-added": (i) => (/Added .* to Walk test/.test(i.notice) || !i.half.includes("up")) && !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) && !/^Error/m.test(i.adbOut || ""), "share-cancel": (i) => i.title.startsWith("Your library"), artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1 && /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast), "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText), "catalog-album": (i) => i.screen.includes("tr-push") && !!i.title, /* eleven real backend strings through the one translator on the device: every output a sentence, and not one of them still carrying Rust */ "trouble-words": (i) => i.trouble.split(" || ").length === 11 && i.trouble.split(" || ").every(function (s) { return s.length > 12; }) && !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble), /* both cards on a phone that is fully set up, and the counter at zero on the way in and on the way out — the preview wrote nothing to his config. `setupWrites` would also be 1 if the boot gate had fired on his phone, which is the other thing this catches. */ "setup-preview": (i) => i.setupUp && i.setupWrites === 0 && i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost, "setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast, /* the export door on the real core. The sheet the second step opens is Android's own, so nothing here asserts that it is UP: the core can build a ten-row M3U8 and hand off before the probe lands, and a step that demanded the picker would be the one that failed for being too fast. What is asserted is the app's own half sheet closing, nothing going red either side of the system sheet, and — after BACK — no notice, because a dismissed sheet wrote nothing to name. */ "export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText), "export-picker": (i) => !i.half.includes("up") && !i.toast, "export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice, /* the real core answers faster than the mock does, and it answers with Kyle's own artists, so none of this asserts a count, a title or a state with a window shorter than the still: only that the page is there, listing something, in the order the sheet was just told to use, and that nothing went wrong saying so */ "dg-page": (i) => i.dgTabs >= 1 && i.dgRows >= 1 && !i.toast && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, "dg-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText) && !i.toast, "dg-az": (i) => i.dgRows >= 1 && /A–Z/.test(i.dgOrder) && !i.toast, /* the row's own words on the device, and then its absence. The "gone" clause is written against THIS row's sentence rather than against `xferFail` being empty: a real failed transfer of Kyle's sitting in the list is not a reason to fail the step, and the real core answering faster than a mock would cannot change either reading — both are settled state, written and removed by the step itself. The screen underneath is deliberately not named: sibling rounds append their own phone steps before `end` too, so whichever screen the back lands on is theirs, not this round's. What IS asserted is that the back popped at all — a sheet left up would close instead and the bar would still say Transfers. */ "trouble-xfer": (i) => i.title.startsWith("Transfers") && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) && !/transfer rejected/.test(i.xferFail), "trouble-xfer-gone": (i) => !/^Transfers/.test(i.title) && !/File not shared/.test(i.xferFail), end: (i) => /^(Good |Still)/.test(i.title), }; /* What OUR package has in the shade right now: the channels its live * notifications sit on, and the downloads one's own title and text. * `--noredact` is what makes the extras print their values rather than a * placeholder. Nobody else's notifications are read — `seen` counts the * dump's records only so that "no downloads channel" cannot pass by the * dump having come back empty. */ function shadeNotice() { try { const dump = adb("shell", "dumpsys", "notification", "--noredact").toString(); const blocks = dump.split("NotificationRecord(").slice(1); const chans = []; let title = "", text = "", card = ""; for (const block of blocks) { const head = block.slice(0, 6000); if (!/pkg=com\.soulstream\.app\b/.test(head)) continue; /* Notification.toString() carries `channel=` 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); /* the extras print as `= ()`, 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, "") : ""; }; /* the media card's own title, for `media-card` in the phone tail. This is why the `continue` below now sits under the `extra` declaration rather than over it. */ if (ch === "playback") card = extra("android.title"); if (ch !== "downloads") continue; title = extra("android.title"); text = extra("android.text"); } return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text, shadeCard: card }; } catch (e) { return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "", shadeCard: "" }; } } /* 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 phone, turned over: the two settings that do it ─────────────── * `settings get system accelerometer_rotation` and `user_rotation` are the * only two things on the device this walk ever writes, and both are READ * before either is written. The saved pair is latched at module scope on * purpose: the step loop's `still()` and its PROBE call are wrapped in * nothing at all, so a WebView that stops answering rejects after cdp.mjs's * timeout and throws clean out of the walk into main's `finally`. That is * the exact path that would otherwise leave Kyle's phone locked sideways, * which is why the restore is a latch reachable from four places — the * rotation block's own finally, main's finally, process 'exit' and a * signal — rather than a step in a list. */ let rotationSaved = null; /** Both rotation settings as the device has them now, or null when either * will not read as something we recognise. null is the SKIP signal: the * block never writes what it could not read first. `user_rotation` comes * back as the literal "null" on a phone where it has never been set. */ function rotationSettings() { try { const accel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim(); const user = adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); if (!/^\d+$/.test(accel)) return null; if (!/^\d+$/.test(user) && user !== "null") return null; return { accel, user }; } catch { return null; } } /** Read once and latch. Returns the pair, or null when the device would not * say — in which case nothing has been written and there is nothing to put * back. */ function rotationArm() { const was = rotationSettings(); if (was) rotationSaved = was; return was; } /** Drop the latch WITHOUT writing anything. For the skip paths: the pair has * been read but nothing has been put, and restoring there would write two * settings onto Kyle's phone that this walk never changed. */ function rotationDisarm() { rotationSaved = null; } /** Hold the phone at one rotation. Auto-rotate goes off FIRST: with the * sensor still in charge it takes `user_rotation` straight back and the * turn never happens. Returns null, or the message it failed with. */ function rotationPut(n) { try { adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); adb("shell", "settings", "put", "system", "user_rotation", String(n)); return null; } catch (e) { return e.message; } } /** Put both back, once. The latch is cleared BEFORE anything is written, so * the first caller is the only one that acts and the three behind it are * no-ops; a restore that fails puts the latch back so the next caller can * try again. The rotation goes first and auto-rotate last — a phone whose * auto-rotate was on must hand control back to the sensor only once the * angle underneath it is the one it started at. Synchronous, because * `adb()` is spawnSync and nothing asynchronous survives an 'exit' * handler. Returns true, null when there was nothing to restore, or the * message it failed with. */ function rotationRestore() { const was = rotationSaved; rotationSaved = null; if (!was) return null; try { if (was.user === "null") { /* never set on this phone: delete it rather than inventing a 0 */ try { adb("shell", "settings", "delete", "system", "user_rotation"); } catch { adb("shell", "settings", "put", "system", "user_rotation", "0"); } } else { adb("shell", "settings", "put", "system", "user_rotation", was.user); } adb("shell", "settings", "put", "system", "accelerometer_rotation", was.accel); return true; } catch (e) { rotationSaved = was; return e.message; } } /** The system's own view of the turn, for the report only. The two settings * read back, plus the display's current size and rotation out of `dumpsys * window displays` — whose spelling moves between Android versions, so a * parse failure comes back empty and is printed, never asserted. */ function rotationSystem() { const out = { setAccel: "", setUser: "", dispCur: "", dispRot: "" }; try { out.setAccel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim(); out.setUser = adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); } catch { /* empty is the reading */ } try { const d = adb("shell", "dumpsys", "window", "displays").toString(); out.dispCur = (d.match(/\bcur=(\d+x\d+)/) || [])[1] || ""; out.dispRot = (d.match(/mCurrentRotation=(?:ROTATION_)?(\w+)/) || d.match(/\brotation=(\d+)/) || [])[1] || ""; } catch { /* empty is the reading */ } return out; } /* 'exit' does not run on a signal and a signal does not run 'exit', so both. Ctrl-C in the middle of the turn is precisely the case that would otherwise leave the phone sideways until Kyle found the Quick Settings tile. Registered only for the phone walk — the mock never writes a setting, and a SIGINT handler on the mock run would change how Ctrl-C behaves there for nothing. */ if (PHONE) { process.on("exit", () => { rotationRestore(); }); for (const sig of ["SIGINT", "SIGTERM", "SIGBREAK"]) { process.on(sig, () => { rotationRestore(); process.exit(130); }); } } /* The audio mixer's underrun count for the app's fast track: a dropout the * ear hears is a number here. Read before and after the walk; a rise during * it is a finding. */ let appUidCache = null; function appUid() { if (appUidCache) return appUidCache; try { appUidCache = (adb("shell", "dumpsys", "package", "com.soulstream.app").toString().match(/\buid=(\d+)/) || [])[1] || null; } catch { appUidCache = null; } return appUidCache; } /* SoulStream's own tracks only: the mixer's thread counters are system-wide and any app's notification sound moves them. Each track row of the dump carries its client "pid/ uid" and an Underruns column (third from the end, before Flushed and BitPerfect). */ function audioUnderruns() { try { const uid = appUid(); if (!uid) return null; const dump = adb("shell", "dumpsys", "media.audio_flinger").toString(); let n = 0; for (const line of dump.split("\n")) { const m = line.match(/(\d+)\/\s+(\d+)\s+\d+\s+\d+\s/); if (!m || m[2] !== uid) continue; const t = line.trim().split(/\s+/); const u = Number(t[t.length - 3]); if (Number.isFinite(u)) n += u; } return n; } catch { return null; } } async function main() { fs.mkdirSync(OUT, { recursive: true }); const problems = []; let cdp = null; // reassigned by the restart phase let underrunsBefore = null; let underrunRise = 0; /* summed across the restart: a relaunch starts fresh tracks at zero */ let chrome = null; let steps, expect, still, prefix; if (PHONE && ARGS.includes("--only-lock")) { /* the lock-screen card alone: whatever is on the bar plays, the phone sleeps, wakes to its lock screen, and that is the still. Leaves the phone locked. */ const attached = await attachPhone(PORT); cdp = attached.cdp; await cdp.send("Runtime.enable"); const st = await cdp.evaluate(`(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, status: p.status } : null; })()`); if (!st) { console.log("nothing on the bar to show on the lock screen"); process.exit(1); } if (st.status !== "playing") { await cdp.evaluate(`window.__ss && __ss.toggle()`); await sleep(2500); } cdp.close(); cdp = null; adb("shell", "input", "keyevent", "KEYCODE_SLEEP"); await sleep(2000); adb("shell", "input", "keyevent", "KEYCODE_WAKEUP"); await sleep(2500); fs.mkdirSync(OUT, { recursive: true }); await phoneStill(path.join(OUT, "phone-lockscreen.png")); console.log(" -- lockscreen still taken (" + st.title + "); the phone is locked now"); process.exit(0); } if (PHONE) { const attached = await attachPhone(PORT); cdp = attached.cdp; const pid = attached.pid; underrunsBefore = audioUnderruns(); steps = PHONE_STEPS; expect = PHONE_EXPECT; prefix = "phone-"; still = phoneStill; console.log("attached to the phone's WebView (pid " + pid + ")"); /* A walk follows a fresh install, so the app is always cold here: the shell mounts, the library loads, Soulseek logs in and the share index is built. Attaching only proves the WebView answers — it says nothing about the app being up. Starting anyway means the first steps run against a half-mounted page and get reported as broken features, which is exactly how an hour went once. Wait for the handle the walk actually drives. */ const upFrom = Date.now(); let up = false; while (Date.now() - upFrom < 90_000) { try { if (await cdp.evaluate(`!!(window.__ss && document.querySelector('.app'))`) === true) { up = true; break; } } catch { /* a page mid-navigation answers nothing; ask again */ } await sleep(1000); } if (!up) throw new Error("the app never came up on the phone (no __ss and no .app after 90s)"); console.log("the app is up (" + ((Date.now() - upFrom) / 1000).toFixed(1) + "s after attaching)"); } else { if (!CHROME) throw new Error("Chrome not found"); /* ship-fast.sh builds dist-smoke itself so it can start the deploy at the same time as this walk — two vite builds racing for the same dependency cache is not worth the four minutes. SS_SKIP_BUILD says it is already there and current. */ if (process.env.SS_SKIP_BUILD === "1" && fs.existsSync(path.join(ROOT, "dist-smoke", "mobile.html"))) { console.log("using the dist-smoke/ that was built for us"); } else { 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); } /* ── waiting only as long as it takes ─────────────────────────── Every step used to sleep its declared wait, sized for the worst case it might ever meet. At 632 steps that was over half of a round: 414 seconds declared in the mock list, 223 in the phone's. So: poll something cheap until it stops changing, then go on. The fingerprint reads the shape of the page AND the app's own moving parts, because a step can be waiting on work that has not reached the DOM yet — a resolve in flight, a transfer arriving, a command that will only show itself in a toast. Two properties keep this honest: · It can only ever SHORTEN a wait, never lengthen one. The declared wait is the ceiling, so a step that passes today still gets every millisecond it got before. · There is a floor. A page can be momentarily quiet in the middle of work — a backend write with nothing visible behind it looks settled the instant it is asked — so nothing is probed before a third of its declared wait has passed. `hold: true` on a step opts out and sleeps the whole wait, for the handful that are waiting on something nothing here can see. */ const FINGERPRINT = `(function () { try { var t = document.querySelector('.toast'); var ss = window.__ss || {}; var pl = ss.player || {}, rs = ss.resolver || {}, nt = ss.net || {}, lb = ss.lib || {}; /* a catalog lookup never touches any of those: it sits in catalog.artists[name].status until it turns 'ready', and the artist page is waiting on exactly that. */ var ct = ss.catalog || {}, looking = 0; ['artists', 'albums', 'extras', 'profiles'].forEach(function (k) { var m = ct[k] || {}; for (var n in m) { if (m[n] && m[n].status === 'loading') looking++; } }); /* what is still running, as its own answer. A search that has sent nothing back yet holds every counter still, so a settled-looking page is not the same as a settled one. */ var busy = (rs.active || nt.searching || looking > 0) ? 1 : 0; return busy + '|' + [ document.querySelectorAll('*').length, document.images.length, (document.querySelector('.screen') || {}).className || '', (document.querySelector('.sheet') || {}).className || '', (document.querySelector('.halfsheet') || {}).className || '', t ? t.textContent.slice(0, 40) : '', document.title, pl.status, pl.phase, Math.round(pl.position || 0), (pl.queue || []).length, pl.qi, rs.active ? 1 : 0, nt.searching ? 1 : 0, looking, nt.responses || 0, nt.files || 0, (nt.groups || []).length, lb.loaded ? 1 : 0, (lb.albums || []).length, (ss.transfers && ss.transfers.items ? ss.transfers.items.length : 0) ].join('|'); } catch (e) { return '1|unreadable'; } })()`; /* two identical readings 90ms apart, and nothing has moved */ const STABLE_READINGS = 2; const POLL_MS = 90; const FLOOR = 0.34; /* Off unless asked for. The declared waits are what round 42 was verified on, and what every expectation was written against; the short ones are an optimisation that has not earned the default yet. SS_ADAPTIVE=1 turns them on — and turning it on and off is the A/B this was missing. */ const ADAPTIVE = process.env.SS_ADAPTIVE === "1"; let waited = 0, saved = 0; /* what the walk actually spent, and what it did not */ async function settle(want, step) { waited += want; if (!ADAPTIVE || (step && step.hold)) { await sleep(want); return; } const began = Date.now(); await sleep(Math.min(want, Math.max(200, Math.round(want * FLOOR)))); let last = null, stable = 0; while (Date.now() - began < want) { let fp; try { fp = await cdp.evaluate(FINGERPRINT); } catch { break; } /* the leading field is the busy flag: while anything the app admits to is still running, a still page does not count as a settled one. The declared wait is still the ceiling, so this can only ever spend what it was given. */ const working = typeof fp === 'string' && fp.charAt(0) === '1'; if (fp === last && !working) { if (++stable >= STABLE_READINGS) break; } else { stable = 0; last = fp; } const left = want - (Date.now() - began); if (left <= 0) break; await sleep(Math.min(POLL_MS, left)); } saved += Math.max(0, want - (Date.now() - began)); } console.log(ADAPTIVE ? "adaptive waits: on (SS_ADAPTIVE=1)" : "adaptive waits: off — sleeping every declared wait"); for (const s of steps) { /* a phone step can be an adb command — an intent, a key */ let adbOut = ""; if (s.adb) { /* kept, not discarded: `am start` prints "Error: Activity not started, unable to resolve Intent" on STDOUT and still exits 0, so a step's own expectation is the only thing that can tell a resolved intent from an unresolved one. It reaches the expectation as `adbOut` below. */ try { adbOut = adb(...s.adb).toString().trim(); } 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 settle(s.wait || 500, s); 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); if (adbOut) info.adbOut = adbOut; /* 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)); } console.log("waits: " + Math.round(saved / 1000) + "s of " + Math.round(waited / 1000) + "s declared was not spent"); /* ── 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)); /* ── the card is still in the shade ─────────────────────── What a small icon the shade cannot inflate, or a large icon that throws while the notification is built, actually does: the card is never posted at all. So read it back out of the same dump the downloads notice uses — our package, on the `playback` channel, with the playing track's own title on it. The title is taken from the player on BOTH sides of the dump because a track may cross while it runs, and either answer is a correct one; both sides come from the live app at the same moment, so a real core answering faster than the mock cannot make this disagree with itself. It runs after `resumed` has asserted status === "playing", so the ongoing card is settled state rather than a race, and it sits above `media-keys`, which proves the transport still answers through the session. What this canNOT prove: the GLYPH. `dumpsys notification` prints the icon's resource id and never a pixel, so a correct falcon and a white rectangle read identically here. That one is a screenshot. */ let cardBefore = await cdp.evaluate(PLAYER); let cardNote = shadeNotice(); let cardAfter = await cdp.evaluate(PLAYER); const cardAgrees = () => /playback/.test(cardNote.shadeChans) && !!cardNote.shadeCard && (cardNote.shadeCard === cardBefore?.title || cardNote.shadeCard === cardAfter?.title); if (!cardAgrees()) { /* the one race the two reads do not cover: `push()` in media.svelte.js is debounced, so the card trails the player by a moment, and a track crossing exactly here shows the player two NEW titles and the shade the old one. Let it settle and look once more. A card that is not posted at all fails this the second time too. */ await sleep(2000); cardBefore = await cdp.evaluate(PLAYER); cardNote = shadeNotice(); cardAfter = await cdp.evaluate(PLAYER); } const cardOk = cardAgrees(); const cardSeen = { chans: cardNote.shadeChans, card: cardNote.shadeCard, title: cardAfter?.title ?? null }; if (!cardOk) problems.push("EXPECT media-card " + JSON.stringify(cardSeen)); console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen)); adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE"); await sleep(1500); const keyPaused = await cdp.evaluate(PLAYER); adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PLAY"); await sleep(2000); const keyPlaying = await cdp.evaluate(PLAYER); const keysOk = keyPaused?.status === "paused" && keyPlaying?.status === "playing"; if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying })); console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status })); const bgBefore = await cdp.evaluate(PLAYER); const posBefore = bgBefore?.pos ?? 0; cdp.close(); adb("shell", "input", "keyevent", "KEYCODE_HOME"); await sleep(10000); const stillPlaying = /PLAYING/.test(mediaSessionState().state); adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity"); await sleep(2500); cdp = await CDP.attach(PORT, { phone: true }); await cdp.send("Runtime.enable"); const bgAfter = await cdp.evaluate(PLAYER); const posAfter = bgAfter?.pos ?? 0; const crossed = !!(bgBefore && bgAfter && bgBefore.title !== bgAfter.title); const bgOk = stillPlaying && (crossed || posAfter >= posBefore + 8); const bgSeen = { posBefore, posAfter, stillPlaying, crossed, from: bgBefore?.title ?? null, to: bgAfter?.title ?? null }; if (!bgOk) problems.push("EXPECT background " + JSON.stringify(bgSeen)); console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen)); await still(path.join(OUT, `${prefix}shade.png`)); /* ── turned over, for real ──────────────────────────────── Round twenty-seven wrote the landscape layout and proved it in headless Chrome behind a viewport override. No real device had ever been turned. The phone walk still refuses a `{ view: [...] }` step — a device's orientation is not the step list's to set — so the turn lives here in the tail instead, where it can hold a latch and put the phone back from a `finally` whatever happens above it. It lands right after `background`, which has just asserted the position advanced by eight seconds with the app off screen: playback is provably live, which is the best arming state there is. What only a device can answer, and the override cannot fake: screen.orientation.angle (an override without `screenOrientation` leaves it at 0, so 90 is a fact rather than a viewport), env()'s real insets around the cutout and the gesture pill, whether the activity survived the configuration change instead of being recreated, and whether the audio kept going through it. The turn and the RESIZE are separate clauses on purpose: a WebView that never got the configuration change otherwise looks exactly like a display that never rotated. What is NOT asserted, because nobody has measured it yet: the inset numbers themselves. MainActivity calls androidx's enableEdgeToEdge() and nothing in the generated Kotlin touches insets, so env() comes straight out of Chromium's own window-inset mapping and whether the hole-punch shows up as a non-zero side inset depends on the cutout mode that picks. The four numbers are REPORTED; what is asserted is the plumbing — that .screen, .mini and .np spend whatever env() says, which is the rule the side-inset longhands at the foot of shell.css exist to win. Ditto chromePct: the first run is what records the truth, and a later round can tighten it. The phone is Kyle's. Both settings are read before either is written and put back from four places; a device whose settings will not read is skipped rather than forced; and `--no-rotate` skips the block outright, for a run where he is holding the phone. */ if (ARGS.includes("--no-rotate")) { console.log(" -- rotation skipped: --no-rotate"); } else { /* the phase's own reading, beside PLAYER / STREAM / HERO / CAT / NET — PROBE is deliberately left alone, because sibling rounds append their fields to it. env() cannot be read out of a custom property everywhere, so the ground truth is a throwaway fixed 0x0 div whose padding IS the four env()s, read back computed; the app's own --inset-left/right are read beside it and printed, so a disagreement between the two shows up in the report rather than hiding inside an assertion. */ const ROT = `(function () { var d = document.createElement('div'); d.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none;' + 'padding-top:env(safe-area-inset-top,0px);padding-right:env(safe-area-inset-right,0px);' + 'padding-bottom:env(safe-area-inset-bottom,0px);padding-left:env(safe-area-inset-left,0px)'; document.documentElement.appendChild(d); var cs = getComputedStyle(d); var ins = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(function (v) { return Math.round(parseFloat(v) || 0); }); d.remove(); var rs = getComputedStyle(document.documentElement); 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; } function pad(sel, side) { var x = document.querySelector(sel); return x ? Math.round(parseFloat(getComputedStyle(x)[side]) || 0) : -1; } /* the chrome that is actually on screen: a mini bar under a raised sheet and a nav under one are translated away on purpose */ var out = []; ['.fchrome.back', '.screen', '.mini:not(.hidden)', '.nav:not(.gone)'].forEach(function (sel) { var b = box(sel); if (b && b.height > 0 && !inside(b)) out.push(sel); }); /* the witness that an activity recreation would destroy: planted on the first reading, gone with the window if a new one replaces it */ if (!window.__ssTurn) window.__ssTurn = 'turn-' + Date.now() + '-' + Math.round(Math.random() * 1000000); var p = window.__ss && __ss.player; var n = window.__ss && __ss.nav; return { token: window.__ssTurn, origin: Math.round(performance.timeOrigin), w: innerWidth, h: innerHeight, vp: innerWidth + 'x' + innerHeight, /* the one thing a viewport override cannot move */ angle: (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : -1, ori: (screen.orientation && screen.orientation.type) || '', mq: matchMedia('(orientation: landscape)').matches, /* the phone-sized landscape query, read off the one variable only it sets: 60px portrait, 48px sideways */ navH: (rs.getPropertyValue('--nav-h') || '').trim(), insets: ins, varLeft: Math.round(parseFloat(rs.getPropertyValue('--inset-left')) || 0), varRight: Math.round(parseFloat(rs.getPropertyValue('--inset-right')) || 0), screenPadL: pad('.screen', 'paddingLeft'), screenPadR: pad('.screen', 'paddingRight'), npPadL: pad('.sheet.up .np', 'paddingLeft'), npPadR: pad('.sheet.up .np', 'paddingRight'), /* both edges, because a hole-punch is on ONE of them: sideways the cutout is left or right depending on which way the phone turned, and a check of the left alone would pass by the inset being 0 */ miniLeft: Math.round((box('.mini') || { left: -1 }).left), miniRight: Math.round(innerWidth - (box('.mini') || { right: innerWidth + 1 }).right), chromePct: (function () { var g = box('.fchrome.back'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(), outside: out.join('|'), sheet: !!document.querySelector('.sheet.up'), 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, 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'; })(), tab: n ? n.tab : '', depth: (function () { try { return Object.keys(n.stacks).map(function (k) { return k + ':' + n.stacks[k].length; }).join(','); } catch (e) { return ''; } })(), status: p ? p.status : '', title: p && p.track ? p.track.title : '', pos: p ? Math.round(p.position) : -1, }; })()`; /* 15 s, not cdp.evaluate's 45: if the activity really was recreated the socket is dead, and this block asks three times over. A broken device should cost the walk under a minute, not three. */ const rotAsk = async (expr) => { const r = await cdp.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }, 15000); if (r.result && r.result.exceptionDetails) { throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text || "")); } return r.result?.result?.value; }; /* a known screen: Home, nothing open, no transition in flight, so `.screen` is the viewport box and not a sheet sliding across it */ try { await cdp.evaluate(HELPERS); await cdp.evaluate(`__settle()`); await sleep(900); } catch { /* the reading below says so */ } const was = rotationArm(); let start = null; try { start = await rotAsk(ROT); } catch (e) { start = null; } if (!was) { console.log(" -- rotation skipped: the phone's rotation settings would not read"); } else if (!start) { /* disarmed, not restored: nothing has been written yet, and putting two settings back that were never changed is still writing to his phone */ rotationDisarm(); console.log(" -- rotation skipped: the page did not answer before the turn"); } else if (start.w > start.h) { rotationDisarm(); console.log(" -- rotation skipped: the phone is already sideways (" + start.vp + ")"); } else { /* "it kept playing" means nothing unless it was playing: the restart phase's own idiom, and the only call this block makes into the app */ if (start.status !== "playing") { try { await cdp.evaluate(`window.__ss && __ss.toggle()`); } catch { /* reported by the clause */ } await sleep(2500); try { start = (await rotAsk(ROT)) || start; } catch { /* keep the earlier reading */ } } let rotErr = ""; /* polled, never fixed-waited: a real device turns on its own clock, and reading a slow answer as a failure has cost this project a round twice. A page that stops answering breaks the loop rather than paying 15 s sixteen times over — and that IS the recreated-WebView reading, which the finally below still restores from. */ const until = async (wide) => { let last = null; for (let i = 0; i < 16; i++) { await sleep(500); try { last = await rotAsk(ROT); } catch (e) { rotErr = e.message; return last; } if (last && (wide ? last.w > last.h : last.w < last.h)) return last; } return last; }; /* `adb()` throws on a non-zero exit, and `pidof` exits 1 when the process is GONE — which is the one finding this block most exists to catch. Read tolerantly, so a dead app comes back as `alive: false` in the report rather than as a bare throw that takes every other clause with it; `logcat -c`, which some devices refuse to clear, is the same shape before the turn. */ const adbSay = (...a) => { try { return adb(...a).toString(); } catch { return ""; } }; try { const pid0 = adbSay("shell", "pidof", "com.soulstream.app").trim(); adbSay("logcat", "-c"); const put = rotationPut(1); if (put) problems.push("ROTATION could not turn the phone: " + put); let turned = await until(true); if (!rotErr && !(turned && turned.w > turned.h)) { /* a device whose natural orientation is landscape maps 1 the other way; one retry at 3 is cheap insurance against a false finding */ rotationPut(3); turned = await until(true); } await sleep(700); if (!rotErr) { try { turned = (await rotAsk(ROT)) || turned; } catch (e) { rotErr = e.message; } } try { await still(path.join(OUT, `${prefix}turned.png`)); } catch (e) { problems.push("ROTATION still: " + e.message); } const sys = rotationSystem(); const pid1 = adbSay("shell", "pidof", "com.soulstream.app").trim(); const fatals = (adbSay("logcat", "-d", "-s", "AndroidRuntime:E").match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length; const t = turned || {}; const ins = t.insets || [0, 0, 0, 0]; const checks = { /* only a device moves this */ turned: t.angle === 90 || t.angle === 270, /* and the WebView got the configuration change */ resized: t.w > t.h && t.mq === true, /* …down to the phone-sized query, exactly */ layout: t.navH === "48px", /* and it is still ANSWERING: a reading that went stale because the socket died is otherwise a good-looking reading of the moment before */ answering: !rotErr, alive: !!pid1 && pid1 === pid0 && !fatals, /* the manifest declares orientation and screenSize handled, so the activity must NOT have been recreated: a recreation makes a new window and a new document, and both of these would move */ kept: t.token === start.token && t.origin === start.origin, stack: t.tab === start.tab && t.depth === start.depth, /* a track that rolled over during the turn starts the next one at zero: still playing, which is the whole of what this clause claims */ playing: t.status === "playing" && (t.pos >= start.pos || t.title !== start.title), unclipped: t.outside === "", /* whatever env() says sideways, the chrome spends it — the plumbing, not a number nobody has measured. The `>= 0` clauses are not belt and braces: pad() answers -1 for an element that is not there, and -1 clears `>= inset - 1` whenever the inset is 0, so a missing .screen passed */ plumbing: t.screenPadL >= 0 && t.screenPadR >= 0 && t.screenPadL >= ins[3] - 1 && t.miniLeft >= 8 + ins[3] - 1 && t.screenPadR >= ins[1] - 1 && t.miniRight >= 8 + ins[1] - 1, }; const bad = Object.keys(checks).filter((k) => !checks[k]); const report = { vp: t.vp, angle: t.angle, ori: t.ori, navH: t.navH, chromePct: t.chromePct, insets: ins.join("/"), insetsReal: ins[1] > 0 || ins[3] > 0, vars: t.varLeft + "/" + t.varRight, screenPad: t.screenPadL + "/" + t.screenPadR, mini: t.miniLeft + "/" + t.miniRight, pid: pid1 || null, fatals, sys, err: rotErr, failed: bad.join(","), }; if (bad.length) problems.push("EXPECT rotation " + JSON.stringify(report)); console.log((bad.length ? " FAIL " : " ok ") + "rotation".padEnd(16) + " " + JSON.stringify(report)); /* the sheet is the one surface whose portrait shape cannot survive a wide viewport: two columns, every control on screen, and its own side padding */ if (!rotErr && t.w > t.h) { try { await cdp.evaluate(`var m = document.querySelector('.mini'); m && m.click(); true`); await sleep(1400); await still(path.join(OUT, `${prefix}turned-sheet.png`)); const sh = (await rotAsk(ROT)) || {}; const sIns = sh.insets || [0, 0, 0, 0]; const sChecks = { up: sh.sheet === true, beside: sh.npCols === "beside", onScreen: sh.npOut === 0, /* `>= 0` first: pad() answers -1 for an element that is not there, and -1 clears `>= inset - 1` whenever the inset is 0 */ plumbing: sh.npPadL >= 0 && sh.npPadR >= 0 && sh.npPadL >= sIns[3] - 1 && sh.npPadR >= sIns[1] - 1, }; const sBad = Object.keys(sChecks).filter((k) => !sChecks[k]); const sReport = { vp: sh.vp, npCols: sh.npCols, npOut: sh.npOut, npPad: sh.npPadL + "/" + sh.npPadR, insets: sIns.join("/"), failed: sBad.join(",") }; if (sBad.length) problems.push("EXPECT rotation-sheet " + JSON.stringify(sReport)); console.log((sBad.length ? " FAIL " : " ok ") + "rotation-sheet".padEnd(16) + " " + JSON.stringify(sReport)); await cdp.evaluate(`window.__ss && __ss.closeSheet()`); await sleep(800); } catch (e) { problems.push("ROTATION rotation-sheet: " + e.message); } } else { console.log(" -- rotation-sheet skipped: the phone did not turn"); } } catch (e) { /* an adb that falls over mid-block, a still that cannot be taken: reported, never thrown. A throw out of here would take the network block, the lock-screen still and the walk's own summary with it — and the phone is put back either way, because catch runs before finally. */ problems.push("ROTATION " + e.message); } finally { /* whatever happened above — a clause that failed, a WebView that stopped answering, a throw out of any of it — the phone goes back first and everything else is reported afterwards. */ const back = rotationRestore(); if (back !== true) problems.push("ROTATION could not put the phone's rotation settings back: " + back); rotErr = ""; let home = await until(false); await sleep(600); if (!rotErr) { try { home = (await rotAsk(ROT)) || home; } catch (e) { rotErr = e.message; } } try { await still(path.join(OUT, `${prefix}turned-back.png`)); } catch { /* the phone may be gone */ } const now = rotationSettings(); const h = home || {}; const bChecks = { answering: !rotErr, portrait: h.w > 0 && h.w < h.h, angle: h.angle === start.angle, layout: h.navH === start.navH, kept: h.token === start.token && h.origin === start.origin, playing: h.status === "playing", settings: !!now && now.accel === was.accel && now.user === was.user, }; const bBad = Object.keys(bChecks).filter((k) => !bChecks[k]); const bReport = { vp: h.vp, angle: h.angle, navH: h.navH, status: h.status, settings: now, wanted: was, err: rotErr, failed: bBad.join(",") }; if (bBad.length) problems.push("EXPECT rotation-back " + JSON.stringify(bReport)); console.log((bBad.length ? " FAIL " : " ok ") + "rotation-back".padEnd(16) + " " + JSON.stringify(bReport)); } } } /* ── 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: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(), 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: (function () { var q = document.querySelector('.screen'); return q ? (q.dataset.title || '') + (q.dataset.kicker || '') : ''; })(), 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 { const rotLeft = rotationRestore(); if (rotLeft !== true && rotLeft !== null) problems.push("ROTATION could not put the phone's rotation settings back: " + rotLeft); 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); });