Files
SoulStream/tests/mobile/cdp.mjs
T
KeelrLeek 26928b706d android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
2026-09-13 19:56:20 -05:00

109 lines
5.1 KiB
JavaScript

/* 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?)" : ""));
}
/* a page that never answers (frozen, or replaced by a reload) must fail
the call, not the whole walk */
send(method, params = {}, timeoutMs = 45000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((res, rej) => {
const t = setTimeout(() => {
if (this.waits.delete(id)) rej(new Error("the page did not answer " + method + " in " + Math.round(timeoutMs / 1000) + "s — a frozen or replaced WebView?"));
}, timeoutMs);
this.waits.set(id, (m) => { clearTimeout(t); res(m); });
});
}
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"));
}