@specific.dev/spectest 0.124.0 → 0.126.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,28 @@
1
+ // scrcpy opens its output before receiving codec metadata or a video frame.
2
+ // Only the patched recorder's first-frame signal makes an immediate stop safe.
3
+ export const FIRST_VIDEO_FRAME = "SPECTEST_FIRST_VIDEO_FRAME";
4
+ export const RECORDER_CAPABILITY = "spectest-first-frame-v1";
5
+
6
+ export function requireRecorderCapability(version) {
7
+ if (!version.includes(RECORDER_CAPABILITY)) {
8
+ throw new Error("Android toolsImage needs scrcpy with the spectest-first-frame-v1 patch; rebuild it from ANDROID_TOOLS_DOCKERFILE");
9
+ }
10
+ }
11
+
12
+ export function observeRecorderOutput(record, chunk) {
13
+ record.log = (record.log + chunk).slice(-16000);
14
+ if (!record.started && record.log.includes(FIRST_VIDEO_FRAME)) {
15
+ record.started = true;
16
+ record.startedAtMs = Date.now();
17
+ }
18
+ }
19
+
20
+ export async function waitForRecorderFrame(record, timeoutMs = 10000) {
21
+ const deadline = Date.now() + timeoutMs;
22
+ while (true) {
23
+ if (record.error) throw new Error(`${record.error}: ${record.log}`);
24
+ if (record.started) return { startedAtMs: record.startedAtMs };
25
+ if (Date.now() >= deadline) throw new Error(`Recorder first video frame timed out: ${record.log}`);
26
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())));
27
+ }
28
+ }
@@ -1,8 +1,10 @@
1
1
  // Runs inside the Android tools container, in the same snapshotted VM.
2
2
  import http from "node:http";
3
+ import { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } from "./android-recording-state.mjs";
3
4
  import { spawn, execFile } from "node:child_process";
4
5
  import { promisify } from "node:util";
5
6
  import { mkdir, stat, open, rm } from "node:fs/promises";
7
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
8
  const exec = promisify(execFile);
7
9
  const serial = process.env.ANDROID_SERIAL;
8
10
  const directory = "/recordings";
@@ -28,6 +30,7 @@ async function bootstrap() {
28
30
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
31
  );
30
32
  }
33
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
34
  await adb("install", "-r", "/spectest/app.apk");
32
35
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
36
  for (const setting of [
@@ -72,6 +75,7 @@ bootstrap().catch((error) => {
72
75
  });
73
76
 
74
77
  async function startRecording(body) {
78
+ requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
75
79
  if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
76
80
  if (recordings.has(body.id)) throw new Error("recording already exists");
77
81
  if ([...recordings.values()].some((r) => !r.stopped))
@@ -119,19 +123,13 @@ async function startRecording(body) {
119
123
  record.error = error.message;
120
124
  resolve(-1);
121
125
  });
122
- child.on("exit", (code) => {
126
+ child.on("close", (code) => {
123
127
  if (!record.stopped) record.error = `Recorder exited early (${code})`;
124
128
  resolve(code);
125
129
  });
126
130
  });
127
131
  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
- });
132
+ output.on("data", (b) => observeRecorderOutput(record, b));
135
133
  record.guard = setInterval(async () => {
136
134
  if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
137
135
  record.error = "Recording exceeded 256 MiB";
@@ -139,12 +137,7 @@ async function startRecording(body) {
139
137
  }
140
138
  }, 1000);
