android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour

The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
/* The DevTools protocol, for the phone walk and the perf probe.
Two things live here: a client small enough to read (a WebSocket, ids,
a map of waits), and the phone attach — a debug build's WebView exposes
DevTools on `localabstract:webview_devtools_remote_<pid>`, which
`adb forward` puts on a local port. The attach refuses to run when
another app has the phone's screen: the harness never takes the phone
from whoever is holding it. */
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { MiniWS } from "./miniws.mjs";
/* Node 22 has a global WebSocket; Node 19 does not */
const WS = typeof WebSocket !== "undefined" ? WebSocket : MiniWS;
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const ADB = [
path.join(process.env.LOCALAPPDATA || "", "Android/Sdk/platform-tools/adb.exe"),
path.join(process.env.ANDROID_HOME || "", "platform-tools/adb.exe"),
"adb",
].find((p) => p === "adb" || fs.existsSync(p));
export function adb(...args) {
const r = spawnSync(ADB, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error("adb " + args.join(" ") + ": " + (r.stderr || "").toString());
return r.stdout;
}
export class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.on = {}; }
static async attach(port, { phone = false } = {}) {
for (let i = 0; i < 200; i++) {
try {
/* a backgrounded WebView accepts the TCP connection and never
answers — without a timeout this loop would hang for good */
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2500) })).json();
const page = list.find((t) => t.type === "page");
if (page) {
const ws = new WS(page.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
const c = new CDP(ws);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); }
else if (m.method && c.on[m.method]) c.on[m.method](m.params);
};
return c;
}
} catch { /* not up yet */ }
await sleep(200);
}
throw new Error("could not attach to the page on port " + port
+ (phone ? " (a WebView in the background does not answer; is SoulStream on screen?)" : ""));
}
send(method, params = {}) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((res) => this.waits.set(id, res));
}
async evaluate(expr) {
const r = await this.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
if (r.result && r.result.exceptionDetails) {
throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || JSON.stringify(r.result.exceptionDetails)));
}
return r.result?.result?.value;
}
close() { try { this.ws.close(); } catch { /* fine */ } }
}
/** What is on the phone's screen, from the window manager. */
export function phoneFocus() {
return (adb("shell", "dumpsys", "window").toString().match(/mCurrentFocus=[^\n]*/) || [""])[0];
}
/** Attach to SoulStream's WebView on the connected phone. Launches the app
* only from the launcher; refuses when another app is in front. */
export async function attachPhone(port) {
const focus = phoneFocus();
if (!/com\.soulstream\.app/.test(focus)) {
if (/launcher/i.test(focus)) {
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
await sleep(2500);
} else {
const who = (focus.match(/\s([\w.]+)\/[^}]*\}/) || [])[1] || "another app";
throw new Error("the phone is in use (" + who + " is on screen); not touching it");
}
}
const pid = adb("shell", "pidof", "com.soulstream.app").toString().trim();
if (!pid) throw new Error("SoulStream is not running on the phone (adb shell pidof found nothing)");
adb("forward", "--remove-all");
adb("forward", `tcp:${port}`, `localabstract:webview_devtools_remote_${pid}`);
const cdp = await CDP.attach(port, { phone: true });
return { cdp, pid };
}
/** The phone's own frame, status bar included. */
export function phoneStill(file) {
fs.writeFileSync(file, adb("exec-out", "screencap", "-p"));
}
+81
View File
@@ -0,0 +1,81 @@
/* A WebSocket client small enough to live here. Node 19 has no global
WebSocket (it arrived in 22), and the recorder must not depend on an npm
install — a mockup that needs one is a mockup nobody records. Text frames
only, which is all the DevTools protocol uses; handles the 16- and 64-bit
lengths a 200 KB screencast frame needs, continuation frames, and pings.
Same surface as the browser's: onopen / onerror / onmessage({data}) / send. */
import http from 'node:http';
import crypto from 'node:crypto';
export class MiniWS {
constructor(url) {
this.onopen = null; this.onerror = null; this.onmessage = null; this.onclose = null;
this.buf = Buffer.alloc(0);
this.frag = [];
this.sock = null;
const u = new URL(url);
const req = http.request({
host: u.hostname, port: u.port, path: u.pathname + u.search, method: 'GET',
headers: {
Connection: 'Upgrade', Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'),
},
});
req.on('upgrade', (res, sock) => {
this.sock = sock;
sock.on('data', d => this.feed(d));
sock.on('error', e => this.onerror && this.onerror(e));
sock.on('close', () => this.onclose && this.onclose());
if (this.onopen) this.onopen();
});
req.on('response', res => { if (this.onerror) this.onerror(new Error('upgrade refused: HTTP ' + res.statusCode)); });
req.on('error', e => { if (this.onerror) this.onerror(e); });
req.end();
}
feed(d) {
this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d;
for (;;) {
const b = this.buf;
if (b.length < 2) return;
const fin = (b[0] & 0x80) !== 0, op = b[0] & 0x0f, masked = (b[1] & 0x80) !== 0;
let len = b[1] & 0x7f, off = 2;
if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); off = 4; }
else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); off = 10; }
if (masked) off += 4; /* a server must not mask; tolerate it */
if (b.length < off + len) return; /* wait for the rest of the frame */
let payload = b.subarray(off, off + len);
if (masked) {
const m = b.subarray(off - 4, off);
payload = Buffer.from(payload.map((v, i) => v ^ m[i & 3]));
}
this.buf = b.subarray(off + len);
if (op === 0x8) { this.close(); return; }
if (op === 0x9) { this.frame(0xA, payload); continue; }
if (op === 0xA) continue;
if (op === 0x1 || op === 0x2 || op === 0x0) {
this.frag.push(payload);
if (fin) {
const msg = Buffer.concat(this.frag).toString('utf8');
this.frag = [];
if (this.onmessage) this.onmessage({ data: msg });
}
}
}
}
frame(op, payload) {
const mask = crypto.randomBytes(4);
const n = payload.length;
let head;
if (n < 126) head = Buffer.from([0x80 | op, 0x80 | n]);
else if (n < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | op; head[1] = 0x80 | 126; head.writeUInt16BE(n, 2); }
else { head = Buffer.alloc(10); head[0] = 0x80 | op; head[1] = 0x80 | 127; head.writeBigUInt64BE(BigInt(n), 2); }
const body = Buffer.alloc(n);
for (let i = 0; i < n; i++) body[i] = payload[i] ^ mask[i & 3];
this.sock.write(Buffer.concat([head, mask, body]));
}
send(s) { this.frame(0x1, Buffer.from(String(s), 'utf8')); }
close() { if (this.sock) { this.sock.end(); this.sock = null; } }
}
+155
View File
@@ -0,0 +1,155 @@
/* Where the phone's frames go.
Attaches to the app on the connected phone, then times the frames the
WebView actually produced (a requestAnimationFrame log in the page) while
the harness scrolls a long list, pushes a record page, opens the player
and sits idle with a track playing — the moments a person feels as lag.
Each scene runs under several CSS variants injected live, so the cost of
an effect is measured rather than guessed: the backdrop blurs behind the
chrome, the artwork ambience, the entrance animations.
node tests/mobile/perf.mjs every scene × every variant
node tests/mobile/perf.mjs --quick the base variant only
Frames over 32 ms are two dropped frames at 60 Hz; the p90 says how the
scene feels, the max how bad the worst hitch was. */
import { attachPhone, sleep } from "./cdp.mjs";
const QUICK = process.argv.includes("--quick");
const PORT = 9222;
const VARIANTS = {
base: "",
"no-backdrop": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}`,
"no-ambience": `.amb{filter:none!important}.grain{display:none!important}`,
"no-feat-blur": `.feat .bg{filter:none!important}`,
"no-motion": `.screen,.screen>*{animation:none!important}`,
"no-blur-at-all": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}.amb{filter:none!important}.grain{display:none!important}.feat .bg{filter:none!important}`,
};
const FRAMES = `(function () {
if (!window.__fm) {
var f = { t: [], on: false };
window.__fm = f;
(function loop(ts) { if (f.on) f.t.push(ts); requestAnimationFrame(loop); })(0);
}
return true;
})()`;
const START = `(__fm.t = [], __fm.on = true, true)`;
const STOP = `(function () {
__fm.on = false;
var t = __fm.t, d = [];
for (var i = 1; i < t.length; i++) d.push(t[i] - t[i - 1]);
d.sort(function (a, b) { return a - b; });
var n = d.length;
if (!n) return { frames: 0 };
var q = function (p) { return Math.round(d[Math.min(n - 1, Math.floor(n * p))]); };
return { frames: n, p50: q(0.5), p90: q(0.9), p99: q(0.99), max: Math.round(d[n - 1]),
over32: d.filter(function (x) { return x > 32; }).length, over100: d.filter(function (x) { return x > 100; }).length };
})()`;
const VARIANT = (css) => `(function () {
var s = document.getElementById('__perfvar');
if (!s) { s = document.createElement('style'); s.id = '__perfvar'; document.head.appendChild(s); }
s.textContent = ${JSON.stringify(css)};
return true;
})()`;
async function scrollGesture(cdp, distance) {
/* the WebView's own scroll, through the input pipeline, not scrollBy */
const size = await cdp.evaluate(`[innerWidth, innerHeight]`);
await cdp.send("Input.synthesizeScrollGesture", {
x: Math.round(size[0] / 2), y: Math.round(size[1] * 0.6),
yDistance: distance, speed: 1400, gestureSourceType: "touch",
});
}
/* the handle on the page has no patch(); tabs are clicked like a thumb would */
const tab = (i) => `(function(){ var b = document.querySelectorAll('.segtabs button')[${i}]; if (b) b.click(); return true; })()`;
const SCENES = [
{
name: "scroll tracks",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(2)); },
run: async (cdp) => { await scrollGesture(cdp, -1800); await scrollGesture(cdp, 1800); await scrollGesture(cdp, -1800); },
},
{
name: "scroll home",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async (cdp) => { await scrollGesture(cdp, -1200); await scrollGesture(cdp, 1200); },
},
{
name: "push record",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); },
run: async (cdp) => {
await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`);
await sleep(900);
await cdp.evaluate(`__ss.back(); true`);
await sleep(700);
},
},
{
name: "open player",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async (cdp) => {
await cdp.evaluate(`(function(){ var m = document.querySelector('.mini'); if (m) m.click(); })()`);
await sleep(1100);
await cdp.evaluate(`__ss.closeSheet(); true`);
await sleep(700);
},
},
{
name: "idle, playing",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async () => { await sleep(3000); },
},
];
async function main() {
const { cdp } = await attachPhone(PORT);
await cdp.send("Runtime.enable");
await cdp.send("Performance.enable");
const hasHandle = await cdp.evaluate(`!!window.__ss`);
if (!hasHandle) throw new Error("no __ss handle on the page — an older build?");
await cdp.evaluate(FRAMES);
/* something must be playing for the lane and the ambience to cost anything */
if (!(await cdp.evaluate(`__ss.player.status === 'playing'`))) {
/* something must be playing for the lane and the ambience to cost anything */
await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); await sleep(300);
await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`); await sleep(900);
await cdp.evaluate(`(function(){ var d = document.querySelector('.rec-acts .disc'); if (d) d.click(); })()`); await sleep(1500);
await cdp.evaluate(`__ss.back(); true`); await sleep(500);
}
const variants = QUICK ? ["base"] : Object.keys(VARIANTS);
const rows = [];
for (const scene of SCENES) {
for (const v of variants) {
await cdp.evaluate(VARIANT(VARIANTS[v]));
await scene.setup(cdp);
await sleep(900);
const m0 = (await cdp.send("Performance.getMetrics")).result.metrics;
await cdp.evaluate(START);
await scene.run(cdp);
const stats = await cdp.evaluate(STOP);
const m1 = (await cdp.send("Performance.getMetrics")).result.metrics;
const metric = (name) => (m1.find((x) => x.name === name)?.value ?? 0) - (m0.find((x) => x.name === name)?.value ?? 0);
const row = {
scene: scene.name, variant: v, ...stats,
script: Math.round(metric("ScriptDuration") * 1000),
style: Math.round(metric("RecalcStyleDuration") * 1000),
layout: Math.round(metric("LayoutDuration") * 1000),
task: Math.round(metric("TaskDuration") * 1000),
};
rows.push(row);
console.log(
scene.name.padEnd(14) + v.padEnd(16)
+ `frames ${String(row.frames).padStart(4)} p50 ${String(row.p50).padStart(3)} p90 ${String(row.p90).padStart(3)} p99 ${String(row.p99).padStart(4)} max ${String(row.max).padStart(4)} >32ms ${String(row.over32).padStart(3)} >100ms ${String(row.over100).padStart(2)}`
+ ` script ${row.script}ms style ${row.style}ms layout ${row.layout}ms`,
);
}
}
await cdp.evaluate(VARIANT(""));
await cdp.evaluate(`__ss.go('home'); true`);
cdp.close();
return rows;
}
main().catch((e) => { console.error(e.message || e); process.exit(1); });
+583
View File
@@ -0,0 +1,583 @@
/* 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(); }
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') || {}).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 || '',
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: `document.querySelector('.scrim').click()`, wait: 500 },
{ 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 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
{ 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 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
{ 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 },
/* 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: `__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 },
/* 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 },
{ 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 },
{ 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: `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"),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow,
"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"),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library"),
album: (i) => i.screen.includes("tr-push") && !i.mini,
"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"),
"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,
/* 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-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"),
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
"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,
"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.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
{ 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 */
{ 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,
"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"),
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. */
function audioUnderruns() {
try {
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
let n = 0;
for (const m of dump.matchAll(/Fifo frame underruns:[^\n]*n:\s*(\d+)/g)) n += +m[1];
for (const m of dump.matchAll(/numTracks=\d+ writeErrors=\d+ underruns=(\d+)/g)) n += +m[1];
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 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/ …");
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./"], { 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") problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" "));
};
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
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) {
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.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();
adb("shell", "am", "force-stop", "com.soulstream.app");
await sleep(800);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
await sleep(6000);
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
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)`);
await sleep(4500);
const hero = await cdp.evaluate(HERO);
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));
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 && underrunsBefore != null) {
const after = audioUnderruns();
const grew = after != null && after > underrunsBefore;
if (grew) problems.push(`AUDIO underruns rose during the walk: ${underrunsBefore} → ${after}`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` mixer underruns ${underrunsBefore} → ${after}`);
}
/* 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); });