Files
SoulStream/tests/mobile/smoke.mjs
T
KeelrLeek f8c3b90665 android: hours on the library's count line, By format, Jump back in on the empty search
The desktop Library summary's hours on the phone's count line; a By format
order from the file stats (lossless by depth and rate first, then by
bitrate, the badge as the card's line); the empty Search page's Jump back
in — the last records played — as the desktop's empty search shows them.
2026-09-13 21:31:16 -05:00

1047 lines
80 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');
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,
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 || '',
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'),
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,
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 || '',
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,
};
})()`;
/* ── 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 },
{ 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 },
{ 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 },
{ 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 },
{ do: `history.back()`, wait: 700 },
/* 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 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 },
{ 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 },
/* 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 },
{ 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 },
];
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),
"user-lookup": (i) => i.half.includes("up") && /dusty_crates/.test(i.halfText) && /Browse their files/.test(i.halfText) && /Add buddy/.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",
"you-norm-off": (i) => i.norm === "off",
"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",
"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"),
home: (i) => /^(Good |Still)/.test(i.title),
"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),
album: (i) => i.screen.includes("tr-push") && !i.mini,
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,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
"track-sheet": (i) => i.half.includes("up"),
sheet: (i) => i.sheet.includes("up"),
"after-back": (i) => !i.sheet.includes("up"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played),
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,
"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"),
"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-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
"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),
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,
"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),
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
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),
"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),
"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),
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "",
"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,
"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 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 },
{ 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 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 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
{ 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 },
/* 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 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
];
const PHONE_EXPECT = {
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img"),
"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,
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"),
"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"),
"artist-cold": (i) => i.screen.includes("has-hero"),
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
"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,
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,
"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,
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
end: (i) => /^(Good |Still)/.test(i.title),
};
/* 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");
};
if (!PHONE) {
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
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); }
}
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(); } 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);
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); });