@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.
@@ -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,74 @@
1
+ import { type AndroidApp, type AndroidRecordingOptions } from "../android.js";
2
+ export interface AndroidOptions {
3
+ /** Project-relative path to an APK built for the worker's native ABI. */
4
+ apk: string;
5
+ /** Android application id, for example com.example.app. */
6
+ package: string;
7
+ /** Launch activity, for example .MainActivity. */
8
+ activity: string;
9
+ /** Android image; default is the tested Android 14 arm64-only redroid image. */
10
+ image?: string;
11
+ /** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
12
+ toolsImage?: string;
13
+ /** Display defaults to Pixel 8 dimensions: 1080 × 2400, at 420 logical dpi. */
14
+ width?: number;
15
+ height?: number;
16
+ dpi?: number;
17
+ /** Show tap markers in replay and enable native physical-touch feedback. Default true. */
18
+ showTouches?: boolean;
19
+ recording?: false | AndroidRecordingOptions;
20
+ dependsOn?: string[];
21
+ }
22
+ export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN 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/*\nRUN 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*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN 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\n";
23
+ /** Native Android, Appium and software recording, all inside the environment VM. */
24
+ export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
25
+ device: {
26
+ image: {
27
+ type: "registry";
28
+ reference: string;
29
+ };
30
+ privileged: true;
31
+ args: string[];
32
+ ports: number[];
33
+ dependsOn: string[] | undefined;
34
+ readyCheck: {
35
+ type: "exec";
36
+ command: string;
37
+ timeoutSecs: number;
38
+ };
39
+ };
40
+ tools: {
41
+ image: {
42
+ type: "registry";
43
+ reference: string;
44
+ content?: undefined;
45
+ } | {
46
+ type: "dockerfile";
47
+ content: string;
48
+ reference?: undefined;
49
+ };
50
+ command: string;
51
+ files: {
52
+ path: string;
53
+ content: string;
54
+ }[];
55
+ volumes: {
56
+ source: string;
57
+ target: string;
58
+ readOnly: true;
59
+ }[];
60
+ inputs: string[];
61
+ env: {
62
+ ANDROID_SERIAL: string;
63
+ ANDROID_ACTIVITY: string;
64
+ ANDROID_SHOW_TOUCHES: string;
65
+ };
66
+ ports: number[];
67
+ readyCheck: {
68
+ type: "http";
69
+ port: number;
70
+ path: string;
71
+ timeoutSecs: number;
72
+ };
73
+ };
74
+ }, AndroidApp, "tools">;
@@ -0,0 +1,92 @@
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 } from "../android.js";
6
+ export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
7
+ 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/*
8
+ 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*
9
+ ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
10
+ 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
11
+ `;
12
+ /** Native Android, Appium and software recording, all inside the environment VM. */
13
+ export function android(options) {
14
+ if (!options.apk ||
15
+ !/^[\w]+(?:\.[\w]+)+$/.test(options.package) ||
16
+ !/^[\w.$]+$/.test(options.activity))
17
+ throw new Error("android(): apk, package and activity are required");
18
+ const apk = posix.normalize(options.apk);
19
+ if (posix.isAbsolute(apk) || apk.startsWith("../") || apk.startsWith("spectest/tests/"))
20
+ throw new Error("android(): apk must be project-relative and outside spectest/tests so app changes invalidate snapshots");
21
+ const width = options.width ?? 1080, height = options.height ?? 2400, dpi = options.dpi ?? 420;
22
+ for (const value of [width, height, dpi])
23
+ if (!Number.isInteger(value) || value < 1 || value > 4096)
24
+ throw new Error("android(): invalid display dimensions/density");
25
+ return serviceGroup({
26
+ primary: "tools",
27
+ services: (g) => ({
28
+ device: {
29
+ image: {
30
+ type: "registry",
31
+ reference: options.image ??
32
+ "redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
33
+ },
34
+ privileged: true,
35
+ // Android's netd ignores resolv.conf. Use Docker's per-container DNS,
36
+ // which resolves service aliases and forwards to the in-VM resolver.
37
+ args: [
38
+ `androidboot.redroid_width=${width}`,
39
+ `androidboot.redroid_height=${height}`,
40
+ `androidboot.redroid_dpi=${dpi}`,
41
+ "androidboot.redroid_gpu_mode=guest",
42
+ "androidboot.use_memfd=1",
43
+ "androidboot.redroid_net_ndns=1",
44
+ "androidboot.redroid_net_dns1=127.0.0.11",
45
+ ],
46
+ ports: [5555],
47
+ dependsOn: options.dependsOn,
48
+ readyCheck: {
49
+ type: "exec",
50
+ command: 'test "$(getprop sys.boot_completed)" = 1',
51
+ timeoutSecs: 180,
52
+ },
53
+ },
54
+ tools: {
55
+ image: options.toolsImage
56
+ ? { type: "registry", reference: options.toolsImage }
57
+ : { type: "dockerfile", content: ANDROID_TOOLS_DOCKERFILE },
58
+ command: "node /spectest-android.mjs",
59
+ files: [
60
+ {
61
+ path: "/spectest-android.mjs",
62
+ content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
63
+ },
64
+ ],
65
+ volumes: [
66
+ {
67
+ source: resolveExistingProjectPath(apk, "Android APK"),
68
+ target: "/spectest/app.apk",
69
+ readOnly: true,
70
+ },
71
+ ],
72
+ inputs: [apk],
73
+ env: {
74
+ ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
75
+ ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
76
+ ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
77
+ },
78
+ ports: [4723, 4725],
79
+ readyCheck: { type: "http", port: 4725, path: "/ready", timeoutSecs: 240 },
80
+ },
81
+ }),
82
+ helpers: ({ name }) => androidApp({
83
+ name,
84
+ endpoint: `http://${name}.internal:4723`,
85
+ recorderEndpoint: `http://${name}.internal:4725`,
86
+ serial: `${name}-device.internal:5555`,
87
+ package: options.package,
88
+ recording: options.recording ?? {},
89
+ showTouches: options.showTouches ?? true,
90
+ }),
91
+ });
92
+ }
@@ -15,3 +15,4 @@ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
15
15
  export { okta, type OktaOptions } from "./okta.js";
