@specific.dev/spectest 0.125.0 → 0.127.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.
@@ -8,6 +8,13 @@ export interface AndroidTarget {
8
8
  export interface AndroidStepDetails {
9
9
  device: string;
10
10
  action: "tap" | "fill" | "text" | "visible" | "wait" | "swipe" | "back" | "source" | "screenshot" | "execute";
11
+ /** Normalized screen bounds observed by this operation. */
12
+ bounds?: {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ };
11
18
  target?: AndroidTarget;
12
19
  point?: {
13
20
  x: number;
package/dist/android.d.ts CHANGED
@@ -24,10 +24,24 @@ export declare class AppiumError extends Error {
24
24
  readonly code: string;
25
25
  constructor(code: string, message: string);
26
26
  }
27
+ export declare const ANDROID_ASSERT: unique symbol;
28
+ export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
29
+ export interface AndroidAssertionResult {
30
+ sourceSeq?: number;
31
+ actual?: unknown;
32
+ error?: unknown;
33
+ }
27
34
  export interface AndroidElement {
28
- tap(): Promise<void>;
29
- fill(text: string): Promise<void>;
30
- text(): Promise<Wrapped<string>>;
35
+ [ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
36
+ tap(options?: {
37
+ timeoutMs?: number;
38
+ }): Promise<void>;
39
+ fill(text: string, options?: {
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
42
+ text(options?: {
43
+ timeoutMs?: number;
44
+ }): Promise<Wrapped<string>>;
31
45
  isDisplayed(): Promise<Wrapped<boolean>>;
32
46
  waitFor(options?: {
33
47
  state?: "visible" | "hidden";
package/dist/android.js CHANGED
@@ -42,6 +42,10 @@ async function session(app) {
42
42
  capabilities: {
43
43
  alwaysMatch: {
44
44
  platformName: "Android",
45
+ // Conditions are explicit waits. Do not stack a session-wide lookup
46
+ // wait or UiSelector's own 10s wait underneath their deadline.
47
+ timeouts: { implicit: 0 },
48
+ "appium:settings[waitForSelectorTimeout]": 0,
45
49
  "appium:automationName": "UiAutomator2",
46
50
  "appium:udid": app.serial,
47
51
  "appium:appPackage": app.package,
@@ -65,6 +69,7 @@ async function session(app) {
65
69
  }
66
70
  return pending;
67
71
  }
72
+ export const ANDROID_ASSERT = Symbol.for("spectest.androidAssert");
68
73
  const parked = new Map();
69
74
  let epoch = 0;
70
75
  /** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
@@ -155,13 +160,13 @@ export function androidScope(caseId, testStartedAt, options = {}) {
155
160
  list.push(marker);
156
161
  taps.set(app.name, list);
157
162
  }
158
- async function op(operation, fn) {
163
+ async function op(operation, fn, settled) {
159
164
  assertActive();
160
165
  const android = { device: app.name, ...operation };
161
166
  const title = androidStepTitle(android);
162
167
  const reservation = reserveEvent(), started = Date.now();
163
168
  try {
164
- const result = await fn();
169
+ const result = await fn(android);
165
170
  const seq = recordStep({
166
171
  kind: "android",
167
172
  title,
@@ -169,10 +174,11 @@ export function androidScope(caseId, testStartedAt, options = {}) {
169
174
  durationMs: Date.now() - started,
170
175
  status: "passed",
171
176
  }, reservation);
177
+ settled?.(seq);
172
178
  return wrap(result, seq);
173
179
  }
174
180
  catch (error) {
175
- recordStep({
181
+ const seq = recordStep({
176
182
  kind: "android",
177
183
  title,
178
184
  android,
@@ -180,9 +186,46 @@ export function androidScope(caseId, testStartedAt, options = {}) {
180
186
  status: "failed",
181
187
  error: String(error),
182
188
  }, reservation);
189
+ settled?.(seq);
183
190
  throw error;
184
191
  }
185
192
  }
193
+ async function bounds(path, details) {
194
+ delete details.bounds;
195
+ if (!captures.has(app.name))
196
+ return;
197
+ try {
198
+ if (!await command("GET", `${path}/displayed`))
199
+ return;
200
+ const screen = await command("GET", "/window/rect");
201
+ const rect = await command("GET", `${path}/rect`);
202
+ const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
203
+ if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0)
204
+ details.bounds = box;
205
+ }
206
+ catch { /* Replay decoration must not change the operation's result. */ }
207
+ }
208
+ const transient = (error) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
209
+ async function until(probe, label, timeoutMs = 10000) {
210
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
211
+ throw new Error("Invalid wait timeout");
212
+ const deadline = Date.now() + timeoutMs;
213
+ while (true) {
214
+ assertActive();
215
+ try {
216
+ const value = await probe();
217
+ if (value !== undefined)
218
+ return value;
219
+ }
220
+ catch (error) {
221
+ if (!transient(error))
222
+ throw error;
223
+ }
224
+ if (Date.now() >= deadline)
225
+ throw new Error(`Timed out waiting for ${label}`);
226
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
227
+ }
228
+ }
186
229
  const element = (using, value, target = androidTarget(using, value)) => {
187
230
  const label = androidTargetLabel(target);
188
231
  const find = async () => {
@@ -195,49 +238,102 @@ export function androidScope(caseId, testStartedAt, options = {}) {
195
238
  throw new Error("Appium returned no element id");
196
239
  return `/element/${encodeURIComponent(id)}`;
197
240
  };
198
- const displayed = async () => {
241
+ const visiblePath = async () => {
199
242
  try {
200
- return await command("GET", `${await find()}/displayed`);
243
+ const path = await find();
244
+ return await command("GET", `${path}/displayed`) ? path : undefined;
201
245
  }
202
246
  catch (error) {
203
- if (error instanceof AppiumError &&
204
- ["no such element", "stale element reference"].includes(error.code))
205
- return false;
247
+ if (transient(error))
248
+ return undefined;
206
249
  throw error;
207
250
  }
208
251
  };
252
+ const ready = (timeoutMs) => until(async () => {
253
+ const path = await visiblePath();
254
+ return path && await command("GET", `${path}/enabled`) ? path : undefined;
255
+ }, `${label} to be visible and enabled`, timeoutMs);
209
256
  return {
210
- async tap() {
257
+ async tap(options = {}) {
211
258
  await op({ action: "tap", target }, async () => {
212
- const path = await find();
259
+ const path = await ready(options.timeoutMs);
213
260
  const marker = await tapMarker(path);
261
+ // Never retry an input dispatch: a transport failure may occur
262
+ // after the app has received it.
214
263
  await command("POST", `${path}/click`, {});
215
264
  saveTap(marker);
216
265
  });
217
266
  },
218
- async fill(text) {
219
- await op({ action: "fill", target }, async () => {
220
- const path = await find();
267
+ async fill(text, options = {}) {
268
+ await op({ action: "fill", target }, async (details) => {
269
+ const path = await ready(options.timeoutMs);
221
270
  await command("POST", `${path}/clear`, {});
222
271
  await command("POST", `${path}/value`, { text });
272
+ await bounds(path, details);
223
273
  });
224
274
  },
225
- text: () => op({ action: "text", target }, async () => command("GET", `${await find()}/text`)),
226
- isDisplayed: () => op({ action: "visible", target }, displayed),
275
+ text: (options = {}) => op({ action: "text", target }, async (details) => {
276
+ return until(async () => {
277
+ const path = await find();
278
+ const value = await command("GET", `${path}/text`);
279
+ await bounds(path, details);
280
+ return value;
281
+ }, label, options.timeoutMs);
282
+ }),
283
+ isDisplayed: () => op({ action: "visible", target }, async (details) => {
284
+ const path = await visiblePath();
285
+ if (path)
286
+ await bounds(path, details);
287
+ return !!path;
288
+ }),
227
289
  async waitFor(options = {}) {
228
- const timeout = options.timeoutMs ?? 10000;
229
- if (!Number.isFinite(timeout) || timeout < 0)
230
- throw new Error("Invalid wait timeout");
231
- await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
232
- const end = Date.now() + timeout;
233
- do {
234
- if ((await displayed()) === (options.state !== "hidden"))
235
- return;
236
- await new Promise((resolve) => setTimeout(resolve, 100));
237
- } while (Date.now() < end);
238
- throw new Error(`Timed out waiting for ${label}`);
290
+ await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
291
+ await until(async () => {
292
+ const path = await visiblePath();
293
+ if (path)
294
+ await bounds(path, details);
295
+ else
296
+ delete details.bounds;
297
+ return (!!path === (options.state !== "hidden")) ? true : undefined;
298
+ }, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
239
299
  });
240
300
  },
301
+ async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
302
+ let sourceSeq, actual, error;
303
+ const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
304
+ try {
305
+ await op({ action: visibility ? "wait" : "text", target,
306
+ ...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") } : {}) }, async (details) => {
307
+ await until(async () => {
308
+ let path;
309
+ let matches;
310
+ if (visibility) {
311
+ path = await visiblePath();
312
+ actual = !!path;
313
+ matches = matcher === "toBeVisible" ? !!path : !path;
314
+ }
315
+ else {
316
+ path = await find();
317
+ const text = await command("GET", `${path}/text`);
318
+ actual = text;
319
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
320
+ matches = expected instanceof RegExp
321
+ ? new RegExp(expected.source, expected.flags).test(text)
322
+ : matcher === "toContainText" ? normalize(text).includes(normalize(expected)) : normalize(text) === normalize(expected);
323
+ }
324
+ if (path)
325
+ await bounds(path, details);
326
+ else
327
+ delete details.bounds;
328
+ return matches !== negated ? true : undefined;
329
+ }, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
330
+ }, seq => { sourceSeq = seq; });
331
+ }
332
+ catch (cause) {
333
+ error = cause;
334
+ }
335
+ return { sourceSeq, actual, error };
336
+ },
241
337
  };
242
338
  };
243
339
  return {
@@ -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,5 +1,6 @@
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";
@@ -74,6 +75,7 @@ bootstrap().catch((error) => {
74
75
  });
75
76
 
76
77
  async function startRecording(body) {
78
+ requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
77
79
  if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
78
80
  if (recordings.has(body.id)) throw new Error("recording already exists");
79
81
  if ([...recordings.values()].some((r) => !r.stopped))
@@ -121,19 +123,13 @@ async function startRecording(body) {
121
123
  record.error = error.message;
122
124
  resolve(-1);
123
125
  });
124
- child.on("exit", (code) => {
126
+ child.on("close", (code) => {
125
127
  if (!record.stopped) record.error = `Recorder exited early (${code})`;
126
128
  resolve(code);
127
129
  });
128
130
  });
129
131
  for (const output of [child.stdout, child.stderr])
130
- output.on("data", (b) => {
131
- record.log = (record.log + b).slice(-16000);
132
- if (!record.started && record.log.includes("Recording started")) {
133
- record.started = true;
134
- record.startedAtMs = Date.now();
135
- }
136
- });
132
+ output.on("data", (b) => observeRecorderOutput(record, b));
137
133
  record.guard = setInterval(async () => {
138
134
  if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
139
135
  record.error = "Recording exceeded 256 MiB";
@@ -141,12 +137,7 @@ async function startRecording(body) {
141
137
  }
142
138
  }, 1000);
143
139
  try {
144
- for (let i = 0; i < 200; i++) {
145
- if (record.error) throw new Error(`${record.error}: ${record.log}`);
146
- if (record.started) return { startedAtMs: record.startedAtMs };
147
- await sleep(50);
148
- }
149
- throw new Error(`Recorder startup timed out: ${record.log}`);
140
+ return await waitForRecorderFrame(record);
150
141
  } catch (error) {
151
142
  clearInterval(record.guard);
152
143
  record.stopped = true;
@@ -6,7 +6,7 @@ export interface AndroidOptions {
6
6
  package: string;
7
7
  /** Launch activity, for example .MainActivity. */
8
8
  activity: string;
9
- /** Android image; default is the tested Android 14 arm64-only redroid image. */
9
+ /** Android image; default is the pinned Android 16 64-bit-only redroid image. */
10
10
  image?: string;
11
11
  /** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
12
12
  toolsImage?: string;
@@ -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 openssl 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: {
@@ -4,9 +4,26 @@ import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp } from "../android.js";
6
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("'", "'\\''") + "'";
7
24
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
8
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl 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/*
9
- 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*
10
27
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
11
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
12
29
  `;
@@ -30,7 +47,7 @@ export function android(options) {
30
47
  image: {
31
48
  type: "registry",
32
49
  reference: options.image ??
33
- "redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
50
+ "redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
34
51
  },
35
52
  privileged: true,
36
53
  // Android's netd ignores resolv.conf. Use Docker's per-container DNS,
@@ -62,6 +79,10 @@ export function android(options) {
62
79
  path: "/spectest-android.mjs",
63
80
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
64
81
  },
82
+ {
83
+ path: "/android-recording-state.mjs",
84
+ content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
85
+ },
65
86
  {
66
87
  path: "/spectest-android-trust.mjs",
67
88
  content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
@@ -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/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { InjectRule, SignConfig } from "./fake-upstream.js";
4
4
  export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
5
5
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
6
6
  import type { Phone, PhoneOptions } from "./phone.js";
7
- import type { Android, AndroidApp } from "./android.js";
7
+ import { type Android, type AndroidApp, type AndroidElement } from "./android.js";
8
8
  import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
9
9
  export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
10
10
  export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
@@ -1959,9 +1959,29 @@ export interface BrowserAssertion extends BrowserMatchers {
1959
1959
  /** Negate every matcher (retries until the negated condition holds). */
1960
1960
  not: BrowserMatchers;
1961
1961
  }
1962
+ export declare function expect(actual: AndroidElement, message?: string): AndroidAssertion;
1962
1963
  export declare function expect(actual: Locator, message?: string): LocatorAssertion;
1963
1964
  export declare function expect(actual: Browser, message?: string): BrowserAssertion;
1964
1965
  export declare function expect(actual: Provenanced, message?: string): Expectation;
1966
+ /** Retrying native Android assertions. Timeout is in milliseconds (default 10 s;
1967
+ * zero performs one observation). Each assertion records one Android step. */
1968
+ export interface AndroidMatchers {
1969
+ toBeVisible(options?: {
1970
+ timeout?: number;
1971
+ }): Promise<void>;
1972
+ toBeHidden(options?: {
1973
+ timeout?: number;
1974
+ }): Promise<void>;
1975
+ toHaveText(expected: string | RegExp, options?: {
1976
+ timeout?: number;
1977
+ }): Promise<void>;
1978
+ toContainText(expected: string | RegExp, options?: {
1979
+ timeout?: number;
1980
+ }): Promise<void>;
1981
+ }
1982
+ export interface AndroidAssertion extends AndroidMatchers {
1983
+ not: AndroidMatchers;
1984
+ }
1965
1985
  /**
1966
1986
  * Assert on a value with **no provenance** — a computed number, a frame read
1967
1987
  * off a raw `WebSocket` you opened yourself, anything that never flowed from a
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { validateServiceNetwork } from "./harness/service-network.js";
2
2
  import { stripVTControlCharacters } from "node:util";
3
+ import { ANDROID_ASSERT } from "./android.js";
3
4
  import { resolveEntrypoints } from "./interactive.js";
4
5
  // Spectest SDK. The user's single `spectest/index.ts` calls
5
6
  // `defineEnvironment({ name, services })` once, defines test cases via
@@ -663,12 +664,35 @@ export class ExpectationError extends Error {
663
664
  }
664
665
  }
665
666
  export function expect(actual, message) {
667
+ if (actual && typeof actual === "object" && ANDROID_ASSERT in actual)
668
+ return buildAndroidAssertion(actual, message);
666
669
  if (isLocator(actual))
667
670
  return buildLocatorAssertion(actual, message);
668
671
  if (isBrowserSession(actual))
669
672
  return buildBrowserAssertion(actual, message);
670
673
  return expectValue(actual, message);
671
674
  }
675
+ function buildAndroidAssertion(element, message) {
676
+ const matchers = (negated) => {
677
+ const run = async (matcher, expected, timeout) => {
678
+ const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
679
+ const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
680
+ if (error)
681
+ error.cause = result.error;
682
+ recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
683
+ actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
684
+ if (error)
685
+ throw error;
686
+ };
687
+ return {
688
+ toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
689
+ toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
690
+ toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
691
+ toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
692
+ };
693
+ };
694
+ return Object.assign(matchers(false), { not: matchers(true) });
695
+ }
672
696
  function expectValue(actual, message) {
673
697
  // The first parameter is typed to the {@link Provenanced} family so a raw
674
698
  // value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.125.0",
3
+ "version": "0.127.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,121 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { androidApp, androidScope } from "./android.js";
3
+ import { expect as assert, ExpectationError } from "./index.js";
4
+ import { startRecording, stopRecording } from "./recorder.js";
5
+
6
+ const cleanups: (() => void)[] = [];
7
+ afterEach(() => { stopRecording(); cleanups.splice(0).forEach(fn => fn()); });
8
+
9
+ async function fixture() {
10
+ let finds = 0, clicks = 0, clears = 0, values = 0;
11
+ let missing = 0, invisible = 0, disabled = 0;
12
+ let text = "Saved", invalid = false, failClick = false;
13
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
14
+ const path = new URL(req.url).pathname;
15
+ const value = (value: unknown) => Response.json({ value });
16
+ const error = (error: string) => Response.json({ value: { error, message: error } }, { status: 404 });
17
+ if (path === "/recordings") return value({ startedAtMs: Date.now() });
18
+ if (path === "/session") return value({ sessionId: "test" });
19
+ if (path.endsWith("/element")) {
20
+ finds++;
21
+ if (invalid) return error("invalid selector");
22
+ if (missing-- > 0) return error("no such element");
23
+ return value({ "element-6066-11e4-a52e-4f735466cecf": `e${finds}` });
24
+ }
25
+ if (path.endsWith("/displayed")) return value(!(invisible-- > 0));
26
+ if (path.endsWith("/enabled")) return value(!(disabled-- > 0));
27
+ if (path.endsWith("/window/rect")) return value({ width: 100, height: 200 });
28
+ if (path.endsWith("/rect")) return value({ x: 10, y: 40, width: 80, height: 20 });
29
+ if (path.endsWith("/text")) return value(text);
30
+ if (path.endsWith("/clear")) { clears++; return value(null); }
31
+ if (path.endsWith("/value")) { values++; return value(null); }
32
+ if (path.endsWith("/click")) { clicks++; return failClick ? error("stale element reference") : value(null); }
33
+ return value(null);
34
+ } });
35
+ const endpoint = `http://127.0.0.1:${server.port}`;
36
+ const scope = androidScope(`test-${server.port}`, Date.now());
37
+ cleanups.push(() => { scope.deactivate(); server.stop(true); });
38
+ startRecording();
39
+ const phone = await scope.open(androidApp({ name: "phone", endpoint, recorderEndpoint: endpoint, serial: "device", package: "app", recording: {} }));
40
+ return { scope, phone, stats: () => ({ finds, clicks, clears, values }),
41
+ configure(options: { missing?: number; invisible?: number; disabled?: number; text?: string; invalid?: boolean; failClick?: boolean }) {
42
+ missing = options.missing ?? 0; invisible = options.invisible ?? 0; disabled = options.disabled ?? 0;
43
+ text = options.text ?? text; invalid = options.invalid ?? false; failClick = options.failClick ?? false;
44
+ } };
45
+ }
46
+
47
+ test("tap and fill wait for visible enabled elements, recording one step each", async () => {
48
+ const f = await fixture();
49
+ f.configure({ missing: 1, invisible: 1, disabled: 1 });
50
+ await f.phone.getByText("Save").tap({ timeoutMs: 1000 });
51
+ f.configure({ missing: 1 });
52
+ await f.phone.getById("draft").fill("private text", { timeoutMs: 1000 });
53
+ expect(f.stats()).toMatchObject({ clicks: 1, clears: 1, values: 1 });
54
+ const events = stopRecording();
55
+ expect(events).toHaveLength(2);
56
+ expect(events[1]).toMatchObject({ android: { action: "fill", bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
57
+ expect(JSON.stringify(events)).not.toContain("private text");
58
+ });
59
+
60
+ test("native assertions retry without emitting polling attempts and preserve provenance", async () => {
61
+ const f = await fixture();
62
+ f.configure({ missing: 2 });
63
+ await assert(f.phone.getByText("Saved"), "save completes").toBeVisible({ timeout: 1000 });
64
+ f.configure({ text: "Loading" });
65
+ const change = setTimeout(() => f.configure({ text: " Saved\n successfully " }), 150);
66
+ await assert(f.phone.getById("status")).toHaveText("Saved successfully", { timeout: 1000 });
67
+ clearTimeout(change);
68
+ await assert(f.phone.getById("status")).toContainText(/Saved/g);
69
+ await assert(f.phone.getById("status")).not.toHaveText("Loading");
70
+ f.configure({ missing: 10 });
71
+ await assert(f.phone.getById("gone")).toBeHidden({ timeout: 0 });
72
+ await assert(f.phone.getById("gone")).not.toBeVisible({ timeout: 0 });
73
+ const events = stopRecording();
74
+ const steps = events.filter(e => e.kind === "android");
75
+ const assertions = events.filter(e => e.kind === "assertion");
76
+ expect(steps).toHaveLength(6);
77
+ expect(assertions).toHaveLength(6);
78
+ for (let i = 0; i < steps.length; i++) expect(assertions[i]).toMatchObject({ sourceSeq: steps[i].seq, passed: true });
79
+ expect(steps[0]).toMatchObject({ android: { bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
80
+ });
81
+
82
+ test("timeouts and invalid selectors fail once, and dispatched clicks are never retried", async () => {
83
+ const f = await fixture();
84
+ f.configure({ missing: 100 });
85
+ await expect(assert(f.phone.getById("missing")).toBeVisible({ timeout: 0 })).rejects.toBeInstanceOf(ExpectationError);
86
+ expect(f.stats().finds).toBe(1);
87
+ f.configure({ invalid: true });
88
+ await expect(f.phone.getById("bad").tap()).rejects.toThrow("invalid selector");
89
+ expect(f.stats().finds).toBe(2);
90
+ f.configure({ failClick: true });
91
+ await expect(f.phone.getById("save").tap()).rejects.toThrow("stale element reference");
92
+ expect(f.stats().clicks).toBe(1);
93
+ const events = stopRecording();
94
+ expect(events.filter(e => e.kind === "android")).toHaveLength(3);
95
+ expect(events.find(e => e.kind === "assertion")).toMatchObject({ passed: false, sourceSeq: events[0].seq });
96
+ });
97
+
98
+ test("immediate visibility checks stay immediate; read waits and canceled waits stop", async () => {
99
+ const f = await fixture();
100
+ f.configure({ missing: 100 });
101
+ expect((await f.phone.getById("missing").isDisplayed()).unwrap()).toBe(false);
102
+ expect(f.stats().finds).toBe(1);
103
+ f.configure({ missing: 1 });
104
+ expect((await f.phone.getById("status").text({ timeoutMs: 500 })).unwrap()).toBe("Saved");
105
+ f.configure({ missing: 100 });
106
+ const pending = f.phone.getById("missing").waitFor();
107
+ setTimeout(() => f.scope.deactivate(), 30);
108
+ await expect(pending).rejects.toThrow("finished test");
109
+ });
110
+
111
+ test("a completed disappearance wait clears the earlier visible target, like browser replay", async () => {
112
+ const f = await fixture();
113
+ const hide = setTimeout(() => f.configure({ missing: 100 }), 50);
114
+ await assert(f.phone.getById("spinner")).toBeHidden({ timeout: 1000 });
115
+ clearTimeout(hide);
116
+ const events = stopRecording();
117
+ const step = events.find(e => e.kind === "android")!;
118
+ expect(step).toMatchObject({ android: { state: "hidden" }, status: "passed" });
119
+ expect(step.android?.bounds).toBeUndefined();
120
+ expect(f.stats().finds).toBeGreaterThan(1);
121
+ });
@@ -18,6 +18,8 @@ export interface AndroidStepDetails {
18
18
  | "source"
19
19
  | "screenshot"
20
20
  | "execute";
21
+ /** Normalized screen bounds observed by this operation. */
22
+ bounds?: { x: number; y: number; width: number; height: number };
21
23
  target?: AndroidTarget;
22
24
  point?: { x: number; y: number };
23
25
  direction?: "up" | "down" | "left" | "right";
@@ -57,6 +57,7 @@ describe("Android case lifecycle", () => {
57
57
  return Response.json({ value: { error: "stale element reference", message: "Gone" } }, { status: 404 });
58
58
  if (path.endsWith("/element"))
59
59
  return json({ value: { "element-6066-11e4-a52e-4f735466cecf": "element-1" } });
60
+ if (path.endsWith("/displayed") || path.endsWith("/enabled")) return json({ value: true });
60
61
  if (path.endsWith("/text")) return json({ value: "inherited draft" });
61
62
  if (path.endsWith("/window/rect")) return json({ value: { width: 1080, height: 2400 } });
62
63
  if (path.endsWith("/rect")) return json({ value: { x: 100, y: 400, width: 880, height: 160 } });
@@ -135,6 +136,8 @@ describe("Android case lifecycle", () => {
135
136
  const starts = requests.filter((r) => r.path === "/session");
136
137
  expect(starts).toHaveLength(1);
137
138
  expect(starts[0].body.capabilities.alwaysMatch).toMatchObject({
139
+ timeouts: { implicit: 0 },
140
+ "appium:settings[waitForSelectorTimeout]": 0,
138
141
  "appium:noReset": true,
139
142
  "appium:autoLaunch": false,
140
143
  "appium:newCommandTimeout": 0,
package/src/android.ts CHANGED
@@ -70,6 +70,10 @@ async function session(app: AndroidApp): Promise<string> {
70
70
  capabilities: {
71
71
  alwaysMatch: {
72
72
  platformName: "Android",
73
+ // Conditions are explicit waits. Do not stack a session-wide lookup
74
+ // wait or UiSelector's own 10s wait underneath their deadline.
75
+ timeouts: { implicit: 0 },
76
+ "appium:settings[waitForSelectorTimeout]": 0,
73
77
  "appium:automationName": "UiAutomator2",
74
78
  "appium:udid": app.serial,
75
79
  "appium:appPackage": app.package,
@@ -93,10 +97,14 @@ async function session(app: AndroidApp): Promise<string> {
93
97
  return pending;
94
98
  }
95
99
 
100
+ export const ANDROID_ASSERT: unique symbol = Symbol.for("spectest.androidAssert");
101
+ export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
102
+ export interface AndroidAssertionResult { sourceSeq?: number; actual?: unknown; error?: unknown }
96
103
  export interface AndroidElement {
97
- tap(): Promise<void>;
98
- fill(text: string): Promise<void>;
99
- text(): Promise<Wrapped<string>>;
104
+ [ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
105
+ tap(options?: { timeoutMs?: number }): Promise<void>;
106
+ fill(text: string, options?: { timeoutMs?: number }): Promise<void>;
107
+ text(options?: { timeoutMs?: number }): Promise<Wrapped<string>>;
100
108
  isDisplayed(): Promise<Wrapped<boolean>>;
101
109
  waitFor(options?: { state?: "visible" | "hidden"; timeoutMs?: number }): Promise<void>;
102
110
  }
@@ -227,14 +235,14 @@ export function androidScope(
227
235
  if (list.length < 10000) list.push(marker);
228
236
  taps.set(app.name, list);
229
237
  }
230
- async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: () => Promise<T>): Promise<Wrapped<T>> {
238
+ async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: (details: AndroidStepDetails) => Promise<T>, settled?: (seq: number | undefined) => void): Promise<Wrapped<T>> {
231
239
  assertActive();
232
240
  const android = { device: app.name, ...operation };
233
241
  const title = androidStepTitle(android);
234
242
  const reservation = reserveEvent(),
235
243
  started = Date.now();
236
244
  try {
237
- const result = await fn();
245
+ const result = await fn(android);
238
246
  const seq = recordStep(
239
247
  {
240
248
  kind: "android",
@@ -245,9 +253,10 @@ export function androidScope(
245
253
  },
246
254
  reservation,
247
255
  );
256
+ settled?.(seq);
248
257
  return wrap(result, seq) as Wrapped<T>;
249
258
  } catch (error) {
250
- recordStep(
259
+ const seq = recordStep(
251
260
  {
252
261
  kind: "android",
253
262
  title,
@@ -258,9 +267,35 @@ export function androidScope(
258
267
  },
259
268
  reservation,
260
269
  );
270
+ settled?.(seq);
261
271
  throw error;
262
272
  }
263
273
  }
274
+ async function bounds(path: string, details: AndroidStepDetails) {
275
+ delete details.bounds;
276
+ if (!captures.has(app.name)) return;
277
+ try {
278
+ if (!await command<boolean>("GET", `${path}/displayed`)) return;
279
+ const screen = await command<{ width: number; height: number }>("GET", "/window/rect");
280
+ const rect = await command<{ x: number; y: number; width: number; height: number }>("GET", `${path}/rect`);
281
+ const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
282
+ if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0) details.bounds = box;
283
+ } catch { /* Replay decoration must not change the operation's result. */ }
284
+ }
285
+ const transient = (error: unknown) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
286
+ async function until<T>(probe: () => Promise<T | undefined>, label: string, timeoutMs = 10000): Promise<T> {
287
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("Invalid wait timeout");
288
+ const deadline = Date.now() + timeoutMs;
289
+ while (true) {
290
+ assertActive();
291
+ try {
292
+ const value = await probe();
293
+ if (value !== undefined) return value;
294
+ } catch (error) { if (!transient(error)) throw error; }
295
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`);
296
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
297
+ }
298
+ }
264
299
  const element = (using: string, value: string, target: AndroidTarget = androidTarget(using, value)): AndroidElement => {
265
300
  const label = androidTargetLabel(target);
266
301
  const find = async () => {
@@ -272,49 +307,88 @@ export function androidScope(
272
307
  if (!id) throw new Error("Appium returned no element id");
273
308
  return `/element/${encodeURIComponent(id)}`;
274
309
  };
275
- const displayed = async () => {
310
+ const visiblePath = async () => {
276
311
  try {
277
- return await command<boolean>("GET", `${await find()}/displayed`);
278
- } catch (error) {
279
- if (
280
- error instanceof AppiumError &&
281
- ["no such element", "stale element reference"].includes(error.code)
282
- )
283
- return false;
284
- throw error;
285
- }
312
+ const path = await find();
313
+ return await command<boolean>("GET", `${path}/displayed`) ? path : undefined;
314
+ } catch (error) { if (transient(error)) return undefined; throw error; }
286
315
  };
316
+ const ready = (timeoutMs?: number) => until(async () => {
317
+ const path = await visiblePath();
318
+ return path && await command<boolean>("GET", `${path}/enabled`) ? path : undefined;
319
+ }, `${label} to be visible and enabled`, timeoutMs);
287
320
  return {
288
- async tap() {
321
+ async tap(options = {}) {
289
322
  await op({ action: "tap", target }, async () => {
290
- const path = await find();
323
+ const path = await ready(options.timeoutMs);
291
324
  const marker = await tapMarker(path);
325
+ // Never retry an input dispatch: a transport failure may occur
326
+ // after the app has received it.
292
327
  await command("POST", `${path}/click`, {});
293
328
  saveTap(marker);
294
329
  });
295
330
  },
296
- async fill(text) {
297
- await op({ action: "fill", target }, async () => {
298
- const path = await find();
331
+ async fill(text, options = {}) {
332
+ await op({ action: "fill", target }, async (details) => {
333
+ const path = await ready(options.timeoutMs);
299
334
  await command("POST", `${path}/clear`, {});
300
335
  await command("POST", `${path}/value`, { text });
336
+ await bounds(path, details);
301
337
  });
302
338
  },
303
- text: () =>
304
- op({ action: "text", target }, async () => command<string>("GET", `${await find()}/text`)),
305
- isDisplayed: () => op({ action: "visible", target }, displayed),
339
+ text: (options = {}) => op({ action: "text", target }, async (details) => {
340
+ return until(async () => {
341
+ const path = await find();
342
+ const value = await command<string>("GET", `${path}/text`);
343
+ await bounds(path, details);
344
+ return value;
345
+ }, label, options.timeoutMs);
346
+ }),
347
+ isDisplayed: () => op({ action: "visible", target }, async (details) => {
348
+ const path = await visiblePath();
349
+ if (path) await bounds(path, details);
350
+ return !!path;
351
+ }),
306
352
  async waitFor(options = {}) {
307
- const timeout = options.timeoutMs ?? 10000;
308
- if (!Number.isFinite(timeout) || timeout < 0) throw new Error("Invalid wait timeout");
309
- await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
310
- const end = Date.now() + timeout;
311
- do {
312
- if ((await displayed()) === (options.state !== "hidden")) return;
313
- await new Promise((resolve) => setTimeout(resolve, 100));
314
- } while (Date.now() < end);
315
- throw new Error(`Timed out waiting for ${label}`);
353
+ await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
354
+ await until(async () => {
355
+ const path = await visiblePath();
356
+ if (path) await bounds(path, details);
357
+ else delete details.bounds;
358
+ return (!!path === (options.state !== "hidden")) ? true : undefined;
359
+ }, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
316
360
  });
317
361
  },
362
+ async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
363
+ let sourceSeq: number | undefined, actual: unknown, error: unknown;
364
+ const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
365
+ try {
366
+ await op({ action: visibility ? "wait" : "text", target,
367
+ ...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") as "visible" | "hidden" } : {}) }, async (details) => {
368
+ await until(async () => {
369
+ let path: string | undefined;
370
+ let matches: boolean;
371
+ if (visibility) {
372
+ path = await visiblePath();
373
+ actual = !!path;
374
+ matches = matcher === "toBeVisible" ? !!path : !path;
375
+ } else {
376
+ path = await find();
377
+ const text = await command<string>("GET", `${path}/text`);
378
+ actual = text;
379
+ const normalize = (value: string) => value.replace(/\s+/g, " ").trim();
380
+ matches = expected instanceof RegExp
381
+ ? new RegExp(expected.source, expected.flags).test(text)
382
+ : matcher === "toContainText" ? normalize(text).includes(normalize(expected!)) : normalize(text) === normalize(expected!);
383
+ }
384
+ if (path) await bounds(path, details);
385
+ else delete details.bounds;
386
+ return matches !== negated ? true : undefined;
387
+ }, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
388
+ }, seq => { sourceSeq = seq; });
389
+ } catch (cause) { error = cause; }
390
+ return { sourceSeq, actual, error };
391
+ },
318
392
  };
319
393
  };
320
394
  return {
@@ -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,5 +1,6 @@
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";
@@ -74,6 +75,7 @@ bootstrap().catch((error) => {
74
75
  });
75
76
 
76
77
  async function startRecording(body) {
78
+ requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
77
79
  if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
78
80
  if (recordings.has(body.id)) throw new Error("recording already exists");
79
81
  if ([...recordings.values()].some((r) => !r.stopped))
@@ -121,19 +123,13 @@ async function startRecording(body) {
121
123
  record.error = error.message;
122
124
  resolve(-1);
123
125
  });
124
- child.on("exit", (code) => {
126
+ child.on("close", (code) => {
125
127
  if (!record.stopped) record.error = `Recorder exited early (${code})`;
126
128
  resolve(code);
127
129
  });
128
130
  });
129
131
  for (const output of [child.stdout, child.stderr])
130
- output.on("data", (b) => {
131
- record.log = (record.log + b).slice(-16000);
132
- if (!record.started && record.log.includes("Recording started")) {
133
- record.started = true;
134
- record.startedAtMs = Date.now();
135
- }
136
- });
132
+ output.on("data", (b) => observeRecorderOutput(record, b));
137
133
  record.guard = setInterval(async () => {
138
134
  if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
139
135
  record.error = "Recording exceeded 256 MiB";
@@ -141,12 +137,7 @@ async function startRecording(body) {
141
137
  }
142
138
  }, 1000);
143
139
  try {
144
- for (let i = 0; i < 200; i++) {
145
- if (record.error) throw new Error(`${record.error}: ${record.log}`);
146
- if (record.started) return { startedAtMs: record.startedAtMs };
147
- await sleep(50);
148
- }
149
- throw new Error(`Recorder startup timed out: ${record.log}`);
140
+ return await waitForRecorderFrame(record);
150
141
  } catch (error) {
151
142
  clearInterval(record.guard);
152
143
  record.stopped = true;
@@ -32,6 +32,7 @@ test("Android expands into two VM services with an APK input and private Appium/
32
32
  const env = JSON.parse(output);
33
33
  const device = env.services["phone-device"],
34
34
  tools = env.services.phone;
35
+ expect(device.image.reference).toBe("redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b");
35
36
  expect(device.privileged).toBe(true);
36
37
  expect(device.args).toContain("androidboot.redroid_width=1080");
37
38
  expect(device.args).toContain("androidboot.redroid_height=2400");
@@ -68,3 +69,62 @@ test("bad dimensions and identifiers fail before image builds", () => {
68
69
  android({ apk: "spectest/tests/app.apk", package: "com.example", activity: ".MainActivity" }),
69
70
  ).toThrow("invalidate snapshots");
70
71
  });
72
+
73
+ test("recorder waits for a retained first frame before an immediate stop, including split output", async () => {
74
+ const { spawn } = await import("node:child_process");
75
+ const { observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
76
+ // Opening the output is deliberately separate from receiving the frame.
77
+ // This models scrcpy's static-screen path: only SIGINT flushes the last frame.
78
+ const child = spawn("node", ["-e", `
79
+ let retained = false;
80
+ process.on('SIGINT', () => {
81
+ process.stdout.write(retained ? 'VALID_VIDEO_WITH_TRAILER' : 'INVALID_EMPTY_VIDEO');
82
+ process.exit(retained ? 0 : 1);
83
+ });
84
+ process.stdin.once('data', () => {
85
+ retained = true;
86
+ process.stdout.write('SPECTEST_FIRST_VIDEO_');
87
+ setImmediate(() => process.stdout.write('FRAME\\n'));
88
+ });
89
+ process.stdout.write('Recording started to mp4 file: fixture.mp4\\n');
90
+ `]);
91
+ const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
92
+ let opened!: () => void;
93
+ const outputOpened = new Promise<void>(resolve => { opened = resolve; });
94
+ child.stdout.on("data", chunk => {
95
+ observeRecorderOutput(record, chunk);
96
+ if (record.log.includes("Recording started")) opened();
97
+ });
98
+ const closed = new Promise<number | null>(resolve => child.on("close", resolve));
99
+ try {
100
+ await outputOpened;
101
+ let ready = false;
102
+ const waiting = waitForRecorderFrame(record).then(value => { ready = true; return value; });
103
+ await new Promise(resolve => setImmediate(resolve));
104
+ expect(ready).toBe(false);
105
+ expect(record.started).toBe(false);
106
+ const frameRequested = Date.now();
107
+ child.stdin.write("deliver first frame");
108
+ const result = await waiting;
109
+ expect(result.startedAtMs).toBeGreaterThanOrEqual(frameRequested);
110
+ child.kill("SIGINT");
111
+ expect(await closed).toBe(0);
112
+ expect(record.log).toContain("VALID_VIDEO_WITH_TRAILER");
113
+ expect(record.log).not.toContain("INVALID_EMPTY_VIDEO");
114
+ } finally {
115
+ child.kill("SIGKILL");
116
+ await closed;
117
+ }
118
+ });
119
+
120
+ test("recorder rejects missing frames, early exit and unpatched custom tools explicitly", async () => {
121
+ const { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
122
+ expect(() => requireRecorderCapability("scrcpy 3.3.1\n")).toThrow("rebuild it from ANDROID_TOOLS_DOCKERFILE");
123
+ expect(() => requireRecorderCapability("scrcpy 3.3.1\nspectest-first-frame-v1\n")).not.toThrow();
124
+ const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
125
+ observeRecorderOutput(record, "Recording started to mp4 file: empty.mp4\n");
126
+ await expect(waitForRecorderFrame(record, 20)).rejects.toThrow("first video frame timed out");
127
+ record.error = "Recorder exited early (1)";
128
+ observeRecorderOutput(record, "SPECTEST_FIRST_VIDEO_FRAME\n");
129
+ await expect(waitForRecorderFrame(record)).rejects.toThrow("Recorder exited early");
130
+ });
@@ -12,7 +12,7 @@ export interface AndroidOptions {
12
12
  package: string;
13
13
  /** Launch activity, for example .MainActivity. */
14
14
  activity: string;
15
- /** Android image; default is the tested Android 14 arm64-only redroid image. */
15
+ /** Android image; default is the pinned Android 16 64-bit-only redroid image. */
16
16
  image?: string;
17
17
  /** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
18
18
  toolsImage?: string;
@@ -26,9 +26,27 @@ export interface AndroidOptions {
26
26
  dependsOn?: string[];
27
27
  }
28
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
+
29
47
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
30
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl 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/*
31
- 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*
32
50
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
33
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
34
52
  `;
@@ -60,7 +78,7 @@ export function android(options: AndroidOptions) {
60
78
  type: "registry" as const,
61
79
  reference:
62
80
  options.image ??
63
- "redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
81
+ "redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
64
82
  },
65
83
  privileged: true,
66
84
  // Android's netd ignores resolv.conf. Use Docker's per-container DNS,
@@ -92,6 +110,10 @@ export function android(options: AndroidOptions) {
92
110
  path: "/spectest-android.mjs",
93
111
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
94
112
  },
113
+ {
114
+ path: "/android-recording-state.mjs",
115
+ content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
116
+ },
95
117
  {
96
118
  path: "/spectest-android-trust.mjs",
97
119
  content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
@@ -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";
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import type { InjectRule, SignConfig } from "./fake-upstream.js";
6
6
  export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
7
7
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
8
8
  import type { Phone, PhoneOptions } from "./phone.js";
9
- import type { Android, AndroidApp } from "./android.js";
9
+ import { ANDROID_ASSERT, type Android, type AndroidApp, type AndroidElement, type AndroidMatcher } from "./android.js";
10
10
  import { resolveEntrypoints, type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
11
11
  export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
12
12
  export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
@@ -3030,18 +3030,49 @@ export interface BrowserAssertion extends BrowserMatchers {
3030
3030
  // `expect(value)` the synchronous value matchers. The two object overloads are
3031
3031
  // listed first so they win over `Provenanced` (neither has `unwrap`); a
3032
3032
  // Locator and a Browser are structurally disjoint, so their order is free.
3033
+ export function expect(actual: AndroidElement, message?: string): AndroidAssertion;
3033
3034
  export function expect(actual: Locator, message?: string): LocatorAssertion;
3034
3035
  export function expect(actual: Browser, message?: string): BrowserAssertion;
3035
3036
  export function expect(actual: Provenanced, message?: string): Expectation;
3036
3037
  export function expect(
3037
- actual: Provenanced | Locator | Browser,
3038
+ actual: Provenanced | Locator | Browser | AndroidElement,
3038
3039
  message?: string,
3039
- ): Expectation | LocatorAssertion | BrowserAssertion {
3040
+ ): Expectation | LocatorAssertion | BrowserAssertion | AndroidAssertion {
3041
+ if (actual && typeof actual === "object" && ANDROID_ASSERT in actual) return buildAndroidAssertion(actual as AndroidElement, message);
3040
3042
  if (isLocator(actual)) return buildLocatorAssertion(actual, message);
3041
3043
  if (isBrowserSession(actual)) return buildBrowserAssertion(actual, message);
3042
3044
  return expectValue(actual as Provenanced, message);
3043
3045
  }
3044
3046
 
3047
+ /** Retrying native Android assertions. Timeout is in milliseconds (default 10 s;
3048
+ * zero performs one observation). Each assertion records one Android step. */
3049
+ export interface AndroidMatchers {
3050
+ toBeVisible(options?: { timeout?: number }): Promise<void>;
3051
+ toBeHidden(options?: { timeout?: number }): Promise<void>;
3052
+ toHaveText(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
3053
+ toContainText(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
3054
+ }
3055
+ export interface AndroidAssertion extends AndroidMatchers { not: AndroidMatchers }
3056
+ function buildAndroidAssertion(element: AndroidElement, message?: string): AndroidAssertion {
3057
+ const matchers = (negated: boolean): AndroidMatchers => {
3058
+ const run = async (matcher: AndroidMatcher, expected?: string | RegExp, timeout?: number) => {
3059
+ const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
3060
+ const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
3061
+ if (error) error.cause = result.error;
3062
+ recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
3063
+ actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
3064
+ if (error) throw error;
3065
+ };
3066
+ return {
3067
+ toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
3068
+ toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
3069
+ toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
3070
+ toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
3071
+ };
3072
+ };
3073
+ return Object.assign(matchers(false), { not: matchers(true) });
3074
+ }
3075
+
3045
3076
  function expectValue(actual: Provenanced, message?: string): Expectation {
3046
3077
  // The first parameter is typed to the {@link Provenanced} family so a raw
3047
3078
  // value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —