@specific.dev/spectest 0.90.0 → 0.90.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ /** Recorded semantics, independent of Appium's wire selectors and display titles. */
2
+ export interface AndroidTarget {
3
+ kind: "text" | "id" | "accessibility" | "class" | "xpath" | "custom";
4
+ value: string;
5
+ /** Original strategy for a custom locator; never interpreted as display text. */
6
+ strategy?: string;
7
+ }
8
+ export interface AndroidStepDetails {
9
+ device: string;
10
+ action: "tap" | "fill" | "text" | "visible" | "wait" | "swipe" | "back" | "source" | "screenshot" | "execute";
11
+ target?: AndroidTarget;
12
+ point?: {
13
+ x: number;
14
+ y: number;
15
+ };
16
+ direction?: "up" | "down" | "left" | "right";
17
+ state?: "visible" | "hidden";
18
+ command?: string;
19
+ }
20
+ export declare function androidTarget(using: string, value: string): AndroidTarget;
21
+ export declare function androidTargetLabel(target: AndroidTarget): string;
22
+ export declare function androidStepTitle(step: AndroidStepDetails): string;
@@ -0,0 +1,78 @@
1
+ export function androidTarget(using, value) {
2
+ const kind = new Map([
3
+ ["id", "id"],
4
+ ["accessibility id", "accessibility"],
5
+ ["class name", "class"],
6
+ ["xpath", "xpath"],
7
+ ]).get(using);
8
+ return kind ? { kind, value } : { kind: "custom", value, strategy: using };
9
+ }
10
+ export function androidTargetLabel(target) {
11
+ const quoted = JSON.stringify(target.value);
12
+ switch (target.kind) {
13
+ case "text":
14
+ return `text ${quoted}`;
15
+ case "id":
16
+ return `id ${quoted}`;
17
+ case "accessibility":
18
+ return `accessibility label ${quoted}`;
19
+ case "xpath":
20
+ return `XPath ${quoted}`;
21
+ case "custom":
22
+ return "custom selector";
23
+ case "class": {
24
+ const classes = {
25
+ "android.widget.EditText": "text field",
26
+ "android.widget.Button": "button",
27
+ "android.widget.TextView": "text view",
28
+ "android.widget.CheckBox": "checkbox",
29
+ "android.widget.Switch": "switch",
30
+ "android.widget.ImageView": "image",
31
+ "android.widget.ScrollView": "scroll view",
32
+ "androidx.recyclerview.widget.RecyclerView": "list",
33
+ };
34
+ return Object.hasOwn(classes, target.value)
35
+ ? classes[target.value]
36
+ : `class ${quoted}`;
37
+ }
38
+ }
39
+ }
40
+ export function androidStepTitle(step) {
41
+ const target = step.target ? androidTargetLabel(step.target) : "element";
42
+ let label;
43
+ switch (step.action) {
44
+ case "tap":
45
+ label = step.point
46
+ ? `tap (${step.point.x}, ${step.point.y})`
47
+ : `tap ${target}`;
48
+ break;
49
+ case "fill":
50
+ label = `fill ${target}`;
51
+ break;
52
+ case "text":
53
+ label = `read text from ${target}`;
54
+ break;
55
+ case "visible":
56
+ label = `check ${target} is visible`;
57
+ break;
58
+ case "wait":
59
+ label = `wait for ${target} to be ${step.state ?? "visible"}`;
60
+ break;
61
+ case "swipe":
62
+ label = `swipe ${step.direction}`;
63
+ break;
64
+ case "back":
65
+ label = "go back";
66
+ break;
67
+ case "source":
68
+ label = "read screen structure";
69
+ break;
70
+ case "screenshot":
71
+ label = "take screenshot";
72
+ break;
73
+ case "execute":
74
+ label = `run command ${JSON.stringify(step.command)}`;
75
+ break;
76
+ }
77
+ return `${step.device}: ${label}`;
78
+ }
package/dist/android.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { rawFetch } from "./harness/raw-fetch.js";
3
3
  import { recordStep, reserveEvent } from "./recorder.js";
4
4
  import { wrap } from "./inspect.js";
5
+ import { androidStepTitle, androidTarget, androidTargetLabel } from "./android-step.js";
5
6
  export const ANDROID_APP = Symbol.for("spectest.androidApp");