16
16
  export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
17
17
  export { electron, type ElectronOptions } from "./electron.js";
18
+ export { android, type AndroidOptions } from "./android.js";
@@ -24,3 +24,4 @@ export { microsoft } from "./microsoft.js";
24
24
  export { okta } from "./okta.js";
25
25
  export { replayFake, } from "./replayFake.js";
26
26
  export { electron } from "./electron.js";
27
+ export { android } from "./android.js";
package/dist/daemon.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } 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
  //
@@ -488,6 +489,12 @@ function ensureLocalBuildkitd() {
488
489
  return _localBuilder;
489
490
  }
490
491
  async function bringUpLocalBuildkitd() {
492
+ if (existsSync("/run/spectest-sticky/consumer.json")) {
493
+ const ready = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 30_000);
494
+ if (ready.code !== 0)
495
+ throw new Error(`sticky BuildKit unavailable: ${ready.stderr}`);
496
+ return true;
497
+ }
491
498
  const paths = await imageCachePaths();
492
499
  if (!paths)
493
500
  return false;
@@ -4369,6 +4376,8 @@ function captureConsole(chunks) {
4369
4376
  }
4370
4377
  async function runOne(testCase) {
4371
4378
  const start = Date.now();
4379
+ const nativeAndroid = androidScope(testCase.id, start);
4380
+ let androidRecordings = [];
4372
4381
  const chunks = [];
4373
4382
  const origStdout = process.stdout.write.bind(process.stdout);
4374
4383
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -4522,7 +4531,8 @@ async function runOne(testCase) {
4522
4531
  });
