@specific.dev/spectest 0.126.0 → 0.128.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.
Files changed (46) hide show
  1. package/dist/android-step.d.ts +7 -0
  2. package/dist/android.d.ts +17 -3
  3. package/dist/android.js +122 -26
  4. package/dist/components/android.d.ts +1 -1
  5. package/dist/components/android.js +1 -1
  6. package/dist/components/k3s-coverage.d.ts +24 -0
  7. package/dist/components/k3s-coverage.js +259 -0
  8. package/dist/components/k3s-trust.d.ts +4 -2
  9. package/dist/components/k3s-trust.js +13 -0
  10. package/dist/components/k3s.js +31 -0
  11. package/dist/coverage/rust.rs +102 -0
  12. package/dist/coverage-bun.d.ts +10 -0
  13. package/dist/coverage-bun.js +32 -0
  14. package/dist/coverage-rust.d.ts +23 -0
  15. package/dist/coverage-rust.js +71 -0
  16. package/dist/coverage.d.ts +11 -5
  17. package/dist/coverage.js +70 -13
  18. package/dist/daemon.js +27 -2
  19. package/dist/harness/component-images.d.ts +1 -0
  20. package/dist/harness/component-images.js +4 -2
  21. package/dist/harness/coverage-target.d.ts +10 -0
  22. package/dist/harness/coverage-target.js +54 -0
  23. package/dist/index.d.ts +21 -1
  24. package/dist/index.js +34 -1
  25. package/package.json +1 -1
  26. package/src/android-actions.test.ts +121 -0
  27. package/src/android-step.ts +2 -0
  28. package/src/android.test.ts +3 -0
  29. package/src/android.ts +107 -33
  30. package/src/components/android.test.ts +1 -0
  31. package/src/components/android.ts +2 -2
  32. package/src/components/k3s-coverage.test.ts +160 -0
  33. package/src/components/k3s-coverage.ts +234 -0
  34. package/src/components/k3s-trust.ts +14 -2
  35. package/src/components/k3s.ts +26 -0
  36. package/src/coverage/rust.rs +102 -0
  37. package/src/coverage-bun.test.ts +103 -0
  38. package/src/coverage-bun.ts +40 -0
  39. package/src/coverage-rust.test.ts +74 -0
  40. package/src/coverage-rust.ts +79 -0
  41. package/src/coverage.test.ts +4 -4
  42. package/src/coverage.ts +74 -13
  43. package/src/daemon.ts +22 -2
  44. package/src/harness/component-images.ts +5 -2
  45. package/src/harness/coverage-target.ts +55 -0
  46. package/src/index.ts +44 -4
@@ -0,0 +1,54 @@
1
+ // Runtime adapters operate on this same context in Docker and nested runtimes.
2
+ // Counter baselines belong to an instance; source identity belongs to its image.
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions } from "../coverage.js";
6
+ import { applyCoverageDelta, readCoverageDir } from "./coverage.js";
7
+ export async function captureCoverageTarget(options) {
8
+ const { context: ctx, coverage, root, baseline } = options;
9
+ const adapters = coverageAdapters(coverage);
10
+ const written = new Set();
11
+ const delta = new Set();
12
+ const context = { ...ctx, async writeReport(name, content) {
13
+ const resolved = path.resolve(ctx.reportDir, name);
14
+ if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep))
15
+ throw new Error("coverage report escapes directory");
16
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
17
+ await fs.writeFile(resolved + ".tmp", content);
18
+ await fs.rename(resolved + ".tmp", resolved);
19
+ written.add(name);
20
+ } };
21
+ try {
22
+ ctx.signal.throwIfAborted();
23
+ const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
24
+ sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
25
+ } : undefined);
26
+ for (const adapter of adapters) {
27
+ if (adapter.collect || adapter.load)
28
+ throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
29
+ written.clear();
30
+ await adapter.capture?.(context);
31
+ if (adapter.reports === "delta")
32
+ for (const name of written)
33
+ delta.add(name);
34
+ }
35
+ if (baseline && options.inventoryAvailable !== false) {
36
+ const units = new Set();
37
+ for (const adapter of adapters)
38
+ for (const unit of await adapter.inventory?.(context) ?? [])
39
+ units.add(unit);
40
+ await context.writeReport("inventory.units", [...units].sort().join("\n"));
41
+ }
42
+ ctx.signal.throwIfAborted();
43
+ const capture = await readCoverageDir(ctx.service, ctx.reportDir);
44
+ if (baseline)
45
+ await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
46
+ capture.root = root;
47
+ attachCoverageSourceOptions(capture, policies);
48
+ const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
49
+ return { ...result, service: capture.service };
50
+ }
51
+ catch (error) {
52
+ return { service: ctx.service, reports: [], error: String(error) };
53
+ }
54
+ }
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
@@ -40,7 +41,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
40
41
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "./locator.js";
41
42
  import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
42
43
  import { describeUrlPattern } from "./url-match.js";
43
- import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
44
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
44
45
  import { resolveExistingProjectPath } from "./project-files.js";
45
46
  export { ca } from "./environment-ca.js";
46
47
  // Low-level ingress primitives + the framework lowering that the friendly
@@ -184,6 +185,15 @@ function expandServiceGroups(input) {
184
185
  // Rewrite relative dependsOn to final keys; a spread keeps function
185
186
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
186
187
  const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
188
+ // Component-owned nested containers keep their collection when callers
189
+ // add ordinary service coverage (for example browser coverage on k3s).
190
+ const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
191
+ if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
192
+ const extra = primaryOverrides.coverage;
193
+ final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
194
+ ...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
195
+ ] };
196
+ }
187
197
  if (final.dependsOn?.length) {
188
198
  final.dependsOn = final.dependsOn.map((d) => partKeys.has(d) ? naming.key(d) : d);
189
199
  }
@@ -663,12 +673,35 @@ export class ExpectationError extends Error {
663
673
  }
664
674
  }
665
675
  export function expect(actual, message) {
676
+ if (actual && typeof actual === "object" && ANDROID_ASSERT in actual)
677
+ return buildAndroidAssertion(actual, message);
666
678
  if (isLocator(actual))
667
679
  return buildLocatorAssertion(actual, message);
668
680
  if (isBrowserSession(actual))
669
681
  return buildBrowserAssertion(actual, message);
670
682
  return expectValue(actual, message);
671
683
  }
684
+ function buildAndroidAssertion(element, message) {
685
+ const matchers = (negated) => {
686
+ const run = async (matcher, expected, timeout) => {
687
+ const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
688
+ const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
689
+ if (error)
690
+ error.cause = result.error;
691
+ recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
692
+ actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
693
+ if (error)
694
+ throw error;
695
+ };
696
+ return {
697
+ toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
698
+ toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
699
+ toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
700
+ toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
701
+ };
702
+ };
703
+ return Object.assign(matchers(false), { not: matchers(true) });
704
+ }
672
705
  function expectValue(actual, message) {
673
706
  // The first parameter is typed to the {@link Provenanced} family so a raw
674
707
  // 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.126.0",
3
+ "version": "0.128.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 {
@@ -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");
@@ -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;
@@ -78,7 +78,7 @@ export function android(options: AndroidOptions) {
78
78
  type: "registry" as const,
79
79
  reference:
80
80
  options.image ??
81
- "redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
81
+ "redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
82
82
  },
83
83
  privileged: true,
84
84
  // Android's netd ignores resolv.conf. Use Docker's per-container DNS,