6
7
  export function androidApp(options) {
7
8
  return { ...options, [ANDROID_APP]: true };
@@ -154,14 +155,17 @@ export function androidScope(caseId, testStartedAt, options = {}) {
154
155
  list.push(marker);
155
156
  taps.set(app.name, list);
156
157
  }
157
- async function op(title, fn) {
158
+ async function op(operation, fn) {
158
159
  assertActive();
160
+ const android = { device: app.name, ...operation };
161
+ const title = androidStepTitle(android);
159
162
  const reservation = reserveEvent(), started = Date.now();
160
163
  try {
161
164
  const result = await fn();
162
165
  const seq = recordStep({
163
166
  kind: "android",
164
- title: `${app.name}: ${title}`,
167
+ title,
168
+ android,
165
169
  durationMs: Date.now() - started,
166
170
  status: "passed",
167
171
  }, reservation);
@@ -170,7 +174,8 @@ export function androidScope(caseId, testStartedAt, options = {}) {
170
174
  catch (error) {
171
175
  recordStep({
172
176
  kind: "android",
173
- title: `${app.name}: ${title}`,
177
+ title,
178
+ android,
174
179
  durationMs: Date.now() - started,
175
180
  status: "failed",
176
181
  error: String(error),
@@ -178,8 +183,8 @@ export function androidScope(caseId, testStartedAt, options = {}) {
178
183
  throw error;
179
184
  }
180
185
  }
181
- const element = (using, value) => {
182
- const label = `${using}=${value}`;
186
+ const element = (using, value, target = androidTarget(using, value)) => {
187
+ const label = androidTargetLabel(target);
183
188
  const find = async () => {
184
189
  const result = await command("POST", "/element", {
185
190
  using,
@@ -203,7 +208,7 @@ export function androidScope(caseId, testStartedAt, options = {}) {
203
208
  };
204
209
  return {
205
210
  async tap() {
206
- await op(`tap ${label}`, async () => {
211
+ await op({ action: "tap", target }, async () => {
207
212
  const path = await find();
208
213
  const marker = await tapMarker(path);
209
214
  await command("POST", `${path}/click`, {});
@@ -211,19 +216,19 @@ export function androidScope(caseId, testStartedAt, options = {}) {
211
216
  });
212
217
  },
213
218
  async fill(text) {
214
- await op(`fill ${label}`, async () => {
219
+ await op({ action: "fill", target }, async () => {
215
220
  const path = await find();
216
221
  await command("POST", `${path}/clear`, {});
217
222
  await command("POST", `${path}/value`, { text });
218
223
  });
219
224
  },
220
- text: () => op(`text ${label}`, async () => command("GET", `${await find()}/text`)),
221
- isDisplayed: () => op(`visible ${label}`, displayed),
225
+ text: () => op({ action: "text", target }, async () => command("GET", `${await find()}/text`)),
226
+ isDisplayed: () => op({ action: "visible", target }, displayed),
222
227
  async waitFor(options = {}) {
223
228
  const timeout = options.timeoutMs ?? 10000;
224
229
  if (!Number.isFinite(timeout) || timeout < 0)
225
230
  throw new Error("Invalid wait timeout");
226
- await op(`wait for ${options.state ?? "visible"} ${label}`, async () => {
231
+ await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
227
232
  const end = Date.now() + timeout;
228
233
  do {
229
234
  if ((await displayed()) === (options.state !== "hidden"))
@@ -239,16 +244,16 @@ export function androidScope(caseId, testStartedAt, options = {}) {
239
244
  locator: element,
240
245
  getByAccessibilityId: (id) => element("accessibility id", id),
241
246
  getById: (id) => element("id", id),
242
- getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`),
247
+ getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`, { kind: "text", value: text }),
243
248
  async tap(x, y) {
244
- await op(`tap (${x}, ${y})`, async () => {
249
+ await op({ action: "tap", point: { x, y } }, async () => {
245
250
  const marker = await tapMarker({ x, y });
246
251
  await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
247
252
  saveTap(marker);
248
253
  });
249
254
  },
250
255
  async swipe(direction) {
251
- await op(`swipe ${direction}`, async () => {
256
+ await op({ action: "swipe", direction }, async () => {
252
257
  const rect = await command("GET", "/window/rect");
253
258
  await command("POST", "/execute/sync", {
254
259
  script: "mobile: swipeGesture",
@@ -266,14 +271,14 @@ export function androidScope(caseId, testStartedAt, options = {}) {
266
271
  });
267
272
  },
268
273
  async back() {
269
- await op("back", () => command("POST", "/back", {}));
274
+ await op({ action: "back" }, () => command("POST", "/back", {}));
270
275
  },
271
- pageSource: () => op("page source", () => command("GET", "/source")),
276
+ pageSource: () => op({ action: "source" }, () => command("GET", "/source")),
272
277
  async screenshot() {
273
- const value = await op("screenshot", () => command("GET", "/screenshot"));
278
+ const value = await op({ action: "screenshot" }, () => command("GET", "/screenshot"));
274
279
  return Buffer.from(value.unwrap(), "base64");
275
280
  },
276
- execute: (script, args = {}) => op(script, () => command("POST", "/execute/sync", { script, args: [args] })),
281
+ execute: (script, args = {}) => op({ action: "execute", command: script }, () => command("POST", "/execute/sync", { script, args: [args] })),
277
282
  };
278
283
  })();
279
284
  handles.set(app.name, pending);
package/dist/daemon.js CHANGED
@@ -5124,6 +5124,11 @@ async function evalCode(code) {
5124
5124
  const base = await spectestContext();
5125
5125
  const ctx = {
5126
5126
  ...base,
5127
+ // Bun does not propagate the import() caller's AsyncLocalStorage into
5128
+ // the snippet's module evaluation. Enter the eval scope at the ctx.fetch
5129
+ // call itself so top-level awaits and imported helpers get wrapped values
5130
+ // without enabling instrumentation for unrelated background/fake fetches.
5131
+ fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
5127
5132
  // Wraps its result (no recorder under eval, so no provenance — but the
5128
5133
  // wrapped type is honest at runtime, so `.unwrap()` works).
5129
5134
  exec: execInServiceWrapped,
@@ -1,3 +1,4 @@
1
+ import type { AndroidStepDetails } from "./android-step.js";
1
2
  export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
2
3
  interface BaseEvent {
3
4
  /** Order of *start* within the test. Reserved when an op begins (see
@@ -659,6 +660,8 @@ export interface StepEvent extends BaseEvent {
659
660
  kind: string;
660
661
  /** The step's one-line summary in the timeline. */
661
662
  title: string;
663
+ /** Native action semantics; titles are presentation only. */
664
+ android?: AndroidStepDetails;
662
665
  blocks?: StepBlock[];
663
666
  status?: "passed" | "failed" | "error";
664
667
  durationMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.90.0",
3
+ "version": "0.90.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,115 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ androidStepTitle,
4
+ androidTarget,
5
+ type AndroidStepDetails,
6
+ } from "./android-step.js";
7
+
8
+ const cases: [Omit<AndroidStepDetails, "device">, string][] = [
9
+ [
10
+ { action: "tap", target: { kind: "text", value: "SAVE LOCAL SQLITE" } },
11
+ 'tap text "SAVE LOCAL SQLITE"',
12
+ ],
13
+ [
14
+ {
15
+ action: "tap",
16
+ target: { kind: "text", value: 'a "quoted" label\nnext' },
17
+ },
18
+ 'tap text "a \\"quoted\\" label\\nnext"',
19
+ ],
20
+ [
21
+ {
22
+ action: "fill",
23
+ target: { kind: "class", value: "android.widget.EditText" },
24
+ },
25
+ "fill text field",
26
+ ],
27
+ [
28
+ {
29
+ action: "text",
30
+ target: { kind: "class", value: "android.widget.EditText" },
31
+ },
32
+ "read text from text field",
33
+ ],
34
+ [
35
+ { action: "visible", target: { kind: "id", value: "app:id/save" } },
36
+ 'check id "app:id/save" is visible',
37
+ ],
38
+ [
39
+ {
40
+ action: "wait",
41
+ target: { kind: "accessibility", value: "Save" },
42
+ state: "hidden",
43
+ },
44
+ 'wait for accessibility label "Save" to be hidden',
45
+ ],
46
+ [
47
+ {
48
+ action: "wait",
49
+ target: { kind: "text", value: "Ready" },
50
+ state: "visible",
51
+ },
52
+ 'wait for text "Ready" to be visible',
53
+ ],
54
+ [
55
+ {
56
+ action: "tap",
57
+ target: { kind: "xpath", value: '//button[@text="Save"]' },
58
+ },
59
+ 'tap XPath "//button[@text=\\"Save\\"]"',
60
+ ],
61
+ [
62
+ {
63
+ action: "tap",
64
+ target: {
65
+ kind: "custom",
66
+ strategy: "-android uiautomator",
67
+ value: 'new UiSelector().text("Save")',
68
+ },
69
+ },
70
+ "tap custom selector",
71
+ ],
72
+ [
73
+ { action: "tap", target: { kind: "class", value: "my.CustomWidget" } },
74
+ 'tap class "my.CustomWidget"',
75
+ ],
76
+ [{ action: "tap", point: { x: 12, y: 34 } }, "tap (12, 34)"],
77
+ [{ action: "swipe", direction: "up" }, "swipe up"],
78
+ [{ action: "back" }, "go back"],
79
+ [{ action: "source" }, "read screen structure"],
80
+ [{ action: "screenshot" }, "take screenshot"],
81
+ [
82
+ { action: "execute", command: "mobile: getDeviceInfo" },
83
+ 'run command "mobile: getDeviceInfo"',
84
+ ],
85
+ ];
86
+ for (const [action, title] of cases) {
87
+ test(title, () =>
88
+ expect(androidStepTitle({ device: "phone", ...action })).toBe(
89
+ `phone: ${title}`,
90
+ ),
91
+ );
92
+ }
93
+
94
+ test("custom selector expressions remain opaque structured data", () => {
95
+ const expression = 'new UiSelector().text("Save").enabled(true)';
96
+ const target = androidTarget("-android uiautomator", expression);
97
+ expect(target).toEqual({
98
+ kind: "custom",
99
+ strategy: "-android uiautomator",
100
+ value: expression,
101
+ });
102
+ expect(androidStepTitle({ device: "phone", action: "tap", target })).toBe(
103
+ "phone: tap custom selector",
104
+ );
105
+ for (const [using, kind] of [
106
+ ["id", "id"],
107
+ ["accessibility id", "accessibility"],
108
+ ["class name", "class"],
109
+ ["xpath", "xpath"],
110
+ ] as const)
111
+ expect(androidTarget(using, expression)).toEqual({
112
+ kind,
113
+ value: expression,
114
+ });
115
+ });
@@ -0,0 +1,107 @@
1
+ /** Recorded semantics, independent of Appium's wire selectors and display titles. */
2
+ export interface AndroidTarget {
3
+ kind: "text" | "id" | "accessibility" | "class" | "xpath" | "custom";
4
+ value: string;
5
+ /** Original strategy for a custom locator; never interpreted as display text. */
6
+ strategy?: string;
7
+ }
8
+ export interface AndroidStepDetails {
9
+ device: string;
10
+ action:
11
+ | "tap"
12
+ | "fill"
13
+ | "text"
14
+ | "visible"
15
+ | "wait"
16
+ | "swipe"
17
+ | "back"
18
+ | "source"
19
+ | "screenshot"
20
+ | "execute";
21
+ target?: AndroidTarget;
22
+ point?: { x: number; y: number };
23
+ direction?: "up" | "down" | "left" | "right";
24
+ state?: "visible" | "hidden";
25
+ command?: string;
26
+ }
27
+
28
+ export function androidTarget(using: string, value: string): AndroidTarget {
29
+ const kind = new Map<string, AndroidTarget["kind"]>([
30
+ ["id", "id"],
31
+ ["accessibility id", "accessibility"],
32
+ ["class name", "class"],
33
+ ["xpath", "xpath"],
34
+ ]).get(using);
35
+ return kind ? { kind, value } : { kind: "custom", value, strategy: using };
36
+ }
37
+
38
+ export function androidTargetLabel(target: AndroidTarget): string {
39
+ const quoted = JSON.stringify(target.value);
40
+ switch (target.kind) {
41
+ case "text":
42
+ return `text ${quoted}`;
43
+ case "id":
44
+ return `id ${quoted}`;
45
+ case "accessibility":
46
+ return `accessibility label ${quoted}`;
47
+ case "xpath":
48
+ return `XPath ${quoted}`;
49
+ case "custom":
50
+ return "custom selector";
51
+ case "class": {
52
+ const classes = {
53
+ "android.widget.EditText": "text field",
54
+ "android.widget.Button": "button",
55
+ "android.widget.TextView": "text view",
56
+ "android.widget.CheckBox": "checkbox",
57
+ "android.widget.Switch": "switch",
58
+ "android.widget.ImageView": "image",
59
+ "android.widget.ScrollView": "scroll view",
60
+ "androidx.recyclerview.widget.RecyclerView": "list",
61
+ } as Record<string, string>;
62
+ return Object.hasOwn(classes, target.value)
63
+ ? classes[target.value]
64
+ : `class ${quoted}`;
65
+ }
66
+ }
67
+ }
68
+
69
+ export function androidStepTitle(step: AndroidStepDetails): string {
70
+ const target = step.target ? androidTargetLabel(step.target) : "element";
71
+ let label: string;
72
+ switch (step.action) {
73
+ case "tap":
74
+ label = step.point
75
+ ? `tap (${step.point.x}, ${step.point.y})`
76
+ : `tap ${target}`;
77
+ break;
78
+ case "fill":
79
+ label = `fill ${target}`;
80
+ break;
81
+ case "text":
82
+ label = `read text from ${target}`;
83
+ break;
84
+ case "visible":
85
+ label = `check ${target} is visible`;
86
+ break;
87
+ case "wait":
88
+ label = `wait for ${target} to be ${step.state ?? "visible"}`;
89
+ break;
90
+ case "swipe":
91
+ label = `swipe ${step.direction}`;
92
+ break;
93
+ case "back":
94
+ label = "go back";
95
+ break;
96
+ case "source":
97
+ label = "read screen structure";
98
+ break;
99
+ case "screenshot":
100
+ label = "take screenshot";
101
+ break;
102
+ case "execute":
103
+ label = `run command ${JSON.stringify(step.command)}`;
104
+ break;
105
+ }
106
+ return `${step.device}: ${label}`;
107
+ }
@@ -153,7 +153,18 @@ describe("Android case lifecycle", () => {
153
153
  expect(bad.error).toContain("encoder failed");
154
154
  expect(captures.size).toBe(0); // failed captures are released too
155
155
  const events = stopRecording();
156
- expect(events.filter((e) => e.kind === "android").length).toBe(7);
156
+ const steps = events.filter((e) => e.kind === "android");
157
+ expect(steps.length).toBe(7);
158
+ expect(steps[0]).toMatchObject({
159
+ title: String.raw`phone: fill text "a \"quoted\" label"`,
160
+ android: { device: "phone", action: "fill", target: { kind: "text", value: 'a "quoted" label' } },
161
+ });
162
+ expect(steps[1]).toMatchObject({ android: { device: "phone", action: "text", target: { kind: "id", value: "com.example.app:id/draft" } } });
163
+ expect(steps[2]).toMatchObject({ title: "phone: take screenshot", android: { device: "phone", action: "screenshot" } });
164
+ expect(steps[3]).toMatchObject({ title: 'phone: tap text "Save"', android: { device: "phone", action: "tap", target: { kind: "text", value: "Save" } } });
165
+ expect(steps[4]).toMatchObject({ android: { device: "phone", action: "tap", point: { x: 270, y: 1800 } } });
166
+ expect(steps[5]).toMatchObject({ status: "failed", android: { device: "phone", action: "tap", target: { kind: "text", value: "Gone" } } });
167
+ expect(JSON.stringify(steps)).not.toContain("private draft");
157
168
  } finally {
158
169
  stopRecording();
159
170
  server.stop(true);
package/src/android.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { rawFetch } from "./harness/raw-fetch.js";
3
3
  import { recordStep, reserveEvent } from "./recorder.js";
4
4
  import { wrap, type Wrapped } from "./inspect.js";
5
+ import { androidStepTitle, androidTarget, androidTargetLabel, type AndroidStepDetails, type AndroidTarget } from "./android-step.js";
5
6
 
6
7
  export interface AndroidRecordingOptions {
7
8
  /** Maximum dimension of the video, default 1280. */
@@ -226,8 +227,10 @@ export function androidScope(
226
227
  if (list.length < 10000) list.push(marker);
227
228
  taps.set(app.name, list);
228
229
  }
229
- async function op<T>(title: string, fn: () => Promise<T>): Promise<Wrapped<T>> {
230
+ async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: () => Promise<T>): Promise<Wrapped<T>> {
230
231
  assertActive();
232
+ const android = { device: app.name, ...operation };
233
+ const title = androidStepTitle(android);
231
234
  const reservation = reserveEvent(),
232
235
  started = Date.now();
233
236
  try {
@@ -235,7 +238,8 @@ export function androidScope(
235
238
  const seq = recordStep(
236
239
  {
237
240
  kind: "android",
238
- title: `${app.name}: ${title}`,
241
+ title,
242
+ android,
239
243
  durationMs: Date.now() - started,
240
244
  status: "passed",
241
245
  },
@@ -246,7 +250,8 @@ export function androidScope(
246
250
  recordStep(
247
251
  {
248
252
  kind: "android",
249
- title: `${app.name}: ${title}`,
253
+ title,
254
+ android,
250
255
  durationMs: Date.now() - started,
251
256
  status: "failed",
252
257
  error: String(error),
@@ -256,8 +261,8 @@ export function androidScope(
256
261
  throw error;
257
262
  }
258
263
  }
259
- const element = (using: string, value: string): AndroidElement => {
260
- const label = `${using}=${value}`;
264
+ const element = (using: string, value: string, target: AndroidTarget = androidTarget(using, value)): AndroidElement => {
265
+ const label = androidTargetLabel(target);
261
266
  const find = async () => {
262
267
  const result = await command<Record<string, string>>("POST", "/element", {
263
268
  using,
@@ -281,7 +286,7 @@ export function androidScope(
281
286
  };
282
287
  return {
283
288
  async tap() {
284
- await op(`tap ${label}`, async () => {
289
+ await op({ action: "tap", target }, async () => {
285
290
  const path = await find();
286
291
  const marker = await tapMarker(path);
287
292
  await command("POST", `${path}/click`, {});
@@ -289,19 +294,19 @@ export function androidScope(
289
294
  });
290
295
  },
291
296
  async fill(text) {
292
- await op(`fill ${label}`, async () => {
297
+ await op({ action: "fill", target }, async () => {
293
298
  const path = await find();
294
299
  await command("POST", `${path}/clear`, {});
295
300
  await command("POST", `${path}/value`, { text });
296
301
  });
297
302
  },
298
303
  text: () =>
299
- op(`text ${label}`, async () => command<string>("GET", `${await find()}/text`)),
300
- isDisplayed: () => op(`visible ${label}`, displayed),
304
+ op({ action: "text", target }, async () => command<string>("GET", `${await find()}/text`)),
305
+ isDisplayed: () => op({ action: "visible", target }, displayed),
301
306
  async waitFor(options = {}) {
302
307
  const timeout = options.timeoutMs ?? 10000;
303
308
  if (!Number.isFinite(timeout) || timeout < 0) throw new Error("Invalid wait timeout");
304
- await op(`wait for ${options.state ?? "visible"} ${label}`, async () => {
309
+ await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
305
310
  const end = Date.now() + timeout;
306
311
  do {
307
312
  if ((await displayed()) === (options.state !== "hidden")) return;
@@ -317,16 +322,16 @@ export function androidScope(
317
322
  getByAccessibilityId: (id) => element("accessibility id", id),
318
323
  getById: (id) => element("id", id),
319
324
  getByText: (text) =>
320
- element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`),
325
+ element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`, { kind: "text", value: text }),
321
326
  async tap(x, y) {
322
- await op(`tap (${x}, ${y})`, async () => {
327
+ await op({ action: "tap", point: { x, y } }, async () => {
323
328
  const marker = await tapMarker({ x, y });
324
329
  await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
325
330
  saveTap(marker);
326
331
  });
327
332
  },
328
333
  async swipe(direction) {
329
- await op(`swipe ${direction}`, async () => {
334
+ await op({ action: "swipe", direction }, async () => {
330
335
  const rect = await command<{ width: number; height: number }>("GET", "/window/rect");
331
336
  await command("POST", "/execute/sync", {
332
337
  script: "mobile: swipeGesture",
@@ -344,15 +349,15 @@ export function androidScope(
344
349
  });
345
350
  },
346
351
  async back() {
347
- await op("back", () => command("POST", "/back", {}));
352
+ await op({ action: "back" }, () => command("POST", "/back", {}));
348
353
  },
349
- pageSource: () => op("page source", () => command<string>("GET", "/source")),
354
+ pageSource: () => op({ action: "source" }, () => command<string>("GET", "/source")),
350
355
  async screenshot() {
351
- const value = await op("screenshot", () => command<string>("GET", "/screenshot"));
356
+ const value = await op({ action: "screenshot" }, () => command<string>("GET", "/screenshot"));
352
357
  return Buffer.from(value.unwrap(), "base64");
353
358
  },
354
359
  execute: <T>(script: string, args: Record<string, unknown> = {}) =>
355
- op(script, () => command<T>("POST", "/execute/sync", { script, args: [args] })),
360
+ op({ action: "execute", command: script }, () => command<T>("POST", "/execute/sync", { script, args: [args] })),
356
361
  } satisfies Android;
357
362
  })();
358
363
  handles.set(app.name, pending);
package/src/daemon.ts CHANGED
@@ -6155,6 +6155,11 @@ async function evalCode(code: string): Promise<EvalResult> {
6155
6155
 
6156
6156
  const ctx: TestContext<undefined> = {
6157
6157
  ...base,
6158
+ // Bun does not propagate the import() caller's AsyncLocalStorage into
6159
+ // the snippet's module evaluation. Enter the eval scope at the ctx.fetch
6160
+ // call itself so top-level awaits and imported helpers get wrapped values
6161
+ // without enabling instrumentation for unrelated background/fake fetches.
6162
+ fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
6158
6163
  // Wraps its result (no recorder under eval, so no provenance — but the
6159
6164
  // wrapped type is honest at runtime, so `.unwrap()` works).
6160
6165
  exec: execInServiceWrapped as unknown as TestContext<undefined>["exec"],
@@ -0,0 +1,111 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("interactive ctx.fetch keeps its wrapped contract across module evaluation", async () => {
7
+ // Exercise the real eval import boundary in a separate process: the daemon
8
+ // changes globals and captures stdout, and APP_DIR is fixed at import time.
9
+ const root = await mkdtemp(join(tmpdir(), "spectest-eval-fetch-"));
10
+ try {
11
+ const daemon = new URL("./daemon.ts", import.meta.url).href;
12
+ const scopes = new URL("./harness/instrumentation-scope.ts", import.meta.url).href;
13
+ const recorder = new URL("./recorder.ts", import.meta.url).href;
14
+ const url = "data:application/json,%7B%22id%22%3A%22example%22%7D";
15
+ await writeFile(join(root, "helper.ts"), `
16
+ export async function read(fetcher) {
17
+ await new Promise(resolve => setTimeout(resolve, 0));
18
+ const response = await fetcher(${JSON.stringify(url)});
19
+ return (await response.json()).id.unwrap();
20
+ }
21
+ `);
22
+ const reproducer = `
23
+ const r = await ctx.fetch(${JSON.stringify(url)});
24
+ const j = await r.json();
25
+ export default {
26
+ statusType: typeof r.status, statusUnwrap: typeof r.status.unwrap,
27
+ idType: typeof j.id, idUnwrap: typeof j.id.unwrap,
28
+ };
29
+ `;
30
+ const readers = `
31
+ import { read } from "../helper.ts";
32
+ const r = await ctx.fetch(${JSON.stringify(url)});
33
+ const clone = r.clone();
34
+ await Promise.resolve();
35
+ const body = await r.json();
36
+ const { fetch: fetcher } = ctx;
37
+ export default {
38
+ status: r.status.unwrap(), ok: r.ok.unwrap(),
39
+ contentType: r.headers.get("content-type").unwrap(),
40
+ id: body.id.unwrap(), text: (await clone.text()).unwrap(),
41
+ rawStatus: r.unwrap().status, helperId: await read(fetcher),
42
+ };
43
+ `;
44
+ const script = `
45
+ import { strict as assert } from "node:assert";
46
+ import { METHODS } from ${JSON.stringify(daemon)};
47
+ import { currentInstrumentationScope, runUninstrumented } from ${JSON.stringify(scopes)};
48
+ import { startRecording, stopRecording } from ${JSON.stringify(recorder)};
49
+ const originalFetch = globalThis.fetch;
50
+ const url = ${JSON.stringify(url)};
51
+ const contentType = (await originalFetch(url)).headers.get("content-type");
52
+ startRecording();
53
+ const result = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
54
+ assert.equal(result.ok, true, JSON.stringify(result));
55
+ assert.deepEqual(result.result, {
56
+ statusType: "object", statusUnwrap: "function",
57
+ idType: "object", idUnwrap: "function",
58
+ });
59
+ const values = await METHODS.eval({ code: ${JSON.stringify(readers)} });
60
+ assert.equal(values.ok, true, JSON.stringify(values));
61
+ assert.deepEqual(values.result, {
62
+ status: 200, ok: true, contentType,
63
+ id: "example", text: '{"id":"example"}', rawStatus: 200,
64
+ helperId: "example",
65
+ });
66
+ // Fake/background work outside the eval's call must still see native fetch.
67
+ let releaseBackground;
68
+ const released = new Promise(resolve => { releaseBackground = resolve; });
69
+ const background = released.then(async () => {
70
+ const r = await fetch(url);
71
+ assert.equal(r.status, 200);
72
+ assert.equal((await r.json()).id, "example");
73
+ });
74
+ globalThis.nativeProbe = async () => {
75
+ releaseBackground();
76
+ await background;
77
+ await runUninstrumented(async () => {
78
+ await Promise.resolve();
79
+ const fakeResponse = await fetch(url);
80
+ assert.equal(fakeResponse.status, 200);
81
+ assert.equal((await fakeResponse.json()).id, "example");
82
+ });
83
+ };
84
+ const isolated = await METHODS.eval({ code: "await nativeProbe();" });
85
+ assert.equal(isolated.ok, true, JSON.stringify(isolated));
86
+ const failed = await METHODS.eval({ code: 'await ctx.fetch(' + JSON.stringify(url) + '); throw new Error("expected");' });
87
+ assert.equal(failed.ok, false);
88
+ assert.equal(failed.error.message, "expected");
89
+ assert.equal(globalThis.fetch, originalFetch);
90
+ assert.equal(currentInstrumentationScope(), undefined);
91
+ assert.equal((await fetch(url)).status, 200);
92
+ const next = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
93
+ assert.equal(next.ok, true, JSON.stringify(next));
94
+ assert.deepEqual(next.result, result.result);
95
+ assert.deepEqual(stopRecording(), []);
96
+ `;
97
+ const child = Bun.spawn([process.execPath, "--eval", script], {
98
+ env: { ...process.env, SPECTEST_APP_DIR: root },
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ });
102
+ const [code, stdout, stderr] = await Promise.all([
103
+ child.exited,
104
+ new Response(child.stdout).text(),
105
+ new Response(child.stderr).text(),
106
+ ]);
107
+ expect(code, stdout + stderr).toBe(0);
108
+ } finally {
109
+ await rm(root, { recursive: true, force: true });
110
+ }
111
+ }, 20_000);
package/src/recorder.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // current recorder is `null` and the `record*` helpers are no-ops, so
9
9
  // callers can invoke them unconditionally.
10
10
 
11
+ import type { AndroidStepDetails } from "./android-step.js";
11
12
  import { clearPendingNullish } from "./inspect.js";
12
13
  import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
13
14
 
@@ -911,6 +912,8 @@ export interface StepEvent extends BaseEvent {
911
912
  kind: string;
912
913
  /** The step's one-line summary in the timeline. */
913
914
  title: string;
915
+ /** Native action semantics; titles are presentation only. */
916
+ android?: AndroidStepDetails;
914
917
  blocks?: StepBlock[];
915
918
  status?: "passed" | "failed" | "error";
916
919
  durationMs?: number;