@specific.dev/spectest 0.89.1 → 0.90.1
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.
- package/dist/android-step.d.ts +22 -0
- package/dist/android-step.js +78 -0
- package/dist/android.d.ts +84 -0
- package/dist/android.js +360 -0
- package/dist/components/android-runtime.mjs +257 -0
- package/dist/components/android.d.ts +74 -0
- package/dist/components/android.js +92 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/daemon.js +30 -3
- package/dist/harness/main.js +6 -1
- package/dist/harness/sticky-consumer.d.ts +28 -0
- package/dist/harness/sticky-consumer.js +178 -0
- package/dist/harness/sticky-maintenance.d.ts +1 -0
- package/dist/harness/sticky-maintenance.js +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/recorder.d.ts +4 -1
- package/dist/recorder.js +6 -3
- package/package.json +7 -3
- package/src/android-step.test.ts +115 -0
- package/src/android-step.ts +107 -0
- package/src/android.test.ts +191 -0
- package/src/android.ts +449 -0
- package/src/components/android-runtime.mjs +257 -0
- package/src/components/android.test.ts +68 -0
- package/src/components/android.ts +123 -0
- package/src/components/index.ts +2 -0
- package/src/daemon.ts +29 -3
- package/src/eval-fetch.test.ts +111 -0
- package/src/harness/main.ts +6 -1
- package/src/harness/sticky-consumer.test.ts +45 -0
- package/src/harness/sticky-consumer.ts +176 -0
- package/src/harness/sticky-maintenance.ts +7 -0
- package/src/index.ts +4 -0
- package/src/recorder.ts +6 -3
|
@@ -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");
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { android } from "./android.js";
|
|
6
|
+
|
|
7
|
+
test("Android expands into two VM services with an APK input and private Appium/ADB addresses", async () => {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "spectest-android-")),
|
|
9
|
+
apk = join(dir, "app.apk");
|
|
10
|
+
writeFileSync(apk, "fixture");
|
|
11
|
+
try {
|
|
12
|
+
// Project path constants are captured on module load, just as in a guest.
|
|
13
|
+
const child = Bun.spawn(
|
|
14
|
+
[
|
|
15
|
+
process.execPath,
|
|
16
|
+
"-e",
|
|
17
|
+
`
|
|
18
|
+
import { defineEnvironment } from ${JSON.stringify(new URL("../index.ts", import.meta.url).pathname)};
|
|
19
|
+
import { android } from ${JSON.stringify(new URL("./android.ts", import.meta.url).pathname)};
|
|
20
|
+
const env = defineEnvironment({ name: "android", services: {
|
|
21
|
+
phone: android({ apk: "app.apk", package: "com.example.app", activity: ".MainActivity", dependsOn: ["backend"], recording: { maxSize: 640 } }),
|
|
22
|
+
backend: { image: { type: "registry", reference: "node:22" } },
|
|
23
|
+
} }).project().environment;
|
|
24
|
+
console.log(JSON.stringify(env));
|
|
25
|
+
`,
|
|
26
|
+
],
|
|
27
|
+
{ env: { ...process.env, SPECTEST_WORKSPACE: dir }, stdout: "pipe", stderr: "pipe" },
|
|
28
|
+
);
|
|
29
|
+
const output = await new Response(child.stdout).text();
|
|
30
|
+
expect(await new Response(child.stderr).text()).toBe("");
|
|
31
|
+
expect(await child.exited).toBe(0);
|
|
32
|
+
const env = JSON.parse(output);
|
|
33
|
+
const device = env.services["phone-device"],
|
|
34
|
+
tools = env.services.phone;
|
|
35
|
+
expect(device.privileged).toBe(true);
|
|
36
|
+
expect(device.args).toContain("androidboot.redroid_width=1080");
|
|
37
|
+
expect(device.args).toContain("androidboot.redroid_height=2400");
|
|
38
|
+
expect(device.args).toContain("androidboot.redroid_dpi=420");
|
|
39
|
+
expect(device.dependsOn).toEqual(["backend"]);
|
|
40
|
+
expect(device.args).toContain("androidboot.redroid_net_dns1=127.0.0.11");
|
|
41
|
+
expect(tools.dependsOn).toContain("phone-device");
|
|
42
|
+
expect(tools.env?.ANDROID_SERIAL).toBe("phone-device.internal:5555");
|
|
43
|
+
expect(tools.env?.ANDROID_ACTIVITY).toBe("com.example.app/.MainActivity");
|
|
44
|
+
expect(tools.env?.ANDROID_SHOW_TOUCHES).toBe("1");
|
|
45
|
+
expect(tools.inputs).toContain("app.apk");
|
|
46
|
+
expect(tools.volumes).toContainEqual({
|
|
47
|
+
source: apk,
|
|
48
|
+
target: "/spectest/app.apk",
|
|
49
|
+
readOnly: true,
|
|
50
|
+
});
|
|
51
|
+
expect(tools.readyCheck).toMatchObject({ type: "http", port: 4725 });
|
|
52
|
+
expect(tools.files?.[0].content).toContain("--no-window");
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("bad dimensions and identifiers fail before image builds", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
android({ apk: "x.apk", package: "bad/package", activity: ".MainActivity" }),
|
|
61
|
+
).toThrow();
|
|
62
|
+
expect(() =>
|
|
63
|
+
android({ apk: "x.apk", package: "com.example", activity: ".MainActivity", width: NaN }),
|
|
64
|
+
).toThrow();
|
|
65
|
+
expect(() =>
|
|
66
|
+
android({ apk: "spectest/tests/app.apk", package: "com.example", activity: ".MainActivity" }),
|
|
67
|
+
).toThrow("invalidate snapshots");
|
|
68
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { serviceGroup } from "../index.js";
|
|
4
|
+
import { resolveExistingProjectPath } from "../project-files.js";
|
|
5
|
+
import { androidApp, type AndroidApp, type AndroidRecordingOptions } from "../android.js";
|
|
6
|
+
|
|
7
|
+
export interface AndroidOptions {
|
|
8
|
+
/** Project-relative path to an APK built for the worker's native ABI. */
|
|
9
|
+
apk: string;
|
|
10
|
+
/** Android application id, for example com.example.app. */
|
|
11
|
+
package: string;
|
|
12
|
+
/** Launch activity, for example .MainActivity. */
|
|
13
|
+
activity: string;
|
|
14
|
+
/** Android image; default is the tested Android 14 arm64-only redroid image. */
|
|
15
|
+
image?: string;
|
|
16
|
+
/** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
|
|
17
|
+
toolsImage?: string;
|
|
18
|
+
/** Display defaults to Pixel 8 dimensions: 1080 × 2400, at 420 logical dpi. */
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
dpi?: number;
|
|
22
|
+
/** Show tap markers in replay and enable native physical-touch feedback. Default true. */
|
|
23
|
+
showTouches?: boolean;
|
|
24
|
+
recording?: false | AndroidRecordingOptions;
|
|
25
|
+
dependsOn?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
|
|
29
|
+
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
30
|
+
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
31
|
+
ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
|
|
32
|
+
RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/** Native Android, Appium and software recording, all inside the environment VM. */
|
|
36
|
+
export function android(options: AndroidOptions) {
|
|
37
|
+
if (
|
|
38
|
+
!options.apk ||
|
|
39
|
+
!/^[\w]+(?:\.[\w]+)+$/.test(options.package) ||
|
|
40
|
+
!/^[\w.$]+$/.test(options.activity)
|
|
41
|
+
)
|
|
42
|
+
throw new Error("android(): apk, package and activity are required");
|
|
43
|
+
const apk = posix.normalize(options.apk);
|
|
44
|
+
if (posix.isAbsolute(apk) || apk.startsWith("../") || apk.startsWith("spectest/tests/"))
|
|
45
|
+
throw new Error(
|
|
46
|
+
"android(): apk must be project-relative and outside spectest/tests so app changes invalidate snapshots",
|
|
47
|
+
);
|
|
48
|
+
const width = options.width ?? 1080,
|
|
49
|
+
height = options.height ?? 2400,
|
|
50
|
+
dpi = options.dpi ?? 420;
|
|
51
|
+
for (const value of [width, height, dpi])
|
|
52
|
+
if (!Number.isInteger(value) || value < 1 || value > 4096)
|
|
53
|
+
throw new Error("android(): invalid display dimensions/density");
|
|
54
|
+
return serviceGroup({
|
|
55
|
+
primary: "tools",
|
|
56
|
+
services: (g) => ({
|
|
57
|
+
device: {
|
|
58
|
+
image: {
|
|
59
|
+
type: "registry" as const,
|
|
60
|
+
reference:
|
|
61
|
+
options.image ??
|
|
62
|
+
"redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
|
|
63
|
+
},
|
|
64
|
+
privileged: true,
|
|
65
|
+
// Android's netd ignores resolv.conf. Use Docker's per-container DNS,
|
|
66
|
+
// which resolves service aliases and forwards to the in-VM resolver.
|
|
67
|
+
args: [
|
|
68
|
+
`androidboot.redroid_width=${width}`,
|
|
69
|
+
`androidboot.redroid_height=${height}`,
|
|
70
|
+
`androidboot.redroid_dpi=${dpi}`,
|
|
71
|
+
"androidboot.redroid_gpu_mode=guest",
|
|
72
|
+
"androidboot.use_memfd=1",
|
|
73
|
+
"androidboot.redroid_net_ndns=1",
|
|
74
|
+
"androidboot.redroid_net_dns1=127.0.0.11",
|
|
75
|
+
],
|
|
76
|
+
ports: [5555],
|
|
77
|
+
dependsOn: options.dependsOn,
|
|
78
|
+
readyCheck: {
|
|
79
|
+
type: "exec" as const,
|
|
80
|
+
command: 'test "$(getprop sys.boot_completed)" = 1',
|
|
81
|
+
timeoutSecs: 180,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
tools: {
|
|
85
|
+
image: options.toolsImage
|
|
86
|
+
? { type: "registry" as const, reference: options.toolsImage }
|
|
87
|
+
: { type: "dockerfile" as const, content: ANDROID_TOOLS_DOCKERFILE },
|
|
88
|
+
command: "node /spectest-android.mjs",
|
|
89
|
+
files: [
|
|
90
|
+
{
|
|
91
|
+
path: "/spectest-android.mjs",
|
|
92
|
+
content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
volumes: [
|
|
96
|
+
{
|
|
97
|
+
source: resolveExistingProjectPath(apk, "Android APK"),
|
|
98
|
+
target: "/spectest/app.apk",
|
|
99
|
+
readOnly: true,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
inputs: [apk],
|
|
103
|
+
env: {
|
|
104
|
+
ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
|
|
105
|
+
ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
|
|
106
|
+
ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
|
|
107
|
+
},
|
|
108
|
+
ports: [4723, 4725],
|
|
109
|
+
readyCheck: { type: "http" as const, port: 4725, path: "/ready", timeoutSecs: 240 },
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
helpers: ({ name }): AndroidApp =>
|
|
113
|
+
androidApp({
|
|
114
|
+
name,
|
|
115
|
+
endpoint: `http://${name}.internal:4723`,
|
|
116
|
+
recorderEndpoint: `http://${name}.internal:4725`,
|
|
117
|
+
serial: `${name}-device.internal:5555`,
|
|
118
|
+
package: options.package,
|
|
119
|
+
recording: options.recording ?? {},
|
|
120
|
+
showTouches: options.showTouches ?? true,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/components/index.ts
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
1
2
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
2
3
|
// a container). Owns:
|
|
3
4
|
//
|
|
@@ -775,6 +776,11 @@ function ensureLocalBuildkitd(): Promise<boolean> {
|
|
|
775
776
|
}
|
|
776
777
|
|
|
777
778
|
async function bringUpLocalBuildkitd(): Promise<boolean> {
|
|
779
|
+
if (existsSync("/run/spectest-sticky/consumer.json")) {
|
|
780
|
+
const ready = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 30_000);
|
|
781
|
+
if (ready.code !== 0) throw new Error(`sticky BuildKit unavailable: ${ready.stderr}`);
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
778
784
|
const paths = await imageCachePaths();
|
|
779
785
|
if (!paths) return false;
|
|
780
786
|
const state = paths.root;
|
|
@@ -3764,6 +3770,7 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
|
3764
3770
|
// ────────────────────────────────────────────────────────────────────────
|
|
3765
3771
|
|
|
3766
3772
|
interface RunResult {
|
|
3773
|
+
androidRecordings: AndroidRecording[];
|
|
3767
3774
|
status: "passed" | "failed";
|
|
3768
3775
|
durationMs: number;
|
|
3769
3776
|
log: string;
|
|
@@ -5290,6 +5297,8 @@ function captureConsole(chunks: LineSink): () => void {
|
|
|
5290
5297
|
|
|
5291
5298
|
async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
5292
5299
|
const start = Date.now();
|
|
5300
|
+
const nativeAndroid = androidScope(testCase.id, start);
|
|
5301
|
+
let androidRecordings: AndroidRecording[] = [];
|
|
5293
5302
|
const chunks: string[] = [];
|
|
5294
5303
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
5295
5304
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -5482,7 +5491,8 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5482
5491
|
return term;
|
|
5483
5492
|
};
|
|
5484
5493
|
|
|
5485
|
-
|
|
5494
|
+
// Share the case clock with native video timestamps and other artifacts.
|
|
5495
|
+
startRecording(start);
|
|
5486
5496
|
const restoreFetch = installFetchWrapper();
|
|
5487
5497
|
const fetchScope = createInstrumentationScope();
|
|
5488
5498
|
|
|
@@ -5597,6 +5607,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5597
5607
|
openTerminal: recordedOpenTerminal,
|
|
5598
5608
|
browser: trackedOpenBrowser,
|
|
5599
5609
|
mobile: trackedOpenMobile,
|
|
5610
|
+
android: nativeAndroid.open,
|
|
5600
5611
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
5601
5612
|
mcp: openMcp,
|
|
5602
5613
|
testName: testCase.name,
|
|
@@ -5624,10 +5635,10 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5624
5635
|
|
|
5625
5636
|
// Result events are gathered inside finally (after the final browser
|
|
5626
5637
|
// drains) so we hoist these out of the try/catch.
|
|
5627
|
-
let outcome: { status: "passed" | "failed"; error?: RunResult["error"] };
|
|
5638
|
+
let outcome: { status: "passed" | "failed"; error?: RunResult["error"] } = { status: "passed" };
|
|
5628
5639
|
try {
|
|
5629
5640
|
const value = await Promise.race([
|
|
5630
|
-
Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
|
|
5641
|
+
Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
|
|
5631
5642
|
timedOut,
|
|
5632
5643
|
]);
|
|
5633
5644
|
// Stash the return value so child cases — which fork from the snapshot
|
|
@@ -5642,6 +5653,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5642
5653
|
};
|
|
5643
5654
|
} finally {
|
|
5644
5655
|
if (timer) clearTimeout(timer);
|
|
5656
|
+
nativeAndroid.deactivate();
|
|
5645
5657
|
RECORDING_EXEC = undefined;
|
|
5646
5658
|
INTERCEPTORS.endScope();
|
|
5647
5659
|
fetchScope.active = false;
|
|
@@ -5664,6 +5676,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5664
5676
|
}
|
|
5665
5677
|
}
|
|
5666
5678
|
for (const s of sessions) s.markClosed();
|
|
5679
|
+
androidRecordings = await nativeAndroid.finish();
|
|
5680
|
+
const captureErrors = androidRecordings.flatMap(r => r.error ? [r.error] : []);
|
|
5681
|
+
if (captureErrors.length && outcome.status === "passed") outcome = { status: "failed", error: { message: captureErrors.join("\n") } };
|
|
5667
5682
|
}
|
|
5668
5683
|
const durationMs = Date.now() - start;
|
|
5669
5684
|
// Capture each service's log delta (the lines THIS case added beyond its
|
|
@@ -5719,6 +5734,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5719
5734
|
}
|
|
5720
5735
|
return {
|
|
5721
5736
|
status: outcome.status,
|
|
5737
|
+
androidRecordings,
|
|
5722
5738
|
durationMs,
|
|
5723
5739
|
log: chunks.join(""),
|
|
5724
5740
|
events,
|
|
@@ -5994,6 +6010,7 @@ function explainEvalExportError(code: string, message: string): string {
|
|
|
5994
6010
|
|
|
5995
6011
|
async function evalCode(code: string): Promise<EvalResult> {
|
|
5996
6012
|
const start = Date.now();
|
|
6013
|
+
const nativeAndroid = androidScope("eval", start, { record: false });
|
|
5997
6014
|
const chunks: string[] = [];
|
|
5998
6015
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
5999
6016
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -6138,6 +6155,11 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6138
6155
|
|
|
6139
6156
|
const ctx: TestContext<undefined> = {
|
|
6140
6157
|
...base,
|
|
6158
|
+
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
6159
|
+
// the snippet's module evaluation. Enter the eval scope at the ctx.fetch
|
|
6160
|
+
// call itself so top-level awaits and imported helpers get wrapped values
|
|
6161
|
+
// without enabling instrumentation for unrelated background/fake fetches.
|
|
6162
|
+
fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
|
|
6141
6163
|
// Wraps its result (no recorder under eval, so no provenance — but the
|
|
6142
6164
|
// wrapped type is honest at runtime, so `.unwrap()` works).
|
|
6143
6165
|
exec: execInServiceWrapped as unknown as TestContext<undefined>["exec"],
|
|
@@ -6145,6 +6167,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6145
6167
|
openTerminal: evalOpenTerminal,
|
|
6146
6168
|
browser: trackedOpenBrowser,
|
|
6147
6169
|
mobile: trackedOpenMobile,
|
|
6170
|
+
android: nativeAndroid.open,
|
|
6148
6171
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
6149
6172
|
mcp: openMcp,
|
|
6150
6173
|
testName: "eval",
|
|
@@ -6205,6 +6228,7 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6205
6228
|
}
|
|
6206
6229
|
}
|
|
6207
6230
|
for (const s of sessions) s.markClosed();
|
|
6231
|
+
await nativeAndroid.finish();
|
|
6208
6232
|
if (filePath) {
|
|
6209
6233
|
fs.unlink(filePath).catch(() => {
|
|
6210
6234
|
/* best-effort cleanup */
|
|
@@ -6795,6 +6819,8 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6795
6819
|
|
|
6796
6820
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
6797
6821
|
// strings, so they travel in params rather than a URL path.
|
|
6822
|
+
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
6823
|
+
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
6798
6824
|
replayChunk: async (params) => {
|
|
6799
6825
|
const caseId = requireString(params, "caseId", "caseId");
|
|
6800
6826
|
const gz = REPLAY_BUNDLES.get(caseId);
|