141
139
  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}`);
140
+ return await waitForRecorderFrame(record);
148
141
  } catch (error) {
149
142
  clearInterval(record.guard);
150
143
  record.stopped = true;
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -19,7 +19,7 @@ export interface AndroidOptions {
19
19
  recording?: false | AndroidRecordingOptions;
20
20
  dependsOn?: string[];
21
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";
22
+ export declare const ANDROID_TOOLS_DOCKERFILE: string;
23
23
  /** Native Android, Appium and software recording, all inside the environment VM. */
24
24
  export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
25
25
  device: {
@@ -60,6 +60,7 @@ export declare function android(options: AndroidOptions): import("../index.js").
60
60
  inputs: string[];
61
61
  env: {
62
62
  ANDROID_SERIAL: string;
63
+ ANDROID_CA_PATH: string;
63
64
  ANDROID_ACTIVITY: string;
64
65
  ANDROID_SHOW_TOUCHES: string;
65
66
  };
@@ -3,9 +3,27 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
7
+ // Instrument the pinned recorder at the point its first real video packet is
8
+ // retained. This follows the MP4 header and survives an immediate SIGINT.
9
+ const recorderPatch = String.raw `from pathlib import Path
10
+ p = Path('/tmp/scrcpy-3.3.1/app/src/recorder.c')
11
+ s = p.read_text()
12
+ a = ' int64_t pts_origin = AV_NOPTS_VALUE;'
13
+ b = ' video_pkt_previous = video_pkt;'
14
+ assert s.count(a) == 1 and s.count(b) == 1
15
+ s = s.replace(a, a + '\n bool spectest_first_frame = true;')
16
+ s = s.replace(b, b + '\n if (spectest_first_frame) {\n LOGI("SPECTEST_FIRST_VIDEO_FRAME");\n spectest_first_frame = false;\n }')
17
+ p.write_text(s)
18
+ p = Path('/tmp/scrcpy-3.3.1/app/src/version.c')
19
+ s = p.read_text()
20
+ a = 'scrcpy_print_version(void) {'
21
+ assert s.count(a) == 1
22
+ p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
23
+ const shellQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
6
24
  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*
