Nobody had seen this app on a phone that has just installed it. On Android the library root is never missing — config.rs falls back to the app's own sandbox, a real folder nothing else can see — so "no folder yet" cannot be a null check, and setup_done has never been written by the phone, so every phone in existence reads as unstamped, Kyle's included. The tour therefore opens on three clauses, not one: no flag, no saved account, and no records, read only after the library and the credentials have both answered. Two cards, skippable, and no password anywhere in it — the Soulseek step goes to the card on Community and stops. Every screen that is empty because of those two things now says so with the row that fixes it: Home (whose copy predated the folder picker), Library → Albums, Library → Artists, which had no empty state at all, and Search, where a flat sentence about signing in became the row that does it. A row goes away because the config moved, so the two doors onto the same folder tell each other — You re-reads when the tour moves it, the tour's store is told when You's own picker does — and the all-files row re-reads the grant when the system screen hands the app back, the way You already did. You → This phone opens the tour again as a preview that reads the config and writes nothing, which is the door the phone walk drives on Kyle's own device. The mock can answer as a fresh install now — an empty database, no account, no permission, the sandbox root — so the whole thing is walked end to end, and two commands that had no mock at all (player_control, open_external) got one on the way past. It is the mock walk's first reload, which turned up two things a reload does not clear by itself: the player writes the session back on pagehide, so the fresh block clears it from a pagehide listener of its own, and the viewport emulation lives on the DevTools session, so the harness hands it back after the re-attach.
1907 lines
153 KiB
JavaScript
1907 lines
153 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 + '"' : ''));
|
||
};
|
||
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,
|
||
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 || '',
|
||
keepOn: !!document.querySelector('.imp-form .sw.on'),
|
||
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
|
||
brwDirs: document.querySelectorAll('.brw-dir').length,
|
||
qrows: document.querySelectorAll('.q-face .row').length,
|
||
viewer: !!document.querySelector('.viewer img'),
|
||
fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length,
|
||
played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '',
|
||
lyrLines: document.querySelectorAll('.lyr .ln').length,
|
||
sync: !!document.querySelector('.syncroom'),
|
||
syCount: (document.querySelector('.sy-count') || {}).textContent || '',
|
||
syNow: (document.querySelector('.sy-now') || {}).textContent || '',
|
||
lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
||
trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(),
|
||
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
|
||
recents: document.querySelectorAll('.recent-chips .chip').length,
|
||
dym: (document.querySelector('.dym') || {}).textContent || '',
|
||
sq: (document.querySelector('.sfield input') || {}).value || '',
|
||
slots: (document.querySelector('.slots .stepper .n') || {}).textContent || '',
|
||
wordfill: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Word-by-word/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
||
artEdge: (function () { var ps = document.querySelectorAll('.prefs[data-mode="albumart"] .pref'); var c = ps[1] && ps[1].querySelector('.chip.on'); return c ? c.textContent.trim() : ''; })(),
|
||
brwFiles: document.querySelectorAll('.brw-dir .peer-file').length,
|
||
brwHead: (document.querySelector('.brw-head .m') || {}).textContent || '',
|
||
peerLine: (document.querySelector('.pr-head .m') || {}).textContent || '',
|
||
peerDesc: (document.querySelector('.pr-about') || {}).textContent || '',
|
||
peerPic: !!document.querySelector('.pr-pic img'),
|
||
peerTags: document.querySelectorAll('.pr-tags .chip').length,
|
||
peerGaps: document.querySelectorAll('.pr-gap').length,
|
||
peerSum: (document.querySelector('.pr-sum') || {}).textContent || '',
|
||
keep: (function () { var g = document.querySelector('.prefs[data-mode="streamcache"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
|
||
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
|
||
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
|
||
rowArt: document.querySelectorAll('.screen .row .art img').length,
|
||
notice: (document.querySelector('.toast.notice') || {}).textContent || '',
|
||
promptVal: (document.querySelector('.halfsheet .prompt .field') || {}).value || '',
|
||
/* the history mirror: the entry's depth against the layers the app says the back gesture can peel */
|
||
hd: (history.state && history.state.d != null) ? history.state.d : -1,
|
||
hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(),
|
||
cardImgs: document.querySelectorAll('.rail .acard img').length,
|
||
cardPhs: document.querySelectorAll('.rail .acard svg').length,
|
||
port: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Listening port/.test(x.textContent); }); var m = r && r.querySelector('.m'); return m ? m.textContent.trim() : ''; })(),
|
||
diagRows: document.querySelectorAll('.group.diag .kv').length,
|
||
diagCpu: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /App CPU/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
|
||
diagPlays: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /Play rows/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
|
||
diagDeck: document.querySelectorAll('.group.diag-deck .row').length,
|
||
popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length,
|
||
barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length,
|
||
heroPix: !!document.querySelector('.hero .hero-img.dissolve'),
|
||
likes: [].map.call(document.querySelectorAll('.tastechips.likes .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
|
||
hates: [].map.call(document.querySelectorAll('.tastechips.hates .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
|
||
similar: document.querySelectorAll('.screen .row.similar').length,
|
||
budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||
budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'),
|
||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||
/* the first run: the sheet, the two cards it offers, and the state
|
||
it read out of the config — "<folder>:<account>:<flag>".
|
||
setupWrites is the number of times the store has stamped
|
||
setup_done, and a PREVIEW must leave it where it found it: that
|
||
is the whole proof that the door in You is not a door onto Kyle's
|
||
own config, and the phone walk asserts it on his phone. */
|
||
setupUp: !!document.querySelector('.halfsheet.up .setup'),
|
||
setupCards: [].map.call(document.querySelectorAll('.halfsheet.up .setup .sheet-sec'), function (s) { return s.textContent.trim(); }).join('|'),
|
||
setupState: (function () { try { return __ss.setupState(); } catch (e) { return ''; } })(),
|
||
setupWrites: (function () { try { return __ss.setup.writes; } catch (e) { return -1; } })(),
|
||
/* the note the blocked screen carries, and the rows it offers: a
|
||
row goes away because the config changed, not because a flag was
|
||
flipped, which is why the count is the thing worth reading */
|
||
setupNotes: document.querySelectorAll('.screen .setup-note').length,
|
||
setupRows: document.querySelectorAll('.screen .setup-note .row').length,
|
||
setupNoteText: [].map.call(document.querySelectorAll('.screen .setup-note .row .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||
signIn: ((document.querySelector('.signin .signin-t') || {}).textContent || '').trim(),
|
||
/* the boot census, latched at the shell's first frame and never
|
||
re-opened: how many commands the app asked for before it had
|
||
drawn anything, how many of those left in the shell's own turn,
|
||
which kinds they were, and how long it took. A probe landing a
|
||
second and a half later still reads the launch. -1 is "the census
|
||
never sealed", -2 is "there is no census". bootCmds is the
|
||
distinct names, not the whole run of them — every step carries
|
||
it, and what is ever asked of it is whether a kind of command was
|
||
at boot at all. */
|
||
bootCalls: (function () { try { return __ss.boot.sealed ? __ss.boot.calls : -1; } catch (e) { return -2; } })(),
|
||
bootSync: (function () { try { return __ss.boot.sync; } catch (e) { return -2; } })(),
|
||
bootMs: (function () { try { return __ss.boot.frameMs == null ? -1 : __ss.boot.frameMs; } catch (e) { return -2; } })(),
|
||
bootLibMs: (function () { try { return __ss.boot.libMs == null ? -1 : __ss.boot.libMs; } catch (e) { return -2; } })(),
|
||
bootCmds: (function () { try { var c = __ss.boot.cmds, u = [], i; for (i = 0; i < c.length; i++) if (u.indexOf(c[i]) < 0) u.push(c[i]); return u.join(' '); } catch (e) { return ''; } })(),
|
||
navMs: (function () { try { var n = performance.getEntriesByType('navigation')[0]; return n ? Math.round(n.domContentLoadedEventEnd) : -1; } catch (e) { return -1; } })(),
|
||
/* ── the weather: what the screen says when a read fails, an answer
|
||
is empty, or nobody is signed in. Every one of these is a line
|
||
this round put there; before it they were an empty box, a spinner
|
||
with no end, or a Rust string in the red toast. */
|
||
slskOn: (function () { try { return __ss.slsk.connection; } catch (e) { return ''; } })(),
|
||
signin: !!document.querySelector('.signin'),
|
||
signinErr: (document.querySelector('.signin-err') || {}).textContent || '',
|
||
why: (document.querySelector('.stub.why') || {}).textContent || '',
|
||
/* the toast's one action, by its words: "Try again" or "Sign in" */
|
||
retry: (document.querySelector('.toast:not(.notice) .na') || {}).textContent || '',
|
||
again: !![].slice.call(document.querySelectorAll('.imp-foot .btn')).find(function (b) { return /Try again/.test(b.textContent); }),
|
||
roomsNote: (document.querySelector('.rooms-note') || {}).textContent || '',
|
||
offlineNote: (document.querySelector('.offline-note') || {}).textContent || '',
|
||
netAsk: !!document.querySelector('.net-ask'),
|
||
folderGone: (document.querySelector('.folder-gone') || {}).textContent || '',
|
||
lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '',
|
||
lyrTools: !!document.querySelector('.lyr-tools'),
|
||
trks: document.querySelectorAll('.screen .trk').length,
|
||
/* the phone's trouble-words step leaves its eleven sentences here */
|
||
trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(),
|
||
syStrip: document.querySelectorAll('.sy-strip .sy-col').length,
|
||
syTicks: document.querySelectorAll('.sy-strip .sy-tick').length,
|
||
syEst: (document.querySelector('.sy-est') || {}).textContent || '',
|
||
syHint: (document.querySelector('.sy-hint') || {}).textContent || '',
|
||
disco: document.querySelectorAll('.grid2 .release').length,
|
||
/* the artist page's three lanes at once — the page, the hero seed and
|
||
the overview — so a step can say what painted and what had not
|
||
landed yet: "<page>:<seed>[+art]:<overview>:<releases>" */
|
||
artFast: (function () {
|
||
try {
|
||
var t = __ss.top();
|
||
if (!t || t.n !== 'artist') return '';
|
||
var a = __ss.catalog.artists[String(t.c.name || '').trim().toLowerCase()];
|
||
if (!a) return 'no-entry';
|
||
var h = __ss.catalog.heroSeeds[a.id] || {}, o = __ss.catalog.overviews[a.id] || {};
|
||
return a.status + ':' + (h.status || '-') + (h.banner ? '+art' : '') + ':' + (o.status || '-') + ':' + ((o.albums || []).length);
|
||
} catch (e) { return ''; }
|
||
})(),
|
||
/* the number this round exists to hold at zero, and who broke it */
|
||
a11yAnon: a11yNone.length,
|
||
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
|
||
a11yBad: a11yAll.map(function (el) { var nm = aname(el); if (!nm) return ''; var j = junk(el, nm); return j ? j + ':' + whatis(el) + ':' + nm.slice(0, 22) : ''; }).filter(Boolean).slice(0, 3).join(' | '),
|
||
/* a layer that is off-screen but still in the tree: the closed
|
||
sheets, the hidden mini bar, the nav under a sheet */
|
||
a11yGhost: (function () {
|
||
var n = 0;
|
||
[].forEach.call(document.querySelectorAll('.sheet:not(.up), .halfsheet:not(.up), .mini.hidden, .nav.gone'), function (h) {
|
||
if (h.matches(A11Y_SEL) && exposed(h)) n++;
|
||
n += [].filter.call(h.querySelectorAll(A11Y_SEL), exposed).length;
|
||
});
|
||
return n;
|
||
})(),
|
||
/* a control that LOOKS chosen and does not say so */
|
||
a11yState: (function () {
|
||
var bad = [];
|
||
[].forEach.call(document.querySelectorAll('.row .sw'), function (sw) {
|
||
var b = sw.closest('button, [role=button]');
|
||
if (!b || !exposed(b)) return;
|
||
var want = sw.classList.contains('on') ? 'true' : 'false';
|
||
if (b.getAttribute('role') !== 'switch' || b.getAttribute('aria-checked') !== want) bad.push('sw:' + aname(b).slice(0, 18));
|
||
});
|
||
[].forEach.call(document.querySelectorAll('.segtabs button, .np-faces .fbtn, .nav button'), function (b) {
|
||
if (!exposed(b)) return;
|
||
var want = b.classList.contains('on') ? 'true' : 'false';
|
||
if (b.getAttribute('role') !== 'tab' || b.getAttribute('aria-selected') !== want) bad.push('tab:' + aname(b).slice(0, 18));
|
||
});
|
||
[].forEach.call(document.querySelectorAll('button.chip'), function (b) {
|
||
if (!exposed(b) || b.classList.contains('tchip')) return;
|
||
if (!b.hasAttribute('aria-pressed')) { if (b.classList.contains('on')) bad.push('chip:' + aname(b).slice(0, 18)); return; }
|
||
if (b.getAttribute('aria-pressed') !== (b.classList.contains('on') ? 'true' : 'false')) bad.push('chip:' + aname(b).slice(0, 18));
|
||
});
|
||
return bad.slice(0, 3).join(' | ');
|
||
})(),
|
||
/* the layer on top: its role, whether it is modal, its name, and
|
||
whether the focus went with it */
|
||
dlg: (function () {
|
||
var d = document.querySelector('.viewer') || document.querySelector('.syncroom')
|
||
|| document.querySelector('.halfsheet.up') || document.querySelector('.sheet.up');
|
||
if (!d) return '';
|
||
var nm = (d.getAttribute('aria-label') || '').trim();
|
||
return (d.getAttribute('role') || '-') + '/' + (d.getAttribute('aria-modal') || '-') + '/' + nm.slice(0, 34) + '/' + (d.contains(document.activeElement) ? 'focus' : 'nofocus');
|
||
})(),
|
||
navTabs: [].map.call(document.querySelectorAll('.nav button'), function (b) { return aname(b) + (b.getAttribute('aria-selected') === 'true' ? '*' : ''); }).join('|'),
|
||
announce: ((document.querySelector('[data-live]') || {}).textContent || '').trim(),
|
||
vp: innerWidth + 'x' + innerHeight,
|
||
/* the sheet's controls: any one of them off the viewport is the landscape bug */
|
||
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
|
||
/* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */
|
||
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
|
||
faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height),
|
||
/* WHICH face: the queue's own, up whether or not anything is after this one */
|
||
qFace: !!document.querySelector('.sheet.up .q-face'),
|
||
/* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */
|
||
halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height),
|
||
halfIn: inside(box('.halfsheet.up')),
|
||
hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(),
|
||
hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(),
|
||
heroH: Math.round((box('.screen .hero') || { height: 0 }).height),
|
||
/* what a page offers first: Play on an artist hero, Play on a record head */
|
||
actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')),
|
||
recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(),
|
||
/* the glass bar above plus the floating bars below, as a share of the screen */
|
||
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
|
||
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
|
||
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
|
||
};
|
||
})()`;
|
||
|
||
/* ── the walks ─────────────────────────────────────────────────────────── */
|
||
const MOCK_STEPS = [
|
||
{ name: "home", wait: 1600 },
|
||
/* a long press on a card is its sheet */
|
||
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Re-fetch the cover')`, name: "cover-refetched", wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 },
|
||
/* a misspelt library name: the catalog has nothing, the library's own names correct it */
|
||
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 },
|
||
{ do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
|
||
/* By format: the hi-res lossless records come first */
|
||
{ do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 500 },
|
||
{ do: `__click('.chips .chip', 'Recently added')`, wait: 400 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 },
|
||
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "album", wait: 900 },
|
||
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 900 },
|
||
{ do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
|
||
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 },
|
||
/* the art opens the viewer; the back gesture closes it */
|
||
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 900 },
|
||
{ do: `history.back()`, name: "viewer-closed", wait: 700 },
|
||
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
|
||
/* a queue row's sheet: the row removed */
|
||
{ do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 },
|
||
/* the second upcoming row moved up */
|
||
{ do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", wait: 600 },
|
||
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
|
||
/* the lyrics tools: nudged half a second, another candidate chosen */
|
||
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Lyrics later')`, name: "lyrics-nudged", wait: 500 },
|
||
{ do: `__click('.halfsheet .row', 'Search again')`, name: "lyrics-candidates", wait: 900 },
|
||
{ do: `__click('.halfsheet .row', 'Live')`, name: "lyrics-chosen", wait: 700 },
|
||
/* pasted words, timestamped: synced lines */
|
||
{ do: `__click('.lyr-tools', null)`, wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Paste lyrics')`, wait: 600 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "lyrics-pasted", wait: 800 },
|
||
/* auto-sync: the app's own correction turned off for this track, then
|
||
back on — the re-read leaves the sheet up so the switch is seen */
|
||
{ do: `__click('.lyr-tools', null)`, wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 },
|
||
/* off again, and left off: a save returns the document with that flag at
|
||
its default, and the switch must not take it */
|
||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 },
|
||
/* the tap-to-sync room: taps become the timing, one undone, the room
|
||
peeled by the back gesture and the set offered back, then saved */
|
||
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
|
||
/* the envelope is measured on open; the estimate lays every line across
|
||
the sung stretches, and dropping it puts the room back to the tap */
|
||
{ do: `__click('.sy-est', null)`, name: "sync-estimated", wait: 700 },
|
||
{ do: `__click('.sy-est', null)`, name: "sync-cleared", wait: 600 },
|
||
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
||
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
|
||
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
|
||
{ do: `history.back()`, wait: 600 },
|
||
{ do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 },
|
||
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
||
{ do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 },
|
||
{ do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 },
|
||
/* the vocal-reduced copy, and back to the recording */
|
||
{ do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 },
|
||
{ do: `__click('.lyr-tools', null)`, wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 },
|
||
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 },
|
||
{ do: `history.back()`, name: "after-back", wait: 900 },
|
||
{ do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 },
|
||
{ do: `history.back()`, wait: 700 },
|
||
{ do: `history.back()`, name: "library-again", wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 },
|
||
/* a buddy's sheet: browse, message, remove */
|
||
{ do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 },
|
||
{ do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 },
|
||
/* the contact editor behind the peer sheet: a petname, a note, the
|
||
favourite mark — then the buddy list wearing all three, ada's own
|
||
name still under it and favourites first */
|
||
{ do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Petname')`, wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Note')`, wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 },
|
||
{ do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
/* a user looked up by name: the peer sheet */
|
||
{ do: `__click('.screen .row', 'Look up a user')`, wait: 600 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'dusty_crates'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Open')`, name: "user-lookup", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
/* your own interests: what is declared, a like added and the server's
|
||
match re-asked, a hate added, the like taken back, a match's sheet */
|
||
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 },
|
||
{ do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 },
|
||
{ do: `__click('.tastechips.likes .tadd', null)`, wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 },
|
||
{ do: `__click('.tastechips.hates .tadd', null)`, wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 },
|
||
{ do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 },
|
||
{ do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `__click('.segtabs button', 'Rooms')`, wait: 500 },
|
||
/* the room list: filtered by name, the rest on demand */
|
||
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = 'vin'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "rooms-filtered", wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||
{ do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 },
|
||
/* the fake network: a room with a few lines in it */
|
||
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
|
||
/* the room's roster wears the petname too, the real name beneath it */
|
||
{ do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `history.back()`, wait: 700 },
|
||
/* nothing set is nothing stored: cleared, the entry leaves the list and
|
||
a name goes back to being a name */
|
||
{ do: `__click('.segtabs button', 'Buddies')`, wait: 600 },
|
||
{ do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 },
|
||
{ do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 },
|
||
{ do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 },
|
||
/* a search answered by five peers; the best one's first file streams,
|
||
the lane rising as the bytes arrive */
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 },
|
||
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 },
|
||
/* the hero's artist: the desktop's page — tabs, Popular's bars, Follow,
|
||
the latest release, fans also like — then a fan-also-likes page */
|
||
{ do: `document.querySelector('.cat-song').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-song-sheet", wait: 600 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
/* a Records-rail card's long press: the record's sheet */
|
||
{ do: `(function () { var sec = [].slice.call(document.querySelectorAll('.sec')).find(function (x) { return /^\\s*Records/.test(x.textContent); }); var rail = sec && sec.nextElementSibling; rail.querySelector('.release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, name: "cat-record-sheet", wait: 600 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 },
|
||
{ do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 },
|
||
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 },
|
||
/* the artist's photo: the page's ⋯ → a picture from this phone becomes
|
||
the hero, the page is asked again, then the catalog's own come back */
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 },
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 },
|
||
/* an artist whose exhaustive page the mock keeps for four seconds: what
|
||
is on screen a second and a half in can only be the two fast lanes */
|
||
{ do: `__ss.push('artist', { name: 'Halcyon Drift' })`, name: "artist-lanes", wait: 1200 },
|
||
{ name: "artist-lanes-page", wait: 4200 },
|
||
{ do: `history.back()`, wait: 700 },
|
||
{ do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
|
||
{ do: `history.back()`, wait: 600 },
|
||
{ do: `history.back()`, wait: 800 },
|
||
{ do: `document.querySelector('.cat-song').click()`, name: "net-playing", wait: 5500 },
|
||
{ do: `document.querySelector('.mini').click()`, name: "net-sheet", wait: 3000 },
|
||
{ name: "net-arrived", wait: 7000 },
|
||
/* the now-playing sheet's ⋯: the streamed track saved into the library
|
||
(its cache is complete by now, so it files at once) */
|
||
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, name: "np-more", wait: 700 },
|
||
/* the same cache, copied out as a plain file into a picked folder —
|
||
nothing downloaded again, no library row */
|
||
{ do: `__click('.halfsheet .row', 'Copy to a folder')`, name: "np-copied", wait: 900 },
|
||
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
|
||
/* the raw peers: a peer's sheet → their shares browsed → a folder opened
|
||
→ one of its files streams */
|
||
{ do: `__settle()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
|
||
{ do: `__click('.net-ask', null)`, name: "net-peers", wait: 1800 },
|
||
/* the hub's filters: FLAC only, then a free slot only */
|
||
{ do: `__click('.netfilters .chip', 'FLAC')`, name: "net-filter-flac", wait: 500 },
|
||
{ do: `__click('.netfilters .chip', 'Free slot')`, name: "net-filter-free", wait: 500 },
|
||
{ do: `__click('.netfilters .chip', 'Free slot')`, wait: 300 },
|
||
{ do: `__click('.netfilters .chip', 'Any format')`, wait: 400 },
|
||
{ do: `document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "peer-file-sheet", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `document.querySelector('.peer-head .peer-menu').click()`, name: "peer-sheet", wait: 700 },
|
||
/* the stranger in full: the card their client serves, their picture in
|
||
the viewer, what they declared, and the comparison of their share
|
||
against this library — a record they have and it doesn't, found */
|
||
{ do: `__click('.halfsheet .row', 'About this user')`, name: "peer-screen", wait: 1400 },
|
||
{ do: `document.querySelector('.pr-pic').click()`, name: "peer-picture", wait: 900 },
|
||
{ do: `history.back()`, wait: 700 },
|
||
{ do: `__click('.screen .btn', 'Compare with my library')`, name: "peer-compare", wait: 2200 },
|
||
{ do: `__click('.pr-gap', 'Geogaddi')`, name: "peer-gap-open", wait: 1300 },
|
||
{ do: `history.back()`, name: "peer-again", wait: 1200 },
|
||
{ do: `__click('.screen .btn', 'Forget this comparison')`, name: "peer-forgotten", wait: 1100 },
|
||
{ do: `document.querySelector('.pr-head .iconbtn').click()`, name: "peer-sheet-again", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Browse their files')`, name: "browse", wait: 1400 },
|
||
{ do: `__click('.brw-dir .peer-folder', 'Homework')`, name: "browse-dir", wait: 600 },
|
||
{ do: `__click('.brw-dir .peer-file', 'Aerodynamic')`, name: "browse-play", wait: 2500 },
|
||
{ do: `history.back()`, wait: 700 },
|
||
/* a catalog record: a row plays through the resolver with the rest of
|
||
the record queued behind it, the next track finds its own copy on
|
||
its turn, and the disc plays the whole record from one folder */
|
||
{ do: `__settle()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 },
|
||
{ do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 },
|
||
/* the two guest rows the record's own anchor had no count for */
|
||
{ name: "cat-album-listeners", wait: 900 },
|
||
/* the record page's own sheet from the bar */
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 },
|
||
{ do: `__ss.next()`, name: "cat-album-next", wait: 7000 },
|
||
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "cat-album-disc", wait: 6500 },
|
||
/* Save: the best folder by the download rules, every file fetched */
|
||
/* one track: the long press's sheet, Save to library */
|
||
{ do: `document.querySelectorAll('.tracks .trk')[3].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-track-sheet", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "cat-track-save", wait: 3500 },
|
||
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); })()`, wait: 500 },
|
||
{ do: `document.querySelector('.rec-acts .save').click()`, name: "cat-album-save", wait: 4500 },
|
||
/* the burst on the Transfers screen, reached from You */
|
||
{ do: `__settle()`, wait: 700 },
|
||
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
|
||
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 },
|
||
/* the shade's copy of this screen, driven from the store itself: two
|
||
rows written straight in — nothing fetched — so the notice is posted
|
||
with a count and the file it is on, and Cancel all is on the head;
|
||
then the rows are taken away again and the notice must go with them */
|
||
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 1300 },
|
||
/* 2600 ms on purpose: the album burst drains 3.4-5.3 s after it starts
|
||
and this lands ten seconds after the Save, so "nothing in flight" is
|
||
settled state rather than a race against a mock still finishing */
|
||
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2600 },
|
||
{ do: `history.back()`, wait: 600 },
|
||
/* Gaps: the followed shelf, its missing records dimmed */
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
||
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
|
||
{ do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 },
|
||
/* Playlists: the shelf (pinned first), a page holding every row kind,
|
||
a local row playing, the stream row through the resolver, the
|
||
filter, a removal and its undo, the page's sheet — unpin, rename —
|
||
a new playlist from the shelf, a record's tracks added to it from
|
||
the record's sheet, and its deletion */
|
||
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
|
||
{ do: `__click('.plgrid .release', 'Late night')`, name: "playlist", wait: 1200 },
|
||
{ do: `document.querySelectorAll('.pl-rows .trk')[1].click()`, name: "playlist-playing", wait: 1200 },
|
||
{ do: `__click('.pl-rows .trk', 'One More Time')`, name: "playlist-stream", wait: 5000 },
|
||
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
|
||
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 },
|
||
/* reordered from the sheet: the second row up, then back down */
|
||
{ do: `document.querySelectorAll('.pl-rows .trk')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Move up')`, name: "playlist-moved-up", wait: 900 },
|
||
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
|
||
{ do: `__click('.halfsheet .row', 'Move down')`, name: "playlist-moved-down", wait: 900 },
|
||
/* 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 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
||
{ do: `__settle()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
|
||
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 500 },
|
||
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 },
|
||
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 },
|
||
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 },
|
||
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 week')`, name: "you-keep", wait: 500 },
|
||
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 month')`, wait: 400 },
|
||
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 500 },
|
||
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, wait: 400 },
|
||
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 500 },
|
||
{ do: `__click('.screen .row', 'Word-by-word')`, wait: 400 },
|
||
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 },
|
||
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
|
||
/* the desktop's portrait refresh, beside the covers' */
|
||
{ do: `__click('.screen .row', 'Refresh artist portraits')`, name: "you-portraits", wait: 900 },
|
||
/* downloads on Wi-Fi only: the switch on, the connection made cellular, a download refused; then off */
|
||
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-on", wait: 400 },
|
||
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 },
|
||
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
|
||
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-off", wait: 400 },
|
||
/* the listening port: typed in, saved, read back off the config — then
|
||
a port below 1024 refused and the saved one left standing */
|
||
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 600 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50555'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 700 },
|
||
{ do: `__click('.screen .row', 'Listening port')`, wait: 500 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 600 },
|
||
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
|
||
/* the dev flavour's diagnostics — the desktop's Ctrl+Shift+D overlay as
|
||
a screen; a normal build has no row here to tap */
|
||
{ do: `__click('.screen .row', 'Diagnostics')`, name: "diag", wait: 1500 },
|
||
{ do: `history.back()`, name: "diag-back", wait: 800 },
|
||
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
|
||
/* recent searches on the empty page: the ones this walk used */
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 500 },
|
||
/* the tab again pops its stack to the root */
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
|
||
{ do: `__click('.sfield .iconbtn', null)`, name: "search-recent", wait: 600 },
|
||
{ do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1400 },
|
||
{ do: `__settle()`, wait: 600 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
|
||
/* the page overflow → the sleep timer */
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 },
|
||
{ do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 },
|
||
/* ── the phone turned over. A resize, so the app keeps playing and keeps
|
||
its stack; what changes is the layout, and every one of these asserts
|
||
something that was clipped or unreachable at 915x411 before. ────── */
|
||
{ view: [915, 411], name: "landscape", wait: 900 },
|
||
{ do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 },
|
||
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 },
|
||
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 },
|
||
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
|
||
{ do: `__ss.closeSheet()`, wait: 800 },
|
||
/* a record sideways: the cover beside the title, Play in view without a
|
||
scroll, and a long sheet over it — the track's, 473px of rows and
|
||
facts — capped taller than portrait's 76% and still scrolling to its
|
||
last row. (Home's own overflow sheet is 212px tall at either cap and
|
||
would have proved nothing.) */
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
||
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 },
|
||
{ do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||
/* and the artist: the hero a banner rather than 300px of a 411px screen,
|
||
so it stops covering its own Play button with the mini bar */
|
||
{ do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 },
|
||
/* the side insets, faked: sideways the cutout and the gesture pill come
|
||
in from the edges, and env() cannot be emulated here — the plumbing can */
|
||
{ do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 },
|
||
{ do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 },
|
||
/* and back, which is also the override reset: portrait must be exactly
|
||
what it was before any of this */
|
||
{ view: [411, 914], wait: 800 },
|
||
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
|
||
{ do: `__ss.closeSheet()`, wait: 600 },
|
||
/* ── the weather ────────────────────────────────────────────────────
|
||
Everything above this line is the happy path; nothing in thirty
|
||
rounds of walk had ever made a command reject or taken the client
|
||
offline, so none of these lines had ever been drawn. The mock's own
|
||
doors (__ssMock.fail / answer / offline / clear) are the weather, and
|
||
every assertion below is on SETTLED state — an armed failure stays
|
||
armed, a sign-in card stays up, the folder-gone row stays — because
|
||
the probe lands after the screenshot, well past the step's own wait.
|
||
The one timed assertion is the room list, and its timeout is shortened
|
||
to 1.2 s against the 3.1 s of waits between arming it and reading it.
|
||
The last step puts the weather away and
|
||
leaves the app exactly as it found it — anything appended after this
|
||
block inherits a signed-in app on Home.
|
||
|
||
The first step settles FIRST, and it has to: the landscape block above
|
||
leaves the library tab three screens deep (record → artist), so one tap
|
||
on the Library tab lands on the artist page and `.segtabs button` would
|
||
be its discography tabs, not Albums/Artists/Tracks. It also wipes any
|
||
error the happy path left standing, so `!i.toast` below means this
|
||
block's toast and no other. */
|
||
{ do: `(function () { __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 },
|
||
{ do: `__ssMock.fail('library_track_index', 'unable to open database file')`, wait: 300 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "weather-tracks", wait: 1300 },
|
||
{ do: `__ssMock.fail('library_track_index', '')`, wait: 200 },
|
||
{ do: `__click('.imp-foot .btn', 'Try again')`, name: "weather-tracks-ok", wait: 1300 },
|
||
/* signed out, in a lift: the card is back and nothing says why in Rust */
|
||
{ do: `__ssMock.offline(true)`, wait: 600 },
|
||
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "weather-signedout", wait: 1100 },
|
||
/* the single most visible raw-Rust site on the phone, twice over */
|
||
{ do: `__ssMock.fail('connect_and_login', 'login failed: INVALIDPASS')`, wait: 200 },
|
||
{ do: `(function () { var p = document.querySelectorAll('.signin .field')[1]; p.value = 'hunter2'; p.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, wait: 400 },
|
||
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-badpass", wait: 1200 },
|
||
{ do: `__ssMock.fail('connect_and_login', 'could not reach server: io: failed to lookup address information: no address associated with hostname')`, wait: 200 },
|
||
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-noserver", wait: 1200 },
|
||
/* Search signed out with a query typed showed NOTHING where the network
|
||
section lives — no row, no reason. Now it says so and offers the door */
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, name: "weather-search-out", wait: 1800 },
|
||
/* the room list is fire-and-forget over the wire (the client sends it and
|
||
nothing answers if the server will not), so the wait is ours: an armed
|
||
answer that resolves and emits nothing is exactly that server. Community
|
||
is left on Buddies by the walk above, so the Rooms tab is asked for by
|
||
name — the note lives under that one. */
|
||
{ do: `__ssMock.fail('connect_and_login', ''); __ssMock.offline(false)`, wait: 1200 },
|
||
{ do: `(function () { __ss.slsk.roomsWaitMs = 1200; __ss.slsk.rooms = []; __ssMock.answer('slsk_room_list', null); __ss.refreshRooms(); return true; })()`, wait: 200 },
|
||
{ do: `document.querySelectorAll('.nav button')[3].click()`, wait: 900 },
|
||
{ do: `__click('.segtabs button', 'Rooms')`, name: "weather-rooms-late", wait: 2200 },
|
||
/* one tap on Rescan library used to empty the whole index and say Ok */
|
||
{ do: `__ssMock.fail('rescan_library', 'library-folder-missing: /storage/1A2B-3C4D/Music')`, wait: 200 },
|
||
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
|
||
{ do: `__click('.screen .row', 'Rescan library')`, name: "weather-rescan", wait: 1500 },
|
||
/* and the weather clears */
|
||
{ do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 },
|
||
{ do: `__settle()`, wait: 900 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 },
|
||
/* ── the first run, on a device that has just been installed ──────
|
||
The mock is the whole backend here, so it can answer as one: no
|
||
records, no playlists, no account, no all-files permission, and the
|
||
library root still the sandbox folder Android hands a fresh
|
||
install. The flag rides in the URL fragment — a reload cannot lose
|
||
that the way a file:// document's storage might — and in
|
||
localStorage beside it, for a page opened by hand.
|
||
|
||
This block is LAST on purpose. The reload wipes every module's
|
||
state (the mock's playlists, its contacts, its one fake transfer),
|
||
so nothing the two hundred steps above built survives into it, and
|
||
it puts the walk back on a provisioned device at the end so that
|
||
nothing appended after it is quietly running against an empty one.
|
||
|
||
A reload is not a reinstall: the webview's own storage comes back
|
||
with it, so the session the walk left behind is cleared by hand —
|
||
a now-playing bar on a phone with nothing in it is the one piece
|
||
of the pretence that would show in the stills. Removing the key is
|
||
not enough on its own, and a `pagehide` listener is not either:
|
||
the player writes the session on pagehide, on visibilitychange AND
|
||
on a ten-second timer while a track plays (player.svelte.js), and a
|
||
CDP-driven reload is not obliged to fire pagehide at all — so the
|
||
first attempt at this step came back with the bar still up. The
|
||
clearing therefore shuts the door instead of chasing the writers:
|
||
setItem is taught to refuse that one key for the few hundred
|
||
milliseconds this page has left, so nothing can put it back. */
|
||
{ do: `(function () { var K = 'ss.mobile.session.v1'; var set = Storage.prototype.setItem; Storage.prototype.setItem = function (k) { if (k === K) return; return set.apply(this, arguments); }; try { localStorage.removeItem(K); } catch (e) {} localStorage.setItem('mockFresh', '1'); location.hash = 'fresh'; })(); true`, wait: 400 },
|
||
{ do: `location.reload()`, name: "fresh-boot", wait: 4500 },
|
||
/* the tour's Soulseek step navigates to the card and stops there —
|
||
nothing in the walk types a username or a password */
|
||
{ do: `__click('.halfsheet .row', 'Sign in on Community')`, name: "fresh-community", wait: 1400 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-home", wait: 1000 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "fresh-albums", wait: 900 },
|
||
/* the Artists tab had no empty state at all before this round */
|
||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "fresh-artists", wait: 900 },
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "fresh-search", wait: 900 },
|
||
/* the preview door: it reads the state and writes nothing */
|
||
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
|
||
{ do: `__click('.screen .row', 'Set up this phone again')`, name: "fresh-preview", wait: 1100 },
|
||
{ do: `__click('.halfsheet .row', 'Choose a folder')`, name: "fresh-folder", wait: 2000 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
|
||
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
|
||
{ do: `location.reload()`, name: "fresh-off", wait: 4500 },
|
||
];
|
||
const MOCK_EXPECT = {
|
||
tracks: (i) => i.title.startsWith("Your library"),
|
||
community: (i) => i.title.startsWith("Community"),
|
||
buddies: (i) => i.title.startsWith("Community"),
|
||
"buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText) && /Name and notes/.test(i.halfText),
|
||
"user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
|
||
/* the local contact metadata: the editor, the petname reaching the
|
||
backend, the mark sticking, the buddy list rearranged — and the
|
||
cleared entry dropped rather than stored empty */
|
||
"contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere",
|
||
"contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "),
|
||
"contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText),
|
||
"contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite",
|
||
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
|
||
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
|
||
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
|
||
taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4 && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
|
||
"taste-declared": (i) => /Boards of Canada/.test(i.likes) && i.similar === 5,
|
||
"taste-hated": (i) => i.hates === "loudness war|loudness war 2",
|
||
"taste-dropped": (i) => !/Boards of Canada/.test(i.likes) && i.similar === 4 && !i.toast,
|
||
"taste-peer": (i) => i.half.includes("up") && /hifi_hank/.test(i.halfText),
|
||
"home-more": (i) => i.half.includes("up"),
|
||
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit" && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||
"you-norm-off": (i) => i.norm === "off" && i.a11yState === "",
|
||
"you-norm-on": (i) => i.norm === "on",
|
||
"you-lossy": (i) => i.srcMax === "Lossy only",
|
||
"you-keep": (i) => i.keep === "1 week",
|
||
"you-slots": (i) => i.slots === "3",
|
||
"you-words": (i) => i.wordfill === "on",
|
||
"you-artedge": (i) => i.artEdge === "1000 px",
|
||
"you-portraits": (i) => /portraits refreshed/.test(i.notice),
|
||
"wifi-on": (i) => i.wifiOnly === "on",
|
||
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
|
||
"wifi-off": (i) => i.wifiOnly === "off" && !i.toast,
|
||
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port),
|
||
"you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||
"you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port),
|
||
"diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1,
|
||
"diag-back": (i) => i.title.startsWith("You"),
|
||
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
|
||
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
|
||
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1,
|
||
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
|
||
"sleep-sheet": (i) => i.half.includes("up"),
|
||
"sleep-set": (i) => !i.half.includes("up"),
|
||
/* ── the weather ────────────────────────────────────────────────────
|
||
Each of these fails if the round's fix is undone: the stub if a failed
|
||
read goes back to being drawn as an empty library, the sign-in lines if
|
||
the toast or the card stops translating, the search note if the block
|
||
goes back to rendering nothing at all signed out, the room note if the
|
||
spinner loses its end, the folder row if the rescan stops refusing.
|
||
Every one also asserts the RAW string is gone — that is the round. */
|
||
"weather-tracks": (i) => /^Couldn't read the library index/.test(i.why) && /library folder isn't readable/.test(i.why)
|
||
&& i.again && i.trks === 0 && !/database file/.test(i.why + i.toast),
|
||
"weather-tracks-ok": (i) => !i.why && !i.again && i.trks > 0 && !i.toast,
|
||
"weather-signedout": (i) => i.slskOn === "offline" && i.signin && !i.signinErr && !i.toast,
|
||
"weather-badpass": (i) => i.signinErr === "Soulseek refused that password." && !/INVALIDPASS/.test(i.signinErr),
|
||
"weather-noserver": (i) => /^Couldn't reach the Soulseek server/.test(i.signinErr) && !/io:|address information/.test(i.signinErr),
|
||
"weather-search-out": (i) => i.title.startsWith("Search") && /Community/.test(i.offlineNote) && !i.netAsk,
|
||
"weather-rooms-late": (i) => i.slskOn === "online" && /hasn't sent its room list/.test(i.roomsNote) && i.again,
|
||
"weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast)
|
||
&& !/library-folder-missing/.test(i.toast + i.folderGone),
|
||
"weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title),
|
||
/* sideways: the chrome budget, then every surface that was clipped. The
|
||
before-value of each is in the rig's now.json — 46% of the height as
|
||
chrome, a 879px cover with the transport 687px below the fold, a
|
||
lyrics face collapsed to nothing, a 300px hero, and the record's own
|
||
Play button off the bottom of a 411px screen. */
|
||
landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title),
|
||
"landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0,
|
||
"landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0,
|
||
/* the queue face by name, not by its rows: the track this walk is on is
|
||
the LAST queueable row of Late night drive (the missing and the
|
||
unresolved ones never reach the queue), so there is nothing up next
|
||
and `qrows` is 0 here — the assertion is that the queue's own face has
|
||
the cover's column, not that something is after this song */
|
||
"landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0,
|
||
"landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn,
|
||
"landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0,
|
||
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
|
||
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
|
||
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
|
||
home: (i) => /^(Good |Still)/.test(i.title) && /^Home\*\|Search\|Library\|Community(, \d+ unread)?\|You(, \d+ transfers?)?$/.test(i.navTabs) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState
|
||
/* and the boot budget, read off __ss.boot. Each clause earns its
|
||
place: the census must have counted something (a census that
|
||
breaks and reports -1 or -2 would sail through a one-sided
|
||
test); it must not have counted the world; the boot chain must
|
||
still be going out in one round rather than waiting on itself,
|
||
which is the one clause that is not a clock at all — it was 10
|
||
before round 32 and is 23 after, so a chain put back in a queue
|
||
lands under the floor; the shell must have sealed on a frame
|
||
rather than on the 1200 ms fallback; the library must still land
|
||
inside the launch rather than behind something slow; and round
|
||
12's eight-second art prewarm must still be nowhere near boot.
|
||
The call ceiling is deliberately loose — the boot chain issues
|
||
about 25 commands and the first screen asks for its covers on
|
||
top, and the point there is to catch a round that pulls a
|
||
per-artist walk or an index to boot, not to police a handful of
|
||
calls. */
|
||
&& i.bootCalls >= 14 && i.bootCalls <= 60
|
||
&& i.bootSync >= 20
|
||
&& i.bootMs >= 0 && i.bootMs < 1200
|
||
&& i.bootLibMs >= 0 && i.bootLibMs < 600
|
||
&& !/library_art_prewarm/.test(i.bootCmds),
|
||
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
|
||
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
|
||
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
|
||
"search-dym": (i) => i.dym === "shpongle",
|
||
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
|
||
search: (i) => i.title.startsWith("Search"),
|
||
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline) && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||
album: (i) => i.screen.includes("tr-push") && !i.mini && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
|
||
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
|
||
"lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle),
|
||
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
|
||
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
|
||
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
|
||
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
||
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
|
||
"sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow && i.syStrip === 64 && /Estimate the timing/.test(i.syEst),
|
||
"sync-estimated": (i) => i.sync && i.syCount === "3 / 3" && i.syTicks === 3 && /Estimated/.test(i.syHint) && /Clear the estimate/.test(i.syEst),
|
||
"sync-cleared": (i) => i.sync && i.syCount === "0 / 3" && i.syTicks === 0 && /Estimate the timing/.test(i.syEst),
|
||
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
|
||
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
|
||
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
|
||
"sync-resumed": (i) => i.sync && i.syCount === "1 / 3",
|
||
"sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow),
|
||
"sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast,
|
||
/* the save's own payload carries no auto-sync flag: the switch kept its */
|
||
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
||
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
||
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
||
viewer: (i) => i.viewer && /^button\/-\/Close artwork viewer/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost,
|
||
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
|
||
"album-playing": (i) => i.mini,
|
||
"track-sheet": (i) => i.half.includes("up") && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
|
||
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
|
||
"after-back": (i) => !i.sheet.includes("up"),
|
||
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played) && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState,
|
||
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
|
||
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
|
||
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
|
||
"queue-moved-up": (i) => !i.half.includes("up") && !!i.qfirst && i.qrows === globalThis.__qrows - 1,
|
||
"library-again": (i) => i.screen.includes("tr-pop"),
|
||
/* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming
|
||
record leads), the news, the back catalogue, recommendations,
|
||
complete the set, following — the desktop's page */
|
||
"home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs),
|
||
room: (i) => i.screen.includes("tr-push") && i.msgs >= 2 && !i.a11yAnon && !i.a11yBad,
|
||
"rooms-filtered": (i) => i.rooms === 1,
|
||
"rooms-more": (i) => i.rooms >= 95,
|
||
/* the artist with a picture shows it on the hero and its card; the
|
||
namesake under Deezer's empty-hash placeholder shows the app's own */
|
||
"cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1,
|
||
"cat-song-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to record/.test(i.halfText),
|
||
"cat-record-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Go to record/.test(i.halfText) && /Go to artist/.test(i.halfText),
|
||
"cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2,
|
||
"cat-artist-follow": (i) => i.fb.includes("Following"),
|
||
"cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,
|
||
"cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4,
|
||
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText),
|
||
"artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice),
|
||
"artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix,
|
||
"artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),
|
||
/* the exhaustive page has NOT landed; the banner and the first four
|
||
releases have — three albums on the Albums tab, a single on its own */
|
||
"artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2,
|
||
/* and the page wins when it does land: the classified six */
|
||
"artist-lanes-page": (i) => /^ready:/.test(i.artFast) && i.heroPix && i.disco === 6 && i.tabs === 2,
|
||
"net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live),
|
||
"net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
|
||
"net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"),
|
||
"np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
|
||
"np-copied": (i) => !i.half.includes("up") && /1 file copied/.test(i.notice),
|
||
"np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
|
||
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
|
||
"net-filter-flac": (i) => i.peers === 3,
|
||
"net-filter-free": (i) => i.peers === 2,
|
||
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
|
||
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),
|
||
"peer-screen": (i) => i.title.startsWith("hifi_hank") && /Ripping my own vinyl/.test(i.peerDesc) && i.peerPic && i.peerTags === 6 && /slot is free/.test(i.peerLine) && /served/.test(i.peerLine) && i.peerGaps === 0,
|
||
"peer-picture": (i) => i.viewer,
|
||
"peer-compare": (i) => i.peerGaps === 6 && /in your library/.test(i.peerSum),
|
||
"peer-gap-open": (i) => i.title.startsWith("Search") && i.sq === "Boards of Canada Geogaddi",
|
||
"peer-again": (i) => i.title.startsWith("hifi_hank") && i.peerGaps === 6,
|
||
"peer-forgotten": (i) => i.peerGaps === 0 && !i.peerSum && /forgotten/i.test(i.notice),
|
||
"peer-sheet-again": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && !/About this user/.test(i.halfText),
|
||
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
|
||
"browse-dir": (i) => i.brwFiles >= 8,
|
||
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
|
||
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10 && !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||
"cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14,
|
||
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),
|
||
"cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
|
||
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
|
||
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs),
|
||
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
|
||
/* xfnMax >= 5 can only have come from the real album burst, and it is
|
||
monotonic, so it is not a race; the title and the line come from the
|
||
two injected rows (990001 is the newest transferring one, so it is
|
||
deterministically the current file); xfnBad === 0 proves the call
|
||
reached a handler instead of being swallowed */
|
||
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13,
|
||
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn,
|
||
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
|
||
"gaps-open": (i) => i.gapCards >= 3,
|
||
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
|
||
"library-by-format": (i) => /^FLAC 24/.test(i.firstBadge),
|
||
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3,
|
||
playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter,
|
||
"playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast,
|
||
"playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
|
||
"playlist-filter": (i) => i.plrows === 1,
|
||
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
|
||
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice) && /Removed/.test(i.announce) && /Undo/.test(i.announce),
|
||
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
|
||
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
|
||
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
|
||
"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"),
|
||
"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 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 },
|
||
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
|
||
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
|
||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
|
||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()`, wait: 700 },
|
||
/* auto-sync off and straight back on — the cache entry ends as it began */
|
||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 },
|
||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 },
|
||
/* the tap room on the real clock: two taps, one undone, then discarded —
|
||
nothing is written to Kyle's own lyrics for this track */
|
||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 },
|
||
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
|
||
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
|
||
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
|
||
/* the real DSP on a real file: the envelope is a whole-file decode, so
|
||
give it room. Estimating writes nothing — the room is discarded next */
|
||
{ do: `(function () { var b = document.querySelector('.sy-est'); if (b && !b.disabled) b.click(); })()`, name: "sync-estimated", wait: 2500 },
|
||
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
|
||
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 },
|
||
{ do: `history.back()`, name: "after-back", wait: 1000 },
|
||
/* a reload keeps the audio thread: the session comes back LIVE */
|
||
{ do: `location.reload()`, name: "after-reload", wait: 4000 },
|
||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'the'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 1200 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
||
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 900 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 1200 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
||
/* the artist page: the library's shell at once, the catalog dissolving in */
|
||
/* button.row: the page header is a .row too, and it is a div */
|
||
/* playlists on the real core: one made here, a record's tracks added
|
||
from the record's sheet, a row played, the playlist deleted again —
|
||
the phone's library is left as it was */
|
||
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
|
||
{ do: `__click('.newpl', 'New playlist')`, wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Walk test'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1500 },
|
||
{ do: `history.back()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
||
{ do: `(function () { var all = document.querySelectorAll('.grid2 .release'); var c = null; for (var i = 0; i < all.length; i++) { if (!${albumText} || all[i].textContent.indexOf(${albumText}) >= 0) { c = all[i]; break; } } if (!c) throw new Error('no album card'); c.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 900 },
|
||
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 },
|
||
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
|
||
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
|
||
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
|
||
/* the paste door on the real core: two lines that resolve to a record
|
||
in the library, reviewed, created, deleted again */
|
||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 },
|
||
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 },
|
||
/* the unknown row repaired from the real library */
|
||
{ do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 },
|
||
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 },
|
||
{ do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 },
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
|
||
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
|
||
/* the pasted-links door on the real core: one track link, and the wait
|
||
line says which door read the paste; the review is left as it came */
|
||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 },
|
||
{ name: "import-links-review", wait: 9000 },
|
||
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
|
||
/* the folder door on the real core, one record's own folder — the
|
||
argument the command grew, walked for real; nothing is created */
|
||
{ do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 },
|
||
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
|
||
/* the picker itself is a system sheet the walk cannot tap: open it,
|
||
dismiss it with BACK, and check the cancel path leaves nothing */
|
||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 },
|
||
{ adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },
|
||
/* the share sheet: a Spotify playlist link sent by "another app" (an
|
||
intent on the plugin's activity) lands in the import — the review is
|
||
left without creating anything */
|
||
{ adb: ["shell", "am", "start", "-a", "android.intent.action.SEND", "-t", "text/plain", "--es", "android.intent.extra.TEXT", "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "-n", "com.soulstream.app/com.soulstream.share.ShareActivity"], name: "share-import", wait: 9000 },
|
||
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
||
/* You: the settings save for real (camelCase args) — the switch and a
|
||
chip, each read back from the config, each restored */
|
||
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 900 },
|
||
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 900 },
|
||
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 },
|
||
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 },
|
||
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 },
|
||
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 900 },
|
||
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, name: "you-slots-back", wait: 900 },
|
||
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 900 },
|
||
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words-off", wait: 900 },
|
||
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 900 },
|
||
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 },
|
||
/* the listening port on the real core, camelCase for real: a spare
|
||
number, then 50300 — the client's own default, so the phone ends the
|
||
walk on exactly the port an unset config would already have used */
|
||
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 900 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50310'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 1200 },
|
||
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
|
||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
|
||
/* the shade, for real. The walk starts no download — two rows go
|
||
straight into the transfer store, which is the notice's only input —
|
||
and the phone's own notification is read back out of dumpsys, then
|
||
the rows are taken away and it has to be gone. Nothing is fetched
|
||
into Kyle's library, and the ids are far above the backend's counter */
|
||
{ do: `__click('.screen .row', 'Transfers')`, name: "shade-open", wait: 1200 },
|
||
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 2400, probe: shadeNotice },
|
||
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2400, probe: shadeNotice },
|
||
{ do: `history.back()`, wait: 700 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
||
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
|
||
/* the real config's contact list, read back over the real IPC */
|
||
{ do: `__ss.loadContacts()`, name: "contacts-read", wait: 800 },
|
||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
||
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
|
||
{ name: "artist", wait: 4500 },
|
||
/* the page's ⋯ on the real core: the hero door is there and knows the
|
||
artist's id — it opens the system picker, so the walk only looks */
|
||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 },
|
||
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
|
||
/* a discography card the library does NOT hold opens the catalog record */
|
||
{ do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 },
|
||
{ do: `history.back()`, wait: 800 },
|
||
{ do: `history.back()`, wait: 800 },
|
||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
||
/* the translator, on the real build: the backend's own vocabulary in,
|
||
plain sentences out. No side effects — nothing is signed out (the
|
||
login throttle is a remembered trap), nothing is asked of the network,
|
||
nothing is written. It also catches a build where trouble.js simply
|
||
did not ship: __ss.say would not be there and the line stays empty. */
|
||
{ do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 },
|
||
/* the first run's preview door, on Kyle's own phone. It READS his
|
||
state — his folder, his account — and must write nothing at all: the
|
||
walk never clears his config, never re-runs setup and never stamps
|
||
the flag, and `setupWrites` staying at zero is what says so. Driven
|
||
through __ss rather than the row in You so the step cannot miss it
|
||
on a long screen, and closed by the scrim, which on a preview is the
|
||
same no-op as the button. Nothing is typed and nothing is picked. */
|
||
{ do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 },
|
||
{ do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 },
|
||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
|
||
];
|
||
const PHONE_EXPECT = {
|
||
/* the phone's own launch, held wider on purpose: a real one signs in,
|
||
restores a session and may walk the follows, none of which the mock
|
||
does, and its library reads are real SQLite rather than an object in
|
||
memory. What is asserted here is the shape — a census that counted
|
||
something, sealed, still went out in one round, and did not drag the
|
||
art prewarm back to boot. The milliseconds are printed rather than
|
||
policed: every step's JSON carries bootMs and bootLibMs, so the
|
||
device's real figures land in the report the first time this walk
|
||
runs. */
|
||
home: (i) => i.bootCalls >= 14 && i.bootCalls <= 90 && i.bootSync >= 20 && i.bootMs >= 0 && !/library_art_prewarm/.test(i.bootCmds),
|
||
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img") && !i.a11yAnon && !i.a11yGhost,
|
||
"album-playing": (i) => i.mini && !i.toast,
|
||
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
|
||
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
|
||
/* the walked album's track may genuinely have no words, so the room's
|
||
own rows are conditional — the discard is not */
|
||
"lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"),
|
||
"lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"),
|
||
"sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)),
|
||
"sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)),
|
||
"sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)),
|
||
"sync-discarded": (i) => !i.sync && !i.toast,
|
||
viewer: (i) => i.viewer,
|
||
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
|
||
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),
|
||
sheet: (i) => i.sheet.includes("up") && /^dialog\/true\/Now playing/.test(i.dlg) && !i.a11yGhost,
|
||
"after-back": (i) => !i.sheet.includes("up"),
|
||
"after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast,
|
||
search: (i) => i.title.startsWith("Search"),
|
||
"hold-sheet": (i) => i.half.includes("up"),
|
||
tracks: (i) => i.title.startsWith("Your library"),
|
||
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
|
||
"artist-cold": (i) => i.screen.includes("has-hero"),
|
||
"sync-estimated": (i) => !i.toast && (!i.sync
|
||
|| (/Clear the estimate/.test(i.syEst)
|
||
? i.syTicks > 0 && i.syStrip === 64
|
||
: /Estimate the timing|Reading the song|No estimate/.test(i.syEst))),
|
||
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax && !i.a11yAnon && !i.a11yGhost && !i.a11yState,
|
||
"you-norm-off": (i) => i.norm === "off" && !i.toast,
|
||
"you-norm-on": (i) => i.norm === "on" && !i.toast,
|
||
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,
|
||
"you-nolimit": (i) => i.srcMax === "No limit" && !i.toast,
|
||
"you-slots": (i) => /^\d+$/.test(i.slots) && !i.toast,
|
||
"you-slots-back": (i) => /^\d+$/.test(i.slots) && !i.toast,
|
||
"you-words": (i) => i.wordfill === "on" && !i.toast,
|
||
"you-words-off": (i) => i.wordfill === "off" && !i.toast,
|
||
"you-artedge": (i) => i.artEdge === "1000 px" && !i.toast,
|
||
"you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast,
|
||
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
|
||
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||
"shade-open": (i) => i.title.startsWith("Transfers") && !i.toast,
|
||
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText),
|
||
/* the dump has to have been read for the negative to mean anything: one
|
||
that came back empty would pass "no downloads channel" on nothing at
|
||
all. A format change fails the step above, loudly, first */
|
||
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans),
|
||
playlists: (i) => i.title.startsWith("Your library"),
|
||
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
|
||
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
|
||
"playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast,
|
||
"playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,
|
||
"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,
|
||
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
|
||
"share-cancel": (i) => i.title.startsWith("Your library"),
|
||
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1
|
||
&& /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast),
|
||
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),
|
||
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
|
||
/* eleven real backend strings through the one translator on the device:
|
||
every output a sentence, and not one of them still carrying Rust */
|
||
"trouble-words": (i) => i.trouble.split(" || ").length === 11
|
||
&& i.trouble.split(" || ").every(function (s) { return s.length > 12; })
|
||
&& !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble),
|
||
/* both cards on a phone that is fully set up, and the counter at zero
|
||
on the way in and on the way out — the preview wrote nothing to his
|
||
config. `setupWrites` would also be 1 if the boot gate had fired on
|
||
his phone, which is the other thing this catches. */
|
||
"setup-preview": (i) => i.setupUp && i.setupWrites === 0
|
||
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
|
||
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
|
||
end: (i) => /^(Good |Still)/.test(i.title),
|
||
};
|
||
|
||
/* What OUR package has in the shade right now: the channels its live
|
||
* notifications sit on, and the downloads one's own title and text.
|
||
* `--noredact` is what makes the extras print their values rather than a
|
||
* placeholder. Nobody else's notifications are read — `seen` counts the
|
||
* dump's records only so that "no downloads channel" cannot pass by the
|
||
* dump having come back empty. */
|
||
function shadeNotice() {
|
||
try {
|
||
const dump = adb("shell", "dumpsys", "notification", "--noredact").toString();
|
||
const blocks = dump.split("NotificationRecord(").slice(1);
|
||
const chans = [];
|
||
let title = "", text = "";
|
||
for (const block of blocks) {
|
||
const head = block.slice(0, 6000);
|
||
if (!/pkg=com\.soulstream\.app\b/.test(head)) continue;
|
||
/* Notification.toString() carries `channel=<id>` on the record's
|
||
own first line; older dumps spell it as the channel object */
|
||
let ch = (head.match(/channel=([\w.\-]+)/) || [])[1] || "";
|
||
if (!ch || ch === "NotificationChannel") ch = (head.match(/mId='([^']*)'/) || [])[1] || ch;
|
||
if (ch) chans.push(ch);
|
||
if (ch !== "downloads") continue;
|
||
/* the extras print as `<key>=<class> (<value>)`, and the class is
|
||
whatever CharSequence the builder kept — String on this one,
|
||
but a Spannable on a device that styles it, so the class is
|
||
matched loosely and stray quotes are trimmed */
|
||
const extra = (key) => {
|
||
const m = head.match(new RegExp(key.replace(/\./g, "\\.") + "=[A-Za-z]+ \\(([^\\n]*?)\\)\\s*\\n"));
|
||
return m ? m[1].replace(/^"|"$/g, "") : "";
|
||
};
|
||
title = extra("android.title");
|
||
text = extra("android.text");
|
||
}
|
||
return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text };
|
||
} catch (e) {
|
||
return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" };
|
||
}
|
||
}
|
||
|
||
/* The system's view of the app's media session: who owns it and what the
|
||
* playback state says. From `dumpsys media_session`. */
|
||
function mediaSessionState() {
|
||
try {
|
||
const dump = adb("shell", "dumpsys", "media_session").toString();
|
||
/* sessions are listed as blocks; find ours by package, then the first
|
||
playback state after it */
|
||
const at = dump.search(/com\.soulstream\.app/);
|
||
if (at < 0) return { owner: "", state: "" };
|
||
const block = dump.slice(Math.max(0, at - 400), at + 3000);
|
||
const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []);
|
||
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };
|
||
} catch { return { owner: "", state: "" }; }
|
||
}
|
||
|
||
/* The audio mixer's underrun count for the app's fast track: a dropout the
|
||
* ear hears is a number here. Read before and after the walk; a rise during
|
||
* it is a finding. */
|
||
let appUidCache = null;
|
||
function appUid() {
|
||
if (appUidCache) return appUidCache;
|
||
try { appUidCache = (adb("shell", "dumpsys", "package", "com.soulstream.app").toString().match(/\buid=(\d+)/) || [])[1] || null; } catch { appUidCache = null; }
|
||
return appUidCache;
|
||
}
|
||
/* SoulStream's own tracks only: the mixer's thread counters are system-wide
|
||
and any app's notification sound moves them. Each track row of the dump
|
||
carries its client "pid/ uid" and an Underruns column (third from the
|
||
end, before Flushed and BitPerfect). */
|
||
function audioUnderruns() {
|
||
try {
|
||
const uid = appUid();
|
||
if (!uid) return null;
|
||
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
|
||
let n = 0;
|
||
for (const line of dump.split("\n")) {
|
||
const m = line.match(/(\d+)\/\s+(\d+)\s+\d+\s+\d+\s/);
|
||
if (!m || m[2] !== uid) continue;
|
||
const t = line.trim().split(/\s+/);
|
||
const u = Number(t[t.length - 3]);
|
||
if (Number.isFinite(u)) n += u;
|
||
}
|
||
return n;
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function main() {
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
const problems = [];
|
||
let cdp = null; // reassigned by the restart phase
|
||
let underrunsBefore = null;
|
||
let underrunRise = 0; /* summed across the restart: a relaunch starts fresh tracks at zero */
|
||
let chrome = null;
|
||
let steps, expect, still, prefix;
|
||
|
||
if (PHONE && ARGS.includes("--only-lock")) {
|
||
/* the lock-screen card alone: whatever is on the bar plays, the phone
|
||
sleeps, wakes to its lock screen, and that is the still. Leaves the
|
||
phone locked. */
|
||
const attached = await attachPhone(PORT);
|
||
cdp = attached.cdp;
|
||
await cdp.send("Runtime.enable");
|
||
const st = await cdp.evaluate(`(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, status: p.status } : null; })()`);
|
||
if (!st) { console.log("nothing on the bar to show on the lock screen"); process.exit(1); }
|
||
if (st.status !== "playing") { await cdp.evaluate(`window.__ss && __ss.toggle()`); await sleep(2500); }
|
||
cdp.close();
|
||
cdp = null;
|
||
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
|
||
await sleep(2000);
|
||
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
|
||
await sleep(2500);
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
await phoneStill(path.join(OUT, "phone-lockscreen.png"));
|
||
console.log(" -- lockscreen still taken (" + st.title + "); the phone is locked now");
|
||
process.exit(0);
|
||
}
|
||
if (PHONE) {
|
||
const attached = await attachPhone(PORT);
|
||
cdp = attached.cdp;
|
||
const pid = attached.pid;
|
||
underrunsBefore = audioUnderruns();
|
||
steps = PHONE_STEPS; expect = PHONE_EXPECT; prefix = "phone-";
|
||
still = phoneStill;
|
||
console.log("attached to the phone's WebView (pid " + pid + ")");
|
||
} else {
|
||
if (!CHROME) throw new Error("Chrome not found");
|
||
console.log("building dist-smoke/ …");
|
||
/* unminified, so a warning's frames name the function */
|
||
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./", "--minify", "false"], { cwd: ROOT, shell: true, encoding: "utf8" });
|
||
if (b.status !== 0) { console.error(b.stdout, b.stderr); throw new Error("vite build failed"); }
|
||
/* a profile per run: a Chrome left behind by an interrupted run keeps
|
||
its crashpad file open, and removing that profile throws EBUSY */
|
||
const profile = path.join(os.tmpdir(), "ss-smoke-profile-" + process.pid);
|
||
for (const d of fs.readdirSync(os.tmpdir())) {
|
||
if (d.startsWith("ss-smoke-profile")) {
|
||
try { fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true }); } catch { /* still held */ }
|
||
}
|
||
}
|
||
chrome = spawn(CHROME, [
|
||
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--mute-audio",
|
||
"--allow-file-access-from-files", `--user-data-dir=${profile}`,
|
||
`--remote-debugging-port=${PORT}`, "--window-size=411,914", "about:blank",
|
||
], { stdio: "ignore" });
|
||
cdp = await CDP.attach(PORT);
|
||
steps = MOCK_STEPS; expect = MOCK_EXPECT; prefix = "";
|
||
still = async (file) => {
|
||
const shot = await cdp.send("Page.captureScreenshot", { format: "png" });
|
||
fs.writeFileSync(file, Buffer.from(shot.result.data, "base64"));
|
||
};
|
||
}
|
||
|
||
try {
|
||
await cdp.send("Page.enable");
|
||
await cdp.send("Runtime.enable");
|
||
await cdp.send("Log.enable");
|
||
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
|
||
cdp.on["Runtime.consoleAPICalled"] = (p) => {
|
||
if (p.type !== "error" && p.type !== "warning") return;
|
||
/* the reload step's own consequence: a pending IPC answer with nobody left to receive it */
|
||
if (p.args.some((x) => /Couldn't find callback id/.test(String(x.value ?? "")))) return;
|
||
const frames = (p.stackTrace?.callFrames || []).slice(0, 5)
|
||
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
|
||
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
|
||
};
|
||
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
|
||
/* after a reload the WebView may present a NEW page target: the old
|
||
socket then never answers. Attach again, keeping the handlers. */
|
||
const reattach = async () => {
|
||
const on = cdp.on;
|
||
try { cdp.close(); } catch { /* already gone */ }
|
||
const a = PHONE ? await attachPhone(PORT) : { cdp: await CDP.attach(PORT) };
|
||
cdp = a.cdp;
|
||
cdp.on = on;
|
||
await cdp.send("Page.enable");
|
||
await cdp.send("Runtime.enable");
|
||
await cdp.send("Log.enable");
|
||
};
|
||
/* the emulated phone, kept: a re-attach has to be handed it again */
|
||
let metrics = { width: 411, height: 914, deviceScaleFactor: 2, mobile: true };
|
||
if (!PHONE) {
|
||
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
|
||
await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
|
||
await sleep(600);
|
||
}
|
||
for (const s of steps) {
|
||
/* a phone step can be an adb command — an intent, a key */
|
||
if (s.adb) {
|
||
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
|
||
}
|
||
/* a mock step can turn the phone over: the viewport, through the
|
||
same override the walk boots with. A real device's orientation
|
||
is not the harness's to set, so a view step on the phone walk
|
||
is a failure rather than a no-op. send() resolves with the
|
||
whole reply and does not reject on a protocol error, so a
|
||
refused override has to be read out of it. */
|
||
if (s.view) {
|
||
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
|
||
else {
|
||
try {
|
||
metrics = { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true };
|
||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
|
||
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
|
||
}
|
||
}
|
||
if (s.do) {
|
||
try {
|
||
/* the helpers live in the page; a reload step wipes them */
|
||
if (s.do.includes("__")) await cdp.evaluate(HELPERS);
|
||
await cdp.evaluate(s.do);
|
||
} catch (e) { problems.push("STEP " + (s.name || s.do) + ": " + e.message); }
|
||
}
|
||
await sleep(s.wait || 500);
|
||
if (s.do === "location.reload()") {
|
||
try {
|
||
await reattach();
|
||
if (!PHONE) await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||
} catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
|
||
}
|
||
if (!s.name) continue;
|
||
await still(path.join(OUT, `${prefix}${s.name}.png`));
|
||
const info = await cdp.evaluate(PROBE);
|
||
/* a step may want the system's own view as well; dumpsys is slow,
|
||
so only the steps that ask for it pay for it */
|
||
if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }
|
||
const ok = expect[s.name] ? expect[s.name](info) : true;
|
||
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
|
||
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));
|
||
}
|
||
|
||
/* ── the restart half of session restore, on the phone only ──────
|
||
A reload kept the audio thread (tested above); a real restart
|
||
loses it. The track must come back on the bar, paused, and play
|
||
must pick it up where it was rather than at zero. */
|
||
if (PHONE) {
|
||
const PLAYER = `(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, pos: Math.round(p.position), status: p.status, needs: !!p.needsResource } : null; })()`;
|
||
const before = await cdp.evaluate(PLAYER);
|
||
cdp.close();
|
||
if (underrunsBefore != null) { const now = audioUnderruns(); if (now != null) underrunRise += Math.max(0, now - underrunsBefore); }
|
||
adb("shell", "am", "force-stop", "com.soulstream.app");
|
||
await sleep(800);
|
||
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
|
||
underrunsBefore = null; /* re-read once the relaunch plays */
|
||
/* a relaunch restores a PAUSED track, which starts the media service
|
||
as a foreground service that must promote itself at once — on
|
||
2026-09-13 it did not, and Android killed the app ten seconds
|
||
later (a Save died with it). Wait past that window BEFORE
|
||
resuming, and the pid must be the same one afterwards. */
|
||
adb("logcat", "-c");
|
||
await sleep(13000);
|
||
underrunsBefore = audioUnderruns();
|
||
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
|
||
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
|
||
const alive = !!pid2 && !fatal;
|
||
if (!alive) problems.push("EXPECT survives-paused-restore " + JSON.stringify({ pid: pid2, fatal }));
|
||
console.log((alive ? " ok " : " FAIL ") + "paused-restore".padEnd(16) + " " + JSON.stringify({ pid: pid2 || null, fatal }));
|
||
adb("forward", "--remove-all");
|
||
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
|
||
cdp = await CDP.attach(PORT);
|
||
await cdp.send("Runtime.enable");
|
||
await still(path.join(OUT, `${prefix}restart.png`));
|
||
const after = await cdp.evaluate(PLAYER);
|
||
const restored = !!before && !!after && after.title === before.title && after.status === "paused" && after.needs && Math.abs(after.pos - before.pos) <= 15;
|
||
if (!restored) problems.push("EXPECT restart " + JSON.stringify({ before, after }));
|
||
console.log((restored ? " ok " : " FAIL ") + "restart".padEnd(16) + " " + JSON.stringify({ before, after }));
|
||
await cdp.evaluate(`window.__ss && __ss.toggle()`);
|
||
await sleep(3500);
|
||
const resumed = await cdp.evaluate(PLAYER);
|
||
await still(path.join(OUT, `${prefix}resumed.png`));
|
||
const okResume = !!resumed && resumed.status === "playing" && !resumed.needs && (!after || resumed.pos >= after.pos - 1);
|
||
if (!okResume) problems.push("EXPECT resumed " + JSON.stringify(resumed));
|
||
console.log((okResume ? " ok " : " FAIL ") + "resumed".padEnd(16) + " " + JSON.stringify(resumed));
|
||
|
||
/* ── the media session, end to end ─────────────────────────
|
||
The system must hold a session for the app with the track
|
||
playing; a media key must reach the player through it; the
|
||
audio must keep moving with the app in the background. */
|
||
await sleep(1500);
|
||
const sess = mediaSessionState();
|
||
const sessOk = /com\.soulstream\.app/.test(sess.owner) && /PLAYING/.test(sess.state);
|
||
if (!sessOk) problems.push("EXPECT media-session " + JSON.stringify(sess));
|
||
console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess));
|
||
|
||
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
|
||
await sleep(1500);
|
||
const keyPaused = await cdp.evaluate(PLAYER);
|
||
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PLAY");
|
||
await sleep(2000);
|
||
const keyPlaying = await cdp.evaluate(PLAYER);
|
||
const keysOk = keyPaused?.status === "paused" && keyPlaying?.status === "playing";
|
||
if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying }));
|
||
console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status }));
|
||
|
||
const posBefore = (await cdp.evaluate(PLAYER))?.pos ?? 0;
|
||
cdp.close();
|
||
adb("shell", "input", "keyevent", "KEYCODE_HOME");
|
||
await sleep(10000);
|
||
const stillPlaying = /PLAYING/.test(mediaSessionState().state);
|
||
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
|
||
await sleep(2500);
|
||
cdp = await CDP.attach(PORT, { phone: true });
|
||
await cdp.send("Runtime.enable");
|
||
const posAfter = (await cdp.evaluate(PLAYER))?.pos ?? 0;
|
||
const bgOk = stillPlaying && posAfter >= posBefore + 8;
|
||
if (!bgOk) problems.push("EXPECT background " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
|
||
console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
|
||
await still(path.join(OUT, `${prefix}shade.png`));
|
||
|
||
/* ── the network: a search, then a peer's copy played as it
|
||
arrives. Only with the app signed in (the stored account signs
|
||
in at boot); skipped, not failed, when it is not. ────────── */
|
||
const online = await cdp.evaluate(`!!(window.__ss && __ss.slsk && __ss.slsk.connection === 'online')`);
|
||
if (!online) {
|
||
console.log(" -- network skipped: not signed in on the phone");
|
||
} else {
|
||
const QUERY = flag("--query") || "daft punk";
|
||
await cdp.evaluate(HELPERS);
|
||
await cdp.evaluate(`__settle()`);
|
||
await sleep(700);
|
||
await cdp.evaluate(`document.querySelectorAll('.nav button')[1].click()`);
|
||
await sleep(600);
|
||
await cdp.evaluate(`(function () { var i = document.querySelector('.sfield input'); i.value = ${JSON.stringify(QUERY)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||
const CAT = `(function () { return { songs: document.querySelectorAll('.cat-song').length, records: document.querySelectorAll('.rail .release').length, artists: document.querySelectorAll('.cat-artist').length }; })()`;
|
||
let catInfo = null;
|
||
for (let i = 0; i < 30; i++) {
|
||
await sleep(500);
|
||
catInfo = await cdp.evaluate(CAT);
|
||
if (catInfo.songs >= 1) break;
|
||
}
|
||
await sleep(600);
|
||
catInfo = await cdp.evaluate(CAT);
|
||
await still(path.join(OUT, `${prefix}cat-results.png`));
|
||
const resOk = catInfo.songs >= 1;
|
||
if (!resOk) problems.push("EXPECT cat-results " + JSON.stringify(catInfo));
|
||
console.log((resOk ? " ok " : " FAIL ") + "cat-results".padEnd(16) + " " + JSON.stringify(catInfo));
|
||
|
||
if (resOk) {
|
||
/* the hero's page: an artist's (tabs, Popular) or a record's */
|
||
const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (document.querySelector('.gb-t') || {}).textContent || '', tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`;
|
||
const topName = (await cdp.evaluate(HERO)).top;
|
||
await cdp.evaluate(HELPERS);
|
||
await cdp.evaluate(`__click('.top .btn-ghost', null)`);
|
||
/* the catalog page lands when the provider answers — polled, since a fixed wait read a slow answer as a failure */
|
||
let hero = null;
|
||
for (let i = 0; i < 24; i++) {
|
||
await sleep(500);
|
||
hero = await cdp.evaluate(HERO);
|
||
if (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1) break;
|
||
}
|
||
await still(path.join(OUT, `${prefix}cat-hero-page.png`));
|
||
const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1);
|
||
if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero));
|
||
console.log((heroOk ? " ok " : " FAIL ") + "cat-hero-page".padEnd(16) + " " + JSON.stringify({ top: topName, title: hero.title, tabs: hero.tabs, pop: hero.pop, socials: hero.socs, hero: hero.screen.includes("has-hero") }));
|
||
await cdp.evaluate(`history.back()`);
|
||
await sleep(1200);
|
||
|
||
/* the first song, through the resolver: the ladder, a
|
||
peer's copy, the lane rising before the file is here */
|
||
const STREAM = `(function () { var p = window.__ss && __ss.player; if (!p || !p.track) return null; return { title: p.track.title, from: p.track.stream && p.track.stream.username, status: p.status, phase: p.phase, pos: Math.round(p.position), coverage: +(p.coverage || 0).toFixed(2), received: p.stream && p.stream.received, total: p.stream && p.stream.total, failed: p.stream && p.stream.failed, error: p.error || '', live: (document.querySelector('.mini .m.live') || {}).textContent || '' }; })()`;
|
||
await cdp.evaluate(`document.querySelector('.cat-song').click()`);
|
||
let st = null;
|
||
for (let i = 0; i < 60; i++) {
|
||
await sleep(1000);
|
||
st = await cdp.evaluate(STREAM);
|
||
if (st && st.status === "playing" && st.pos >= 3) break;
|
||
if (st && (st.failed || st.error)) break;
|
||
}
|
||
await still(path.join(OUT, `${prefix}net-playing.png`));
|
||
console.log(" stream " + JSON.stringify(st));
|
||
await cdp.evaluate(`document.querySelector('.mini').click()`);
|
||
await sleep(2500);
|
||
await still(path.join(OUT, `${prefix}net-sheet.png`));
|
||
const st2 = await cdp.evaluate(STREAM);
|
||
const partial = !!st2 && st2.received > 0 && (st2.received < st2.total || st2.coverage < 1 || st2.phase === "rising");
|
||
const streamOk = !!st && !!st2 && st.status === "playing" && st2.status === "playing" && !!st2.from && st2.pos >= st.pos && !st2.failed && !st2.error;
|
||
if (!streamOk) problems.push("EXPECT net-stream " + JSON.stringify({ st, st2 }));
|
||
console.log((streamOk ? " ok " : " FAIL ") + "net-stream".padEnd(16) + " " + JSON.stringify({ from: st2?.from, status: st2?.status, phase: st2?.phase, pos: st2?.pos, coverage: st2?.coverage, received: st2?.received, total: st2?.total, live: st?.live, partialWhileWatched: partial }));
|
||
/* the raw peers, on request */
|
||
await cdp.evaluate(`document.querySelector('.sheet .np-head .iconbtn').click()`);
|
||
await sleep(700);
|
||
await cdp.evaluate(`document.querySelector('.net-ask').click()`);
|
||
const NET = `(function () { return { peers: document.querySelectorAll('.peer').length, files: document.querySelectorAll('.peer-file').length }; })()`;
|
||
let netInfo = null;
|
||
for (let i = 0; i < 30; i++) {
|
||
await sleep(500);
|
||
netInfo = await cdp.evaluate(NET);
|
||
if (netInfo.peers >= 4) break;
|
||
}
|
||
await still(path.join(OUT, `${prefix}net-peers.png`));
|
||
const peersOk = !!netInfo && netInfo.peers >= 1;
|
||
if (!peersOk) problems.push("EXPECT net-peers " + JSON.stringify(netInfo));
|
||
console.log((peersOk ? " ok " : " FAIL ") + "net-peers".padEnd(16) + " " + JSON.stringify(netInfo));
|
||
/* a peer's sheet → their shares browsed (one request; a big
|
||
share takes seconds) → back. A share that will not open
|
||
is reported by the screen, not a failure of the port. */
|
||
try {
|
||
await cdp.evaluate(`document.querySelector('.peer-head .peer-menu').click()`);
|
||
await sleep(700);
|
||
await cdp.evaluate(HELPERS);
|
||
await cdp.evaluate(`__click('.halfsheet .row', 'Browse their files')`);
|
||
const BRW = `(function () { return { title: (document.querySelector('.gb-t') || {}).textContent || '', dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
|
||
let brw = null;
|
||
for (let i = 0; i < 40; i++) { await sleep(500); brw = await cdp.evaluate(BRW); if (brw.dirs > 0 || brw.err) break; }
|
||
await still(path.join(OUT, `${prefix}net-browse.png`));
|
||
const brwOk = !!brw && (brw.dirs > 0 || brw.err);
|
||
if (!brwOk) problems.push("EXPECT net-browse " + JSON.stringify(brw));
|
||
console.log((brwOk ? " ok " : " FAIL ") + "net-browse".padEnd(16) + " " + JSON.stringify(brw));
|
||
await cdp.evaluate(`history.back()`);
|
||
await sleep(800);
|
||
} catch (e) { problems.push("STEP net-browse: " + e.message); }
|
||
await cdp.evaluate(HELPERS);
|
||
await cdp.evaluate(`__settle()`);
|
||
await sleep(800);
|
||
}
|
||
}
|
||
|
||
/* the lock-screen card: opt in, because it locks the phone and a
|
||
fingerprint has to open it again */
|
||
if (ARGS.includes("--lockscreen")) {
|
||
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
|
||
await sleep(2000);
|
||
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
|
||
await sleep(2500);
|
||
await still(path.join(OUT, `${prefix}lockscreen.png`));
|
||
console.log(" -- lockscreen still taken; the phone is locked now");
|
||
}
|
||
}
|
||
} finally {
|
||
if (cdp) cdp.close();
|
||
if (chrome) chrome.kill();
|
||
}
|
||
if (PHONE) {
|
||
const after = audioUnderruns();
|
||
const rise = underrunRise + (underrunsBefore != null && after != null ? Math.max(0, after - underrunsBefore) : 0);
|
||
const grew = rise > 0;
|
||
if (grew) problems.push(`AUDIO SoulStream's tracks underran ${rise} time${rise === 1 ? "" : "s"} during the walk`);
|
||
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` SoulStream's tracks: ${rise} underrun${rise === 1 ? "" : "s"} during the walk`);
|
||
}
|
||
/* the two toasts a Tauri-less page always shows are not findings */
|
||
const real = PHONE ? problems : problems.filter((p) => !/transformCallback|reading 'invoke'/.test(p));
|
||
console.log("\n" + (real.length ? real.length + " problem(s):\n" + real.map((p) => " - " + p.slice(0, 400)).join("\n") : "no exceptions, no console errors, every step as expected"));
|
||
process.exit(real.length ? 1 : 0);
|
||
}
|
||
main().catch((e) => { console.error(e); process.exit(1); });
|