4523
4532
  return term;
4524
4533
  };
4525
- startRecording();
4534
+ // Share the case clock with native video timestamps and other artifacts.
4535
+ startRecording(start);
4526
4536
  const restoreFetch = installFetchWrapper();
4527
4537
  const fetchScope = createInstrumentationScope();
4528
4538
  // Look up the parent's stored return value (if any). The parent ran in
@@ -4633,6 +4643,7 @@ async function runOne(testCase) {
4633
4643
  openTerminal: recordedOpenTerminal,
4634
4644
  browser: trackedOpenBrowser,
4635
4645
  mobile: trackedOpenMobile,
4646
+ android: nativeAndroid.open,
4636
4647
  desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
4637
4648
  mcp: openMcp,
4638
4649
  testName: testCase.name,
@@ -4654,10 +4665,10 @@ async function runOne(testCase) {
4654
4665
  });
4655
4666
  // Result events are gathered inside finally (after the final browser
4656
4667
  // drains) so we hoist these out of the try/catch.
4657
- let outcome;
4668
+ let outcome = { status: "passed" };
4658
4669
  try {
4659
4670
  const value = await Promise.race([
4660
- Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
4671
+ Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
4661
4672
  timedOut,
4662
4673
  ]);
4663
4674
  // Stash the return value so child cases — which fork from the snapshot
@@ -4675,6 +4686,7 @@ async function runOne(testCase) {
4675
4686
  finally {
4676
4687
  if (timer)
4677
4688
  clearTimeout(timer);
4689
+ nativeAndroid.deactivate();
4678
4690
  RECORDING_EXEC = undefined;
4679
4691
  INTERCEPTORS.endScope();
4680
4692
  fetchScope.active = false;
@@ -4699,6 +4711,10 @@ async function runOne(testCase) {
4699
4711
  }
4700
4712
  for (const s of sessions)
4701
4713
  s.markClosed();
4714
+ androidRecordings = await nativeAndroid.finish();
4715
+ const captureErrors = androidRecordings.flatMap(r => r.error ? [r.error] : []);
4716
+ if (captureErrors.length && outcome.status === "passed")
4717
+ outcome = { status: "failed", error: { message: captureErrors.join("\n") } };
4702
4718
  }
4703
4719
  const durationMs = Date.now() - start;
4704
4720
  // Capture each service's log delta (the lines THIS case added beyond its
@@ -4757,6 +4773,7 @@ async function runOne(testCase) {
4757
4773
  }
4758
4774
  return {
4759
4775
  status: outcome.status,
4776
+ androidRecordings,
4760
4777
  durationMs,
4761
4778
  log: chunks.join(""),
4762
4779
  events,
@@ -4987,6 +5004,7 @@ function explainEvalExportError(code, message) {
4987
5004
  }
4988
5005
  async function evalCode(code) {
4989
5006
  const start = Date.now();
5007
+ const nativeAndroid = androidScope("eval", start, { record: false });
4990
5008
  const chunks = [];
4991
5009
  const origStdout = process.stdout.write.bind(process.stdout);
4992
5010
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -5106,6 +5124,11 @@ async function evalCode(code) {
5106
5124
  const base = await spectestContext();
5107
5125
  const ctx = {
5108
5126
  ...base,
5127
+ // Bun does not propagate the import() caller's AsyncLocalStorage into
5128
+ // the snippet's module evaluation. Enter the eval scope at the ctx.fetch
5129
+ // call itself so top-level awaits and imported helpers get wrapped values
5130
+ // without enabling instrumentation for unrelated background/fake fetches.
5131
+ fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
5109
5132
  // Wraps its result (no recorder under eval, so no provenance — but the
5110
5133
  // wrapped type is honest at runtime, so `.unwrap()` works).
5111
5134
  exec: execInServiceWrapped,
@@ -5113,6 +5136,7 @@ async function evalCode(code) {
5113
5136
  openTerminal: evalOpenTerminal,
5114
5137
  browser: trackedOpenBrowser,
5115
5138
  mobile: trackedOpenMobile,
5139
+ android: nativeAndroid.open,
5116
5140
  desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
5117
5141
  mcp: openMcp,
5118
5142
  testName: "eval",
@@ -5173,6 +5197,7 @@ async function evalCode(code) {
5173
5197
  }
5174
5198
  for (const s of sessions)
5175
5199
  s.markClosed();
5200
+ await nativeAndroid.finish();
5176
5201
  if (filePath) {
5177
5202
  fs.unlink(filePath).catch(() => {
5178
5203
  /* best-effort cleanup */
@@ -5676,6 +5701,8 @@ export function harnessMethods(state) {
5676
5701
  },
5677
5702
  // One chunk of a parked replay bundle. Case ids are arbitrary user
5678
5703
  // strings, so they travel in params rather than a URL path.
5704
+ androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
5705
+ releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
5679
5706
  replayChunk: async (params) => {
5680
5707
  const caseId = requireString(params, "caseId", "caseId");
5681
5708
  const gz = REPLAY_BUNDLES.get(caseId);
@@ -33,12 +33,13 @@
33
33
  * transports. Two tables would each enforce the rule and together break it.
34
34
  */
35
35
  import net from "node:net";
36
+ import { initializeStickyConsumer, STICKY_CAPABILITY } from "./sticky-consumer";
36
37
  import { METHODS } from "../daemon";
37
38
  import { codeOf } from "./methods";
38
39
  import { PROTOCOL_VERSION, decodeFrame, describeThrow, encodeFrame, fail, ok, } from "./protocol";
39
40
  /** Capabilities this build announces. Empty is fine — the slot is what
40
41
  * matters, so a later release has something to negotiate against. */
41
- const CAPABILITIES = [];
42
+ const CAPABILITIES = [STICKY_CAPABILITY];
42
43
  /** Methods this build can serve. Everything else is answered with an
43
44
  * error naming the method, which is far easier to diagnose than a
44
45
  * request that hangs. */
@@ -46,6 +47,10 @@ export function handlers(sdkVersion, onShutdown, methods = METHODS) {
46
47
  return {
47
48
  // Everything the environment actually does.
48
49
  ...methods,
50
+ stickyInitialize: async (params) => {
51
+ await initializeStickyConsumer(params);
52
+ return { initialized: true };
53
+ },
49
54
  // Transport-level methods, which the method table has no business
50
55
  // knowing about: they concern this connection, not this environment.
51
56
  // Declared after the spread so the table can never shadow them — a
@@ -0,0 +1,28 @@
1
+ export declare const STICKY_CAPABILITY = "sticky-disks-v1";
2
+ export declare const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
3
+ export declare const STICKY_RUNTIME = "/run/spectest-sticky";
4
+ export interface StickyConsumerConfig {
5
+ path: string;
6
+ capacityBytes: number;
7
+ headroomBytes: number;
8
+ cacheTargetBytes: number;
9
+ buildkitTargetBytes: number;
10
+ compatibility: string;
11
+ }
12
+ declare function exec(program: string, args: string[], timeout?: number): Promise<string>;
13
+ export declare function validateStickyConfig(config: StickyConsumerConfig): void;
14
+ export declare function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void>;
15
+ /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
16
+ * Only called after runtime containers/volumes have been removed on the
17
+ * isolated publication clone. Never removes files behind the daemons' backs.
18
+ */
19
+ export declare function collectStickyCache(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
20
+ freeBytes: number;
21
+ totalBytes: number;
22
+ }>): Promise<void>;
23
+ /** Only on an isolated maintenance clone; never on the live ready environment. */
24
+ export declare function prepareStickyConsumer(artifact?: boolean): Promise<{
25
+ prepared: true;
26
+ freeBytes: number;
27
+ }>;
28
+ export {};