@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.
- package/dist/components/android-recording-state.mjs +28 -0
- package/dist/components/android-runtime.mjs +7 -14
- package/dist/components/android-trust.mjs +126 -0
- package/dist/components/android.d.ts +2 -1
- package/dist/components/android.js +29 -2
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/package.json +1 -1
- package/src/components/android-recording-state.mjs +28 -0
- package/src/components/android-runtime.mjs +7 -14
- package/src/components/android-trust.mjs +126 -0
- package/src/components/android-trust.test.ts +113 -0
- package/src/components/android.test.ts +61 -0
- package/src/components/android.ts +30 -2
- package/src/components/index.ts +1 -1
|
@@ -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("
|
|
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
|
-
|
|
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
|
|
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";
|
package/dist/components/index.js
CHANGED
|
@@ -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
|
@@ -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("
|
|
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
|
-
|
|
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
|
},
|
package/src/components/index.ts
CHANGED
|
@@ -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";
|