@specific.dev/spectest 0.89.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { type Wrapped } from "./inspect.js";
2
+ export interface AndroidRecordingOptions {
3
+ /** Maximum dimension of the video, default 1280. */
4
+ maxSize?: number;
5
+ /** Maximum frame rate, default 15. Static screens produce few frames. */
6
+ maxFps?: number;
7
+ /** H.264 bitrate target in bits/second, default 2,000,000. */
8
+ bitRate?: number;
9
+ }
10
+ export declare const ANDROID_APP: unique symbol;
11
+ export interface AndroidApp {
12
+ readonly [ANDROID_APP]: true;
13
+ readonly name: string;
14
+ readonly endpoint: string;
15
+ readonly recorderEndpoint: string;
16
+ readonly serial: string;
17
+ readonly package: string;
18
+ readonly recording: false | AndroidRecordingOptions;
19
+ readonly showTouches?: boolean;
20
+ }
21
+ export declare function androidApp(options: Omit<AndroidApp, typeof ANDROID_APP>): AndroidApp;
22
+ export declare function isAndroidApp(value: unknown): value is AndroidApp;
23
+ export declare class AppiumError extends Error {
24
+ readonly code: string;
25
+ constructor(code: string, message: string);
26
+ }
27
+ export interface AndroidElement {
28
+ tap(): Promise<void>;
29
+ fill(text: string): Promise<void>;
30
+ text(): Promise<Wrapped<string>>;
31
+ isDisplayed(): Promise<Wrapped<boolean>>;
32
+ waitFor(options?: {
33
+ state?: "visible" | "hidden";
34
+ timeoutMs?: number;
35
+ }): Promise<void>;
36
+ }
37
+ export interface Android {
38
+ getByAccessibilityId(id: string): AndroidElement;
39
+ getById(resourceId: string): AndroidElement;
40
+ getByText(text: string): AndroidElement;
41
+ /** Appium selector, e.g. -android uiautomator or xpath. */
42
+ locator(using: string, value: string): AndroidElement;
43
+ tap(x: number, y: number): Promise<void>;
44
+ swipe(direction: "up" | "down" | "left" | "right"): Promise<void>;
45
+ back(): Promise<void>;
46
+ pageSource(): Promise<Wrapped<string>>;
47
+ /** PNG bytes. */
48
+ screenshot(): Promise<Uint8Array>;
49
+ /** Appium extension command, e.g. mobile: getDeviceInfo. */
50
+ execute<T = unknown>(script: string, args?: Record<string, unknown>): Promise<Wrapped<T>>;
51
+ }
52
+ export interface AndroidTap {
53
+ /** Milliseconds from the case start, in the same clock as the video manifest. */
54
+ offsetMs: number;
55
+ /** Normalized screen coordinates, independent of encoding resolution. */
56
+ x: number;
57
+ y: number;
58
+ }
59
+ export interface AndroidRecording {
60
+ id: string;
61
+ name: string;
62
+ package: string;
63
+ openedAtMs: number;
64
+ closedAtMs: number;
65
+ mediaDurationMs: number;
66
+ width: number;
67
+ height: number;
68
+ bytes: number;
69
+ taps?: AndroidTap[];
70
+ error?: string;
71
+ }
72
+ /** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
73
+ export declare function androidScope(caseId: string, testStartedAt: number, options?: {
74
+ record?: boolean;
75
+ }): {
76
+ begin: (apps?: Iterable<AndroidApp>) => Promise<void>;
77
+ open: (app: AndroidApp) => Promise<Android>;
78
+ finish: () => Promise<AndroidRecording[]>;
79
+ deactivate: () => void;
80
+ };
81
+ export declare function androidRecordingChunk(id: string, offset: unknown, limit: unknown): Promise<unknown>;
82
+ export declare function releaseAndroidRecording(id: string): Promise<{
83
+ released: boolean;
84
+ }>;
@@ -0,0 +1,355 @@
1
+ import { createHash } from "node:crypto";
2
+ import { rawFetch } from "./harness/raw-fetch.js";
3
+ import { recordStep, reserveEvent } from "./recorder.js";
4
+ import { wrap } from "./inspect.js";
5
+ export const ANDROID_APP = Symbol.for("spectest.androidApp");
6
+ export function androidApp(options) {
7
+ return { ...options, [ANDROID_APP]: true };
8
+ }
9
+ export function isAndroidApp(value) {
10
+ return !!value && typeof value === "object" && value[ANDROID_APP] === true;
11
+ }
12
+ export class AppiumError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = "AppiumError";
18
+ }
19
+ }
20
+ async function request(url, method = "GET", body) {
21
+ const response = await rawFetch(url, {
22
+ method,
23
+ ...(body === undefined
24
+ ? {}
25
+ : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
26
+ signal: AbortSignal.timeout(120000),
27
+ });
28
+ const data = (await response.json());
29
+ if (!response.ok || data.value?.error || data.error)
30
+ throw new AppiumError(data.value?.error ?? "unknown error", data.value?.message ?? data.error ?? `Android request failed (${response.status})`);
31
+ return ("value" in data ? data.value : data);
32
+ }
33
+ // Both the Appium session and this registry are preserved by a VM snapshot.
34
+ // Never reset/relaunch the app or delete its session at a case boundary.
35
+ const sessions = new Map();
36
+ async function session(app) {
37
+ const key = JSON.stringify([app.endpoint, app.serial, app.package]);
38
+ let pending = sessions.get(key);
39
+ if (!pending) {
40
+ pending = request(`${app.endpoint}/session`, "POST", {
41
+ capabilities: {
42
+ alwaysMatch: {
43
+ platformName: "Android",
44
+ "appium:automationName": "UiAutomator2",
45
+ "appium:udid": app.serial,
46
+ "appium:appPackage": app.package,
47
+ "appium:noReset": true,
48
+ "appium:autoLaunch": false,
49
+ "appium:dontStopAppOnReset": true,
50
+ "appium:newCommandTimeout": 0,
51
+ },
52
+ },
53
+ })
54
+ .then((result) => {
55
+ if (!result.sessionId)
56
+ throw new Error("Appium returned no session id");
57
+ return result.sessionId;
58
+ })
59
+ .catch((error) => {
60
+ sessions.delete(key);
61
+ throw error;
62
+ });
63
+ sessions.set(key, pending);
64
+ }
65
+ return pending;
66
+ }
67
+ const parked = new Map();
68
+ let epoch = 0;
69
+ /** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
70
+ export function androidScope(caseId, testStartedAt, options = {}) {
71
+ let active = true;
72
+ const captures = new Map();
73
+ const handles = new Map();
74
+ const taps = new Map();
75
+ const assertActive = () => {
76
+ if (!active)
77
+ throw new Error("This Android handle belongs to a finished test");
78
+ };
79
+ async function capture(app) {
80
+ assertActive();
81
+ if (app.recording === false || options.record === false)
82
+ return;
83
+ let pending = captures.get(app.name);
84
+ if (!pending) {
85
+ if (captures.size >= 8)
86
+ throw new Error("A case can record at most eight Android devices");
87
+ const id = createHash("sha256").update(`${caseId}:${app.name}:${++epoch}`).digest("hex");
88
+ pending = request(`${app.recorderEndpoint}/recordings`, "POST", {
89
+ id,
90
+ ...app.recording,
91
+ }).then((result) => ({
92
+ app,
93
+ id,
94
+ openedAtMs: Math.max(0, result.startedAtMs - testStartedAt),
95
+ }));
96
+ captures.set(app.name, pending);
97
+ }
98
+ await pending;
99
+ }
100
+ async function begin(apps = []) {
101
+ assertActive();
102
+ // Previous artifacts were fetched before the VM was handed off. Forked
103
+ // children can discard their inherited copies without touching siblings.
104
+ for (const [id, previous] of parked) {
105
+ await request(`${previous.app.recorderEndpoint}/recordings/${id}`, "DELETE");
106
+ parked.delete(id);
107
+ }
108
+ for (const app of apps)
109
+ await capture(app);
110
+ assertActive();
111
+ }
112
+ async function open(app) {
113
+ assertActive();
114
+ if (!isAndroidApp(app))
115
+ throw new Error("ctx.android(): pass the handle from an android() service");
116
+ let pending = handles.get(app.name);
117
+ if (pending)
118
+ return pending;
119
+ pending = (async () => {
120
+ await capture(app);
121
+ assertActive();
122
+ const sid = await session(app);
123
+ assertActive();
124
+ const command = (method, path, body) => {
125
+ assertActive();
126
+ return request(`${app.endpoint}/session/${encodeURIComponent(sid)}${path}`, method, body);
127
+ };
128
+ async function tapMarker(point) {
129
+ if (!captures.has(app.name) || app.showTouches === false)
130
+ return;
131
+ // Appium injects touches after Android's physical-touch visualizer.
132
+ // Keep markers alongside the video; never change tap semantics or fail
133
+ // a test just because optional visual feedback could not be measured.
134
+ try {
135
+ const screen = await command("GET", "/window/rect");
136
+ const rect = typeof point === "string"
137
+ ? await command("GET", `${point}/rect`)
138
+ : { ...point, width: 0, height: 0 };
139
+ const x = (rect.x + rect.width / 2) / screen.width;
140
+ const y = (rect.y + rect.height / 2) / screen.height;
141
+ if (![x, y].every(v => Number.isFinite(v) && v >= 0 && v <= 1))
142
+ return;
143
+ return { x, y, offsetMs: Date.now() - testStartedAt };
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ }
149
+ function saveTap(marker) {
150
+ if (!marker || !active)
151
+ return;
152
+ const list = taps.get(app.name) ?? [];
153
+ if (list.length < 10000)
154
+ list.push(marker);
155
+ taps.set(app.name, list);
156
+ }
157
+ async function op(title, fn) {
158
+ assertActive();
159
+ const reservation = reserveEvent(), started = Date.now();
160
+ try {
161
+ const result = await fn();
162
+ const seq = recordStep({
163
+ kind: "android",
164
+ title: `${app.name}: ${title}`,
165
+ durationMs: Date.now() - started,
166
+ status: "passed",
167
+ }, reservation);
168
+ return wrap(result, seq);
169
+ }
170
+ catch (error) {
171
+ recordStep({
172
+ kind: "android",
173
+ title: `${app.name}: ${title}`,
174
+ durationMs: Date.now() - started,
175
+ status: "failed",
176
+ error: String(error),
177
+ }, reservation);
178
+ throw error;
179
+ }
180
+ }
181
+ const element = (using, value) => {
182
+ const label = `${using}=${value}`;
183
+ const find = async () => {
184
+ const result = await command("POST", "/element", {
185
+ using,
186
+ value,
187
+ });
188
+ const id = result["element-6066-11e4-a52e-4f735466cecf"];
189
+ if (!id)
190
+ throw new Error("Appium returned no element id");
191
+ return `/element/${encodeURIComponent(id)}`;
192
+ };
193
+ const displayed = async () => {
194
+ try {
195
+ return await command("GET", `${await find()}/displayed`);
196
+ }
197
+ catch (error) {
198
+ if (error instanceof AppiumError &&
199
+ ["no such element", "stale element reference"].includes(error.code))
200
+ return false;
201
+ throw error;
202
+ }
203
+ };
204
+ return {
205
+ async tap() {
206
+ await op(`tap ${label}`, async () => {
207
+ const path = await find();
208
+ const marker = await tapMarker(path);
209
+ await command("POST", `${path}/click`, {});
210
+ saveTap(marker);
211
+ });
212
+ },
213
+ async fill(text) {
214
+ await op(`fill ${label}`, async () => {
215
+ const path = await find();
216
+ await command("POST", `${path}/clear`, {});
217
+ await command("POST", `${path}/value`, { text });
218
+ });
219
+ },
220
+ text: () => op(`text ${label}`, async () => command("GET", `${await find()}/text`)),
221
+ isDisplayed: () => op(`visible ${label}`, displayed),
222
+ async waitFor(options = {}) {
223
+ const timeout = options.timeoutMs ?? 10000;
224
+ if (!Number.isFinite(timeout) || timeout < 0)
225
+ throw new Error("Invalid wait timeout");
226
+ await op(`wait for ${options.state ?? "visible"} ${label}`, async () => {
227
+ const end = Date.now() + timeout;
228
+ do {
229
+ if ((await displayed()) === (options.state !== "hidden"))
230
+ return;
231
+ await new Promise((resolve) => setTimeout(resolve, 100));
232
+ } while (Date.now() < end);
233
+ throw new Error(`Timed out waiting for ${label}`);
234
+ });
235
+ },
236
+ };
237
+ };
238
+ return {
239
+ locator: element,
240
+ getByAccessibilityId: (id) => element("accessibility id", id),
241
+ getById: (id) => element("id", id),
242
+ getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`),
243
+ async tap(x, y) {
244
+ await op(`tap (${x}, ${y})`, async () => {
245
+ const marker = await tapMarker({ x, y });
246
+ await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
247
+ saveTap(marker);
248
+ });
249
+ },
250
+ async swipe(direction) {
251
+ await op(`swipe ${direction}`, async () => {
252
+ const rect = await command("GET", "/window/rect");
253
+ await command("POST", "/execute/sync", {
254
+ script: "mobile: swipeGesture",
255
+ args: [
256
+ {
257
+ left: rect.width * 0.1,
258
+ top: rect.height * 0.1,
259
+ width: rect.width * 0.8,
260
+ height: rect.height * 0.8,
261
+ direction,
262
+ percent: 0.75,
263
+ },
264
+ ],
265
+ });
266
+ });
267
+ },
268
+ async back() {
269
+ await op("back", () => command("POST", "/back", {}));
270
+ },
271
+ pageSource: () => op("page source", () => command("GET", "/source")),
272
+ async screenshot() {
273
+ const value = await op("screenshot", () => command("GET", "/screenshot"));
274
+ return Buffer.from(value.unwrap(), "base64");
275
+ },
276
+ execute: (script, args = {}) => op(script, () => command("POST", "/execute/sync", { script, args: [args] })),
277
+ };
278
+ })();
279
+ handles.set(app.name, pending);
280
+ return pending;
281
+ }
282
+ async function finish() {
283
+ active = false;
284
+ const result = [];
285
+ for (const [name, pending] of captures) {
286
+ let owner;
287
+ let recording = {
288
+ id: "",
289
+ name,
290
+ package: "",
291
+ openedAtMs: 0,
292
+ closedAtMs: Date.now() - testStartedAt,
293
+ mediaDurationMs: 0,
294
+ width: 0,
295
+ height: 0,
296
+ bytes: 0,
297
+ taps: taps.get(name) ?? [],
298
+ };
299
+ try {
300
+ const { app, id, openedAtMs } = await pending;
301
+ owner = app;
302
+ recording = { ...recording, id, package: app.package, openedAtMs };
303
+ const stopped = await request(`${app.recorderEndpoint}/recordings/${id}/stop`, "POST");
304
+ recording = {
305
+ ...recording,
306
+ bytes: stopped.bytes,
307
+ width: stopped.width,
308
+ height: stopped.height,
309
+ mediaDurationMs: stopped.mediaDurationMs,
310
+ closedAtMs: Math.max(openedAtMs, stopped.closedAtMs - testStartedAt),
311
+ };
312
+ parked.set(id, { app, recording });
313
+ }
314
+ catch (error) {
315
+ recording.error = String(error);
316
+ // A failed finalization still owns a file in the tools container.
317
+ if (recording.id) {
318
+ if (owner)
319
+ await request(`${owner.recorderEndpoint}/recordings/${recording.id}`, "DELETE").catch(() => { });
320
+ }
321
+ }
322
+ result.push(recording);
323
+ }
324
+ return result;
325
+ }
326
+ return {
327
+ begin,
328
+ open,
329
+ finish,
330
+ deactivate: () => {
331
+ active = false;
332
+ },
333
+ };
334
+ }
335
+ export async function androidRecordingChunk(id, offset, limit) {
336
+ const entry = parked.get(id);
337
+ if (!entry)
338
+ throw new Error("Unknown Android recording");
339
+ const start = Number(offset ?? 0), count = Number(limit ?? 6291456);
340
+ if (!Number.isSafeInteger(start) ||
341
+ start < 0 ||
342
+ !Number.isSafeInteger(count) ||
343
+ count < 1 ||
344
+ count > 6291456)
345
+ throw new Error("Invalid recording chunk range");
346
+ return request(`${entry.app.recorderEndpoint}/recordings/${id}?offset=${start}&limit=${count}`);
347
+ }
348
+ export async function releaseAndroidRecording(id) {
349
+ const entry = parked.get(id);
350
+ if (entry) {
351
+ await request(`${entry.app.recorderEndpoint}/recordings/${id}`, "DELETE");
352
+ parked.delete(id);
353
+ }
354
+ return { released: true };
355
+ }
@@ -0,0 +1,257 @@
1
+ // Runs inside the Android tools container, in the same snapshotted VM.
2
+ import http from "node:http";
3
+ import { spawn, execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdir, stat, open, rm } from "node:fs/promises";
6
+ const exec = promisify(execFile);
7
+ const serial = process.env.ANDROID_SERIAL;
8
+ const directory = "/recordings";
9
+ const recordings = new Map();
10
+ const maxBytes = 256 * 1024 * 1024;
11
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+ const adb = (...args) =>
13
+ exec("adb", ["-s", serial, ...args], { timeout: 120000, maxBuffer: 1024 * 1024 });
14
+ await mkdir(directory, { recursive: true });
15
+ let ready = false;
16
+ let bootError;
17
+ async function bootstrap() {
18
+ const deadline = Date.now() + 180000;
19
+ while (Date.now() < deadline) {
20
+ try {
21
+ await exec("adb", ["connect", serial], { timeout: 10000 });
22
+ if ((await adb("shell", "getprop", "sys.boot_completed")).stdout.trim() === "1") break;
23
+ } catch {}
24
+ await sleep(500);
25
+ }
26
+ if ((await adb("shell", "getprop", "sys.boot_completed")).stdout.trim() !== "1") {
27
+ throw new Error(
28
+ "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
+ );
30
+ }
31
+ await adb("install", "-r", "/spectest/app.apk");
32
+ await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
+ for (const setting of [
34
+ "window_animation_scale",
35
+ "transition_animation_scale",
36
+ "animator_duration_scale",
37
+ ]) {
38
+ await adb("shell", "settings", "put", "global", setting, "0");
39
+ }
40
+ // Native feedback covers physical input; injected Appium taps use replay metadata.
41
+ await adb("shell", "settings", "put", "system", "show_touches", process.env.ANDROID_SHOW_TOUCHES ?? "1");
42
+ await adb("shell", "svc", "power", "stayon", "true");
43
+ await adb("shell", "input", "keyevent", "82");
44
+ await adb("shell", "am", "start", "-W", "-n", process.env.ANDROID_ACTIVITY);
45
+ const appium = spawn(
46
+ "appium",
47
+ ["--address", "0.0.0.0", "--port", "4723", "--log-level", "warn"],
48
+ { stdio: "inherit" },
49
+ );
50
+ appium.on("exit", (code) => {
51
+ ready = false;
52
+ bootError = `Appium exited (${code})`;
53
+ });
54
+ appium.on("error", (error) => {
55
+ ready = false;
56
+ bootError = error.message;
57
+ });
58
+ for (let i = 0; i < 120; i++) {
59
+ try {
60
+ if ((await fetch("http://127.0.0.1:4723/status")).ok) {
61
+ ready = true;
62
+ return;
63
+ }
64
+ } catch {}
65
+ await sleep(250);
66
+ }
67
+ throw new Error("Appium did not become ready");
68
+ }
69
+ bootstrap().catch((error) => {
70
+ bootError = error.message;
71
+ console.error(error);
72
+ });
73
+
74
+ async function startRecording(body) {
75
+ if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
76
+ if (recordings.has(body.id)) throw new Error("recording already exists");
77
+ if ([...recordings.values()].some((r) => !r.stopped))
78
+ throw new Error("a recording is already active");
79
+ if (recordings.size >= 8) throw new Error("release older recordings before starting another");
80
+ const size = body.maxSize ?? 1280,
81
+ fps = body.maxFps ?? 15,
82
+ bitrate = body.bitRate ?? 2000000;
83
+ if (
84
+ !Number.isInteger(size) ||
85
+ size < 320 ||
86
+ size > 1920 ||
87
+ !Number.isInteger(fps) ||
88
+ fps < 1 ||
89
+ fps > 30 ||
90
+ !Number.isInteger(bitrate) ||
91
+ bitrate < 100000 ||
92
+ bitrate > 8000000
93
+ )
94
+ throw new Error("invalid recording settings");
95
+ const path = `${directory}/${body.id}.mp4`;
96
+ // scrcpy's stdout is otherwise block-buffered under Node's pipes: the
97
+ // readiness line may never flush on an idle screen.
98
+ const child = spawn("stdbuf", [
99
+ "-oL",
100
+ "-eL",
101
+ "scrcpy",
102
+ "--serial",
103
+ serial,
104
+ "--no-window",
105
+ "--no-playback",
106
+ "--no-control",
107
+ "--no-audio",
108
+ "--video-codec=h264",
109
+ "--video-encoder=c2.android.avc.encoder",
110
+ `--max-size=${size}`,
111
+ `--max-fps=${fps}`,
112
+ `--video-bit-rate=${bitrate}`,
113
+ `--record=${path}`,
114
+ ]);
115
+ const record = { child, path, stopped: false, log: "", error: null, startedAtMs: Date.now() };
116
+ recordings.set(body.id, record);
117
+ record.exit = new Promise((resolve) => {
118
+ child.on("error", (error) => {
119
+ record.error = error.message;
120
+ resolve(-1);
121
+ });
122
+ child.on("exit", (code) => {
123
+ if (!record.stopped) record.error = `Recorder exited early (${code})`;
124
+ resolve(code);
125
+ });
126
+ });
127
+ for (const output of [child.stdout, child.stderr])
128
+ output.on("data", (b) => {
129
+ record.log = (record.log + b).slice(-16000);
130
+ if (!record.started && record.log.includes("Recording started")) {
131
+ record.started = true;
132
+ record.startedAtMs = Date.now();
133
+ }
134
+ });
135
+ record.guard = setInterval(async () => {
136
+ if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
137
+ record.error = "Recording exceeded 256 MiB";
138
+ child.kill("SIGINT");
139
+ }
140
+ }, 1000);
141
+ try {
142
+ for (let i = 0; i < 200; i++) {
143
+ if (record.error) throw new Error(`${record.error}: ${record.log}`);
144
+ if (record.started) return { startedAtMs: record.startedAtMs };
145
+ await sleep(50);
146
+ }
147
+ throw new Error(`Recorder startup timed out: ${record.log}`);
148
+ } catch (error) {
149
+ clearInterval(record.guard);
150
+ record.stopped = true;
151
+ child.kill("SIGKILL");
152
+ await record.exit;
153
+ await rm(path, { force: true });
154
+ recordings.delete(body.id);
155
+ throw error;
156
+ }
157
+ }
158
+ async function stopRecording(record) {
159
+ if (record.result) return record.result;
160
+ record.stopped = true;
161
+ const closedAtMs = Date.now();
162
+ clearInterval(record.guard);
163
+ record.child.kill("SIGINT");
164
+ let timer;
165
+ const code = await Promise.race([
166
+ record.exit,
167
+ new Promise((resolve) => {
168
+ timer = setTimeout(() => {
169
+ record.error = "Recorder finalization timed out";
170
+ record.child.kill("SIGKILL");
171
+ resolve(-1);
172
+ }, 15000);
173
+ }),
174
+ ]);
175
+ clearTimeout(timer);
176
+ if (record.error || code !== 0)
177
+ throw new Error(`${record.error ?? "Recorder failed"}: ${record.log}`);
178
+ const bytes = (await stat(record.path)).size;
179
+ if (bytes > maxBytes) throw new Error("Recording exceeded 256 MiB");
180
+ const { stdout } = await exec("ffprobe", [
181
+ "-v",
182
+ "error",
183
+ "-show_format",
184
+ "-show_streams",
185
+ "-of",
186
+ "json",
187
+ record.path,
188
+ ]);
189
+ const probe = JSON.parse(stdout),
190
+ stream = probe.streams.find((s) => s.codec_type === "video");
191
+ if (!stream || !Number(stream.nb_frames)) throw new Error("Recording contains no video frames");
192
+ return (record.result = {
193
+ bytes,
194
+ startedAtMs: record.startedAtMs,
195
+ closedAtMs,
196
+ width: stream.width,
197
+ height: stream.height,
198
+ mediaDurationMs: Math.round(Number(probe.format.duration) * 1000),
199
+ });
200
+ }
201
+ const server = http.createServer(async (req, res) => {
202
+ const json = (status, body) => {
203
+ res.writeHead(status, { "Content-Type": "application/json" });
204
+ res.end(JSON.stringify(body));
205
+ };
206
+ try {
207
+ const url = new URL(req.url, "http://localhost");
208
+ if (url.pathname === "/ready") return json(ready ? 200 : 503, { ready, error: bootError });
209
+ if (!ready) return json(503, { error: bootError ?? "Android is starting" });
210
+ if (req.method === "POST" && url.pathname === "/recordings") {
211
+ let body = "";
212
+ for await (const chunk of req) {
213
+ body += chunk;
214
+ if (body.length > 8192) throw new Error("request too large");
215
+ }
216
+ return json(200, await startRecording(JSON.parse(body)));
217
+ }
218
+ const match = url.pathname.match(/^\/recordings\/([a-z0-9-]{1,100})(\/stop)?$/);
219
+ const record = match && recordings.get(match[1]);
220
+ if (!record) return json(404, { error: "Unknown recording" });
221
+ if (req.method === "POST" && match[2]) return json(200, await stopRecording(record));
222
+ if (req.method === "DELETE") {
223
+ if (!record.stopped) throw new Error("Stop the recording before releasing it");
224
+ await rm(record.path, { force: true });
225
+ recordings.delete(match[1]);
226
+ return json(200, { released: true });
227
+ }
228
+ if (req.method === "GET" && record.result) {
229
+ const offset = Number(url.searchParams.get("offset") ?? 0),
230
+ limit = Number(url.searchParams.get("limit") ?? 6291456);
231
+ if (
232
+ !Number.isSafeInteger(offset) ||
233
+ offset < 0 ||
234
+ !Number.isSafeInteger(limit) ||
235
+ limit < 1 ||
236
+ limit > 6291456
237
+ )
238
+ throw new Error("invalid chunk range");
239
+ const file = await open(record.path, "r");
240
+ try {
241
+ const buffer = Buffer.alloc(Math.min(limit, Math.max(0, record.result.bytes - offset)));
242
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, offset);
243
+ return json(200, {
244
+ total: record.result.bytes,
245
+ offset,
246
+ b64: buffer.subarray(0, bytesRead).toString("base64"),
247
+ });
248
+ } finally {
249
+ await file.close();
250
+ }
251
+ }
252
+ json(409, { error: "Recording is not finalized" });
253
+ } catch (error) {
254
+ json(500, { error: error.message });
255
+ }
256
+ });
257
+ server.listen(4725, "0.0.0.0");