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