Files
SoulStream/tests/mobile/cdp.mjs
T
KeelrLeek a8adce087f android: the phone walk waits for the WebView instead of giving up on it
A launch leaves a window where the app is up, focused and has a pid while
the devtools socket the walk attaches to does not exist yet — on a loaded
machine it has been as long as ten seconds. attachPhone tried once and
reported "is SoulStream on screen?" about an app that plainly was. It now
tries for fifteen seconds, re-reading the pid each time in case the app
restarted underneath it.
2026-09-14 04:55:17 -05:00

127 lines
5.9 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");
}
}
/* 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 as long as ten seconds — a walk that attached once and gave up
read it as "is SoulStream on screen?" when it plainly was. Try for a
while, re-reading the pid each time in case the app restarted under us. */
let pid = "";
let last = null;
for (let attempt = 0; attempt < 6; 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"));
}