Files
SoulStream/tests/mobile/cdp.mjs
T
KeelrLeek 9bfbcfc96c android: the phone walk waits for the app, not just for the WebView
A walk always follows a fresh install, so the app is always cold: the shell
mounts, the library loads, Soulseek logs in and the share index is built.
Attaching only proves the WebView answers, and the walk used to begin its
first step there — against a page that had not finished mounting, which
reads as a broken feature rather than a slow boot. It now waits for the
handle it drives, and the attach itself gets two more tries when the
devtools socket outlasts its own forty seconds of patience.
2026-09-14 06:43:53 -05:00

128 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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");
}
}
/* The devtools socket is registered by the WebView, not by the process, so
there is a window after a launch where the app is up, focused and has a
pid, and the socket does not exist yet. On a loaded machine that window
has been longer than CDP.attach’s own forty seconds of patience — and the walk
then reported "is SoulStream on screen?" about an app that plainly was. Each
attempt below already waits 40s; three of them is the ceiling, re-reading the
pid each time in case the app restarted underneath us. */
let pid = "";
let last = null;
for (let attempt = 0; attempt < 3; attempt++) {
pid = adb("shell", "pidof", "com.soulstream.app").toString().trim();
if (pid) {
adb("forward", "--remove-all");
adb("forward", `tcp:${port}`, `localabstract:webview_devtools_remote_${pid}`);
try {
const cdp = await CDP.attach(port, { phone: true });
return { cdp, pid };
} catch (e) {
last = e;
}
}
await sleep(2500);
}
if (!pid) throw new Error("SoulStream is not running on the phone (adb shell pidof found nothing)");
throw last ?? new Error("could not attach to the phone's WebView on port " + port);
}
/** The phone's own frame, status bar included. */
export function phoneStill(file) {
fs.writeFileSync(file, adb("exec-out", "screencap", "-p"));
}