25
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 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/*
26
+ 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 && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && 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
27
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
10
28
  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
29
  `;
@@ -61,6 +79,14 @@ export function android(options) {
61
79
  path: "/spectest-android.mjs",
62
80
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
63
81
  },
82
+ {
83
+ path: "/android-recording-state.mjs",
84
+ content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
85
+ },
86
+ {
87
+ path: "/spectest-android-trust.mjs",
88
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
89
+ },
64
90
  ],
65
91
  volumes: [
66
92
  {
@@ -72,6 +98,7 @@ export function android(options) {
72
98
  inputs: [apk],
73
99
  env: {
74
100
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
101
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
75
102
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
76
103
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
77
104
  },
@@ -15,4 +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 ReplayFakeContext, type ReplayHandler, type ReplayKey, type WebSocketKey, type ReplayWebSocketMessage, type CassetteWebSocket, type CassetteWebSocketMessage, type WebSocketTiming, type ReplayRequest, type MatchKey, type JsonValue, 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";
18
+ export { android, ANDROID_TOOLS_DOCKERFILE, type AndroidOptions } from "./android.js";
@@ -24,4 +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";
27
+ export { android, ANDROID_TOOLS_DOCKERFILE } from "./android.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.124.0",
3
+ "version": "0.126.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,28 @@
1
+ // scrcpy opens its output before receiving codec metadata or a video frame.
2
+ // Only the patched recorder's first-frame signal makes an immediate stop safe.
3
+ export const FIRST_VIDEO_FRAME = "SPECTEST_FIRST_VIDEO_FRAME";
4
+ export const RECORDER_CAPABILITY = "spectest-first-frame-v1";
5
+
6
+ export function requireRecorderCapability(version) {
7
+ if (!version.includes(RECORDER_CAPABILITY)) {
8
+ throw new Error("Android toolsImage needs scrcpy with the spectest-first-frame-v1 patch; rebuild it from ANDROID_TOOLS_DOCKERFILE");
9
+ }
10
+ }
11
+
12
+ export function observeRecorderOutput(record, chunk) {
13
+ record.log = (record.log + chunk).slice(-16000);
14
+ if (!record.started && record.log.includes(FIRST_VIDEO_FRAME)) {
15
+ record.started = true;
16
+ record.startedAtMs = Date.now();
17
+ }
18
+ }
19
+
20
+ export async function waitForRecorderFrame(record, timeoutMs = 10000) {
21
+ const deadline = Date.now() + timeoutMs;
22
+ while (true) {
23
+ if (record.error) throw new Error(`${record.error}: ${record.log}`);
24
+ if (record.started) return { startedAtMs: record.startedAtMs };
25
+ if (Date.now() >= deadline) throw new Error(`Recorder first video frame timed out: ${record.log}`);
26
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())));
27
+ }
28
+ }
@@ -1,8 +1,10 @@
1
1
  // Runs inside the Android tools container, in the same snapshotted VM.
2
2
  import http from "node:http";
3
+ import { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } from "./android-recording-state.mjs";
3
4
  import { spawn, execFile } from "node:child_process";
4
5
  import { promisify } from "node:util";
5
6
  import { mkdir, stat, open, rm } from "node:fs/promises";
7
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
8
  const exec = promisify(execFile);
7
9
  const serial = process.env.ANDROID_SERIAL;
8
10
  const directory = "/recordings";
@@ -28,6 +30,7 @@ async function bootstrap() {
28
30
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
31
  );
30
32
  }
33
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
34
  await adb("install", "-r", "/spectest/app.apk");
32
35
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
36
  for (const setting of [
@@ -72,6 +75,7 @@ bootstrap().catch((error) => {
72
75
  });
73
76
 
74
77
  async function startRecording(body) {
78
+ requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
75
79
  if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
76
80
  if (recordings.has(body.id)) throw new Error("recording already exists");
77
81
  if ([...recordings.values()].some((r) => !r.stopped))
@@ -119,19 +123,13 @@ async function startRecording(body) {
119
123
  record.error = error.message;
120
124
  resolve(-1);
121
125
  });
122
- child.on("exit", (code) => {
126
+ child.on("close", (code) => {
123
127
  if (!record.stopped) record.error = `Recorder exited early (${code})`;
124
128
  resolve(code);
125
129
  });
126
130
  });
127
131
  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
- });
132
+ output.on("data", (b) => observeRecorderOutput(record, b));
135
133
  record.guard = setInterval(async () => {
136
134
  if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
137
135
  record.error = "Recording exceeded 256 MiB";
@@ -139,12 +137,7 @@ async function startRecording(body) {
139
137
  }
140
138
  }, 1000);
141
139
  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}`);
140
+ return await waitForRecorderFrame(record);
148
141
  } catch (error) {
149
142
  clearInterval(record.guard);
150
143
  record.stopped = true;
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ANDROID_TRUST_SCRIPT, installAndroidTrust } from "./android-trust.mjs";
6
+
7
+ test("Android CA provisioning requires confirmed installation and supports root ADB restart", async () => {
8
+ const calls: string[][] = [];
9
+ let rooted = false;
10
+ const adb = async (...args: string[]) => {
11
+ calls.push(args);
12
+ if (args[0] === "root") rooted = true;
13
+ if (args.includes("su")) throw new Error("no su");
14
+ return { stdout: args.includes("id") ? (rooted ? "0\n" : "2000\n") : "" };
15
+ };
16
+ await expect(installAndroidTrust(adb, "/fixture/ca.crt", async () => ({ stdout: "1234abcd\n" })))
17
+ .rejects.toThrow("did not confirm success");
18
+ expect(calls.findIndex(args => args[0] === "root")).toBeLessThan(calls.findIndex(args => args[0] === "push"));
19
+ expect(calls).toContainEqual(["wait-for-device"]);
20
+ expect(calls.at(-1)).toEqual(["shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh"]);
21
+ });
22
+
23
+ test("invalid CA data fails before accessing Android", async () => {
24
+ let called = false;
25
+ await expect(installAndroidTrust(async () => { called = true; }, "/fixture/ca.crt", async () => ({ stdout: "not-a-certificate" })))
26
+ .rejects.toThrow("invalid CA subject hash");
27
+ expect(called).toBe(false);
28
+ });
29
+
30
+ const canMount = process.platform === "linux" && Bun.spawnSync(["sh", "-c", "command -v unshare >/dev/null && unshare -Urnm true"], { stderr: "ignore" }).exitCode === 0;
31
+ if (process.env.SPECTEST_REQUIRE_ANDROID_TRUST_MOUNTS === "1" && !canMount) {
32
+ throw new Error("Android trust CI requires Linux user/mount namespaces (unshare -Urnm); refusing to skip mount coverage");
33
+ }
34
+
35
+ // Exercise real bind mounts and private zygote/child mount namespaces.
36
+ // This is intentionally more than an assertion about the generated script.
37
+ test.skipIf(!canMount)("Android trust preserves roots and hash collisions, reaches private app namespaces and is idempotent", async () => {
38
+ const directory = mkdtempSync(join(tmpdir(), "spectest-android-mounts-"));
39
+ for (const name of ["system", "apex", "bin"]) mkdirSync(join(directory, name));
40
+ writeFileSync(join(directory, "apex", "1234abcd.0"), "public root with the same subject hash\n");
41
+ writeFileSync(join(directory, "apex", "deadbeef.0"), "another public root\n");
42
+ writeFileSync(join(directory, "spectest-ca.crt"), "the environment CA\n");
43
+ writeFileSync(join(directory, "bin", "getenforce"), "#!/bin/sh\necho Disabled\n", { mode: 0o755 });
44
+ // Linux procps calls these fields lowercase; Android toybox uses uppercase.
45
+ writeFileSync(join(directory, "bin", "ps"), `#!/bin/sh
46
+ set -eu
47
+ snapshot=$(/bin/ps -A -o pid,ppid)
48
+ if [ ! -e '${directory}/race.triggered' ]; then
49
+ touch '${directory}/race.triggered'
50
+ kill -USR1 $(cat '${directory}/zygote.pid')
51
+ while [ ! -e '${directory}/late.ready' ]; do sleep 0.01; done
52
+ fi
53
+ # The late app is deliberately absent from this process-list snapshot. It
54
+ # must inherit the parent's trust even though it gets its own namespace.
55
+ printf '%s\\n' "$snapshot"
56
+ `, { mode: 0o755 });
57
+ writeFileSync(join(directory, "bin", "pidof"), `#!/bin/sh\ncat '${directory}/zygote.pid'\n`, { mode: 0o755 });
58
+ writeFileSync(join(directory, "trust.sh"), ANDROID_TRUST_SCRIPT
59
+ .replaceAll("/system/etc/security/cacerts", `${directory}/system`)
60
+ .replaceAll("/apex/com.android.conscrypt/cacerts", `${directory}/apex`)
61
+ .replaceAll("/data/local/tmp", directory));
62
+ writeFileSync(join(directory, "late.sh"), `#!/bin/sh
63
+ set -eu
64
+ mount --make-rprivate /
65
+ touch '${directory}/late.ready'
66
+ exec sleep 120
67
+ `);
68
+ writeFileSync(join(directory, "zygote.sh"), `#!/bin/sh
69
+ set -eu
70
+ mount --make-rprivate /
71
+ late_app() {
72
+ unshare -m sh '${directory}/late.sh' &
73
+ echo $! > '${directory}/late.pid'
74
+ }
75
+ trap late_app USR1
76
+ echo $$ > '${directory}/zygote.pid'
77
+ unshare -m sh -c 'mount --make-rprivate /; touch "${directory}/app.ready"; exec sleep 120' &
78
+ echo $! > '${directory}/app.pid'
79
+ while true; do wait || true; done
80
+ `);
81
+ writeFileSync(join(directory, "verify.sh"), `#!/bin/sh
82
+ set -eu
83
+ export PATH='${directory}/bin':"$PATH":/usr/sbin:/sbin
84
+ unshare -m sh '${directory}/zygote.sh' &
85
+ child=$!
86
+ trap 'kill "$child" $(cat "${directory}/app.pid" "${directory}/late.pid" 2>/dev/null) 2>/dev/null || true' EXIT
87
+ while [ ! -f '${directory}/app.ready' ]; do sleep 0.01; done
88
+ sh '${directory}/trust.sh' 1234abcd
89
+ for pid in $(cat '${directory}/zygote.pid' '${directory}/app.pid' '${directory}/late.pid'); do
90
+ for store in system apex; do
91
+ cmp '${directory}/spectest-ca.crt' "/proc/$pid/root${directory}/$store/1234abcd.1"
92
+ test "$(cat "/proc/$pid/root${directory}/$store/1234abcd.0")" = 'public root with the same subject hash'
93
+ test "$(cat "/proc/$pid/root${directory}/$store/deadbeef.0")" = 'another public root'
94
+ done
95
+ done
96
+ before=$(wc -l < /proc/$child/mountinfo)
97
+ sh '${directory}/trust.sh' 1234abcd
98
+ after=$(wc -l < /proc/$child/mountinfo)
99
+ test "$before" = "$after"
100
+ test ! -e '${directory}/spectest-system-cacerts/1234abcd.2'
101
+ # A populated shell trust store is insufficient without a usable zygote.
102
+ : > '${directory}/zygote.pid'
103
+ if sh '${directory}/trust.sh' 1234abcd; then exit 1; fi
104
+ `);
105
+ try {
106
+ const child = Bun.spawn(["timeout", "10s", "unshare", "-Urnm", "sh", join(directory, "verify.sh")], { stdout: "pipe", stderr: "pipe" });
107
+ const [stdout, stderr, status] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
108
+ expect({ status, stderr: stderr.replace("Android TLS: no zygote process found\n", "") }).toEqual({ status: 0, stderr: "" });
109
+ expect(stdout.match(/spectest-android-trust-ready/g)?.length).toBe(2);
110
+ } finally {
111
+ rmSync(directory, { recursive: true, force: true });
112
+ }
113
+ }, 20_000);
@@ -42,6 +42,7 @@ test("Android expands into two VM services with an APK input and private Appium/
42
42
  expect(tools.env?.ANDROID_SERIAL).toBe("phone-device.internal:5555");
43
43
  expect(tools.env?.ANDROID_ACTIVITY).toBe("com.example.app/.MainActivity");
44
44
  expect(tools.env?.ANDROID_SHOW_TOUCHES).toBe("1");
45
+ expect(tools.env?.ANDROID_CA_PATH).toBe(process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt");
45
46
  expect(tools.inputs).toContain("app.apk");
46
47
  expect(tools.volumes).toContainEqual({
47
48
  source: apk,
@@ -50,6 +51,7 @@ test("Android expands into two VM services with an APK input and private Appium/
50
51
  });
51
52
  expect(tools.readyCheck).toMatchObject({ type: "http", port: 4725 });
52
53
  expect(tools.files?.[0].content).toContain("--no-window");
54
+ expect(tools.files?.map((file: { path: string }) => file.path)).toContain("/spectest-android-trust.mjs");
53
55
  } finally {
54
56
  rmSync(dir, { recursive: true, force: true });
55
57
  }
@@ -66,3 +68,62 @@ test("bad dimensions and identifiers fail before image builds", () => {
66
68
  android({ apk: "spectest/tests/app.apk", package: "com.example", activity: ".MainActivity" }),
67
69
  ).toThrow("invalidate snapshots");
68
70
  });
71
+
72
+ test("recorder waits for a retained first frame before an immediate stop, including split output", async () => {
73
+ const { spawn } = await import("node:child_process");
74
+ const { observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
75
+ // Opening the output is deliberately separate from receiving the frame.
76
+ // This models scrcpy's static-screen path: only SIGINT flushes the last frame.
77
+ const child = spawn("node", ["-e", `
78
+ let retained = false;
79
+ process.on('SIGINT', () => {
80
+ process.stdout.write(retained ? 'VALID_VIDEO_WITH_TRAILER' : 'INVALID_EMPTY_VIDEO');
81
+ process.exit(retained ? 0 : 1);
82
+ });
83
+ process.stdin.once('data', () => {
84
+ retained = true;
85
+ process.stdout.write('SPECTEST_FIRST_VIDEO_');
86
+ setImmediate(() => process.stdout.write('FRAME\\n'));
87
+ });
88
+ process.stdout.write('Recording started to mp4 file: fixture.mp4\\n');
89
+ `]);
90
+ const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
91
+ let opened!: () => void;
92
+ const outputOpened = new Promise<void>(resolve => { opened = resolve; });
93
+ child.stdout.on("data", chunk => {
94
+ observeRecorderOutput(record, chunk);
95
+ if (record.log.includes("Recording started")) opened();
96
+ });
97
+ const closed = new Promise<number | null>(resolve => child.on("close", resolve));
98
+ try {
99
+ await outputOpened;
100
+ let ready = false;
101
+ const waiting = waitForRecorderFrame(record).then(value => { ready = true; return value; });
102
+ await new Promise(resolve => setImmediate(resolve));
103
+ expect(ready).toBe(false);
104
+ expect(record.started).toBe(false);
105
+ const frameRequested = Date.now();
106
+ child.stdin.write("deliver first frame");
107
+ const result = await waiting;
108
+ expect(result.startedAtMs).toBeGreaterThanOrEqual(frameRequested);
109
+ child.kill("SIGINT");
110
+ expect(await closed).toBe(0);
111
+ expect(record.log).toContain("VALID_VIDEO_WITH_TRAILER");
112
+ expect(record.log).not.toContain("INVALID_EMPTY_VIDEO");
113
+ } finally {
114
+ child.kill("SIGKILL");
115
+ await closed;
116
+ }
117
+ });
118
+
119
+ test("recorder rejects missing frames, early exit and unpatched custom tools explicitly", async () => {
120
+ const { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
121
+ expect(() => requireRecorderCapability("scrcpy 3.3.1\n")).toThrow("rebuild it from ANDROID_TOOLS_DOCKERFILE");
122
+ expect(() => requireRecorderCapability("scrcpy 3.3.1\nspectest-first-frame-v1\n")).not.toThrow();
123
+ const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
124
+ observeRecorderOutput(record, "Recording started to mp4 file: empty.mp4\n");
125
+ await expect(waitForRecorderFrame(record, 20)).rejects.toThrow("first video frame timed out");
126
+ record.error = "Recorder exited early (1)";
127
+ observeRecorderOutput(record, "SPECTEST_FIRST_VIDEO_FRAME\n");
128
+ await expect(waitForRecorderFrame(record)).rejects.toThrow("Recorder exited early");
129
+ });
@@ -3,6 +3,7 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp, type AndroidApp, type AndroidRecordingOptions } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
6
7
 
7
8
  export interface AndroidOptions {
8
9
  /** Project-relative path to an APK built for the worker's native ABI. */
@@ -25,9 +26,27 @@ export interface AndroidOptions {
25
26
  dependsOn?: string[];
26
27
  }
27
28
 
29
+ // Instrument the pinned recorder at the point its first real video packet is
30
+ // retained. This follows the MP4 header and survives an immediate SIGINT.
31
+ const recorderPatch = String.raw`from pathlib import Path
32
+ p = Path('/tmp/scrcpy-3.3.1/app/src/recorder.c')
33
+ s = p.read_text()
34
+ a = ' int64_t pts_origin = AV_NOPTS_VALUE;'
35
+ b = ' video_pkt_previous = video_pkt;'
36
+ assert s.count(a) == 1 and s.count(b) == 1
37
+ s = s.replace(a, a + '\n bool spectest_first_frame = true;')
38
+ s = s.replace(b, b + '\n if (spectest_first_frame) {\n LOGI("SPECTEST_FIRST_VIDEO_FRAME");\n spectest_first_frame = false;\n }')
39
+ p.write_text(s)
40
+ p = Path('/tmp/scrcpy-3.3.1/app/src/version.c')
41
+ s = p.read_text()
42
+ a = 'scrcpy_print_version(void) {'
43
+ assert s.count(a) == 1
44
+ p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
45
+ const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
46
+
28
47
  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*
48
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 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/*
49
+ 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 && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && 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
50
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
32
51
  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
52
  `;
@@ -91,6 +110,14 @@ export function android(options: AndroidOptions) {
91
110
  path: "/spectest-android.mjs",
92
111
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
93
112
  },
113
+ {
114
+ path: "/android-recording-state.mjs",
115
+ content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
116
+ },
117
+ {
118
+ path: "/spectest-android-trust.mjs",
119
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
120
+ },
94
121
  ],
95
122
  volumes: [
96
123
  {
@@ -102,6 +129,7 @@ export function android(options: AndroidOptions) {
102
129
  inputs: [apk],
103
130
  env: {
104
131
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
132
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
105
133
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
106
134
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
107
135
  },
@@ -111,4 +111,4 @@ export {
111
111
  } from "./replayFake.js";
112
112
  export { electron, type ElectronOptions } from "./electron.js";
113
113
 
114
- export { android, type AndroidOptions } from "./android.js";
114
+ export { android, ANDROID_TOOLS_DOCKERFILE, type AndroidOptions } from "./android.js";