@specific.dev/spectest 0.90.0 → 0.90.2

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);
@@ -213,6 +213,8 @@ export declare const NODE_COVERAGE_DUMP_PREFIX = "node-";
213
213
  * source map: the inline `data:` map in the text, the file the last
214
214
  * `sourceMappingURL` comment names, or `<script>.map` next to the
215
215
  * script; `sourcesContent` dropped (the sources are the repository).
216
+ * Records are keyed by their descriptor and compiled-source hash, not
217
+ * their URL: a recompilation can reuse a URL with different source.
216
218
  * "Once per branch" is a marker file under `.spectest/scripts/`: the
217
219
  * directory is shared by every process of the service and forks with
218
220
  * the environment, so a record written at bring-up or by an ancestor
package/dist/coverage.js CHANGED
@@ -196,6 +196,8 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
196
196
  * source map: the inline `data:` map in the text, the file the last
197
197
  * `sourceMappingURL` comment names, or `<script>.map` next to the
198
198
  * script; `sourcesContent` dropped (the sources are the repository).
199
+ * Records are keyed by their descriptor and compiled-source hash, not
200
+ * their URL: a recompilation can reuse a URL with different source.
199
201
  * "Once per branch" is a marker file under `.spectest/scripts/`: the
200
202
  * directory is shared by every process of the service and forks with
201
203
  * the environment, so a record written at bring-up or by an ancestor
@@ -242,7 +244,9 @@ export const NODE_COVERAGE_HOOK = `"use strict";
242
244
  const SELF = url.pathToFileURL(__filename).href;
243
245
  const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
244
246
  const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
245
- const known = new Set();
247
+ // V8 script ids identify a compiled script within this process. A URL
248
+ // can be reused by another process, vm context, or compilation.
249
+ const known = new Map();
246
250
  let seq = 0;
247
251
 
248
252
  function isApp(u) {
@@ -375,7 +379,6 @@ export const NODE_COVERAGE_HOOK = `"use strict";
375
379
  fs.renameSync(tmp, file);
376
380
  }
377
381
  function describe(s, source) {
378
- const id = idOf(s.url);
379
382
  let scriptPath = null;
380
383
  try {
381
384
  scriptPath = url.fileURLToPath(s.url);
@@ -383,16 +386,20 @@ export const NODE_COVERAGE_HOOK = `"use strict";
383
386
  const text = typeof source === "string" ? source : null;
384
387
  const record = {
385
388
  url: s.url,
389
+ sourceHash: text !== null ? crypto.createHash("sha256").update(text).digest("hex") : null,
386
390
  lineLengths: text !== null ? lineLengths(text) : null,
387
391
  functions: s.functions.map(function (f) {
388
392
  return [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset];
389
393
  }),
390
394
  sourceMap: text !== null && scriptPath !== null ? readMap(scriptPath, text) : null,
391
395
  };
392
- writeAtomic(path.join(SCRIPTS, id + ".json"), JSON.stringify({ spectestScript: record }));
393
- try {
396
+ const document = JSON.stringify({ spectestScript: record });
397
+ const id = idOf(document);
398
+ if (!fs.existsSync(path.join(MARKERS, id))) {
399
+ writeAtomic(path.join(SCRIPTS, id + ".json"), document);
394
400
  fs.writeFileSync(path.join(MARKERS, id), "");
395
- } catch (e) {}
401
+ }
402
+ return id;
396
403
  }
397
404
  function take() {
398
405
  const res = post("Profiler.takePreciseCoverage");
@@ -413,9 +420,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
413
420
  if (kept.length === 0 && units.size === 0) return;
414
421
  const fresh = [];
415
422
  for (const s of kept) {
416
- if (known.has(s.url)) continue;
417
- known.add(s.url);
418
- if (!fs.existsSync(path.join(MARKERS, idOf(s.url)))) fresh.push(s);
423
+ if (!known.has(s.scriptId)) fresh.push(s);
419
424
  }
420
425
  if (fresh.length > 0) {
421
426
  try {
@@ -426,7 +431,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
426
431
  for (const s of fresh) {
427
432
  const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
428
433
  try {
429
- describe(s, r && r.scriptSource);
434
+ known.set(s.scriptId, describe(s, r && r.scriptSource));
430
435
  } catch (e) {}
431
436
  }
432
437
  post("Debugger.disable");
@@ -442,7 +447,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
442
447
  result: kept.map(function (s) {
443
448
  return {
444
449
  url: s.url,
445
- spectestScript: idOf(s.url),
450
+ spectestScript: known.get(s.scriptId) || null,
446
451
  functions: s.functions.filter(ran).map(function (f) {
447
452
  const first = f.ranges[0];
448
453
  const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
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,
@@ -41,11 +41,12 @@ export interface DecodedSourceMap {
41
41
  /** Decode one VLQ-encoded segment into its fields. */
42
42
  export declare function decodeVlq(segment: string): number[];
43
43
  /**
44
- * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
44
+ * Normalise a source-map `sources` entry without discarding its identity. Bundlers prefix
45
45
  * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
46
46
  * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
47
- * The server maps these onto the repo by suffix, so all that matters here
48
- * is stripping what is not a path.
47
+ * The server validates file identity against the repository. Preserve
48
+ * virtual namespaces and external URLs; losing them makes foreign
49
+ * sources indistinguishable from project files.
49
50
  */
50
51
  export declare function normalizeSourcePath(src: string, sourceRoot?: string): string;
51
52
  /** Sources nobody's change can be attributed to: dependencies and bundler
@@ -86,22 +86,28 @@ export function decodeVlq(segment) {
86
86
  return out;
87
87
  }
88
88
  /**
89
- * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
89
+ * Normalise a source-map `sources` entry without discarding its identity. Bundlers prefix
90
90
  * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
91
91
  * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
92
- * The server maps these onto the repo by suffix, so all that matters here
93
- * is stripping what is not a path.
92
+ * The server validates file identity against the repository. Preserve
93
+ * virtual namespaces and external URLs; losing them makes foreign
94
+ * sources indistinguishable from project files.
94
95
  */
95
96
  export function normalizeSourcePath(src, sourceRoot) {
96
97
  let s = src;
97
98
  if (sourceRoot && !/^[a-z]+:/i.test(s)) {
98
99
  s = sourceRoot.replace(/\/?$/, "/") + s;
99
100
  }
100
- s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
101
- // A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
102
- const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
103
- if (m)
104
- s = m[1];
101
+ // Keep virtual source identity intact. Stripping the scheme turned
102
+ // webpack://_N_E/../src/utils.ts (a foreign source) into a plausible
103
+ // project path; a suffix match then attributed its lines to lib/utils.ts.
104
+ // Do not use URL here: it collapses the traversal we need to preserve.
105
+ if (/^(webpack|turbopack):\/\//.test(s))
106
+ return s;
107
+ s = s.replace(/^file:\/\//, "");
108
+ // External URLs keep their identity; Vite's /@fs is an explicit file path.
109
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s))
110
+ return s;
105
111
  s = s.replace(/^\/@fs\//, "/");
106
112
  s = s.replace(/\?.*$/, "");
107
113
  // Turbopack (Next.js) roots every source at the `[project]` token —
package/dist/index.d.ts CHANGED
@@ -829,8 +829,7 @@ export type ServiceImage = {
829
829
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
830
830
  * twin, inside the VM, where the repo sits at the project root. Existence
831
831
  * checks go through the project-file resolver, so the same rules as
832
- * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
833
- * instead of read stale).
832
+ * `ctx.readProjectFile` apply.
834
833
  */
835
834
  export declare function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
836
835
  export interface VolumeMount {
package/dist/index.js CHANGED
@@ -253,8 +253,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
253
253
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
254
254
  * twin, inside the VM, where the repo sits at the project root. Existence
255
255
  * checks go through the project-file resolver, so the same rules as
256
- * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
257
- * instead of read stale).
256
+ * `ctx.readProjectFile` apply.
258
257
  */
259
258
  export function validateServiceImage(serviceName, image) {
260
259
  if (image.type !== "dockerfile")
@@ -9,46 +9,20 @@
9
9
  // on EVERY start, warm ones included.
10
10
  //
11
11
  // A warm start happens only when the warm-template hash matches, so /workspace
12
- // is correct for every file that is IN that hash. It can be behind for the two
13
- // kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
- // edit keeps the fast start) and anything a project lists in
15
- // `spectest/.specignore`.
12
+ // is correct for every file that is IN that hash. It can be behind for the one
13
+ // kind of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
+ // edit keeps the fast start).
16
15
  //
17
16
  // Hence the rule below: a path under `spectest/` resolves against the app copy
18
17
  // first, because that copy is always current — this is what lets a fixture live
19
18
  // in `spectest/tests/fixtures/` and still be found after it was added. Any
20
19
  // other path resolves against /workspace, which the hash keeps current.
21
- //
22
- // The remaining hole is a file that `.specignore` excludes AND that sits outside
23
- // `spectest/`: /workspace holds whatever the cold start uploaded, so a later
24
- // edit is invisible. Nothing can repair those bytes at read time, so we refuse
25
- // to read them instead of returning stale content. The control plane writes the
26
- // exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
27
- import { existsSync, readFileSync } from "node:fs";
20
+ import { existsSync } from "node:fs";
28
21
  import path from "node:path";
29
22
  /** The extracted repo — `ctx.projectRoot`. */
30
23
  export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
31
24
  /** The app dir; the user's `spectest/` sits directly under it. */
32
25
  export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
33
- /** Paths (repo-relative) the warm-template hash skipped because of
34
- * `spectest/.specignore`. Written by the control plane at env start; absent
35
- * when the project ships no `.specignore`. */
36
- const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
37
- let _envIgnored;
38
- function envIgnored() {
39
- if (_envIgnored)
40
- return _envIgnored;
41
- let paths = [];
42
- try {
43
- const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8"));
44
- paths = raw.paths ?? [];
45
- }
46
- catch {
47
- /* No file (no .specignore, or an older env) — nothing to refuse. */
48
- }
49
- _envIgnored = new Set(paths);
50
- return _envIgnored;
51
- }
52
26
  /** Project files resolved since the last take — what a test read
53
27
  * through the SDK. Module memory: forks with the environment; the
54
28
  * coverage capture takes and clears it after every case, so a forked
@@ -89,11 +63,6 @@ export function resolveProjectPath(p, opts = {}) {
89
63
  if (existsSync(app))
90
64
  return app;
91
65
  }
92
- if (envIgnored().has(rel)) {
93
- throw new Error(`project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
94
- `is whatever a cold start uploaded and can be out of date. Move it under ` +
95
- `spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
96
- }
97
66
  return path.join(WORKSPACE, rel);
98
67
  }
99
68
  /** {@link resolveProjectPath}, but a missing file is an error that names every
@@ -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.2",
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);
@@ -297,6 +297,34 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
297
297
  }
298
298
  });
299
299
 
300
+ test("recompiled scripts at the same URL get distinct records within and across processes", async () => {
301
+ const dir = await scratch();
302
+ const sharedUrl = `file://${path.join(dir, "shared.js")}`;
303
+ await fs.writeFile(path.join(dir, "main.js"), `
304
+ const vm = require("node:vm");
305
+ vm.runInThisContext("globalThis.value = 1;", { filename: ${JSON.stringify(sharedUrl)} });
306
+ vm.runInThisContext("globalThis.value = 2;", { filename: ${JSON.stringify(sharedUrl)} });
307
+ `);
308
+ expect(await runOnce(dir, "main.js")).toBe(0);
309
+ const first = (await Promise.all((await dumpsIn(dir)).map((n) => readDump(dir, n))))
310
+ .flatMap((d) => d.result).filter((s) => s.url === sharedUrl);
311
+ expect(first).toHaveLength(2);
312
+ const ids = new Set(first.map((s) => s.spectestScript));
313
+ expect(ids.size).toBe(2); // same URL, line lengths and extents, different source
314
+ for (const id of ids) {
315
+ const record = JSON.parse(await fs.readFile(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR, `${id}.json`), "utf8"));
316
+ expect(record.spectestScript.url).toBe(sharedUrl);
317
+ expect(record.spectestScript.sourceHash).toMatch(/^[a-f0-9]{64}$/);
318
+ }
319
+ // A second process shares the marker directory. Identical descriptors
320
+ // deduplicate, but must never borrow the other compilation's identity.
321
+ expect(await runOnce(dir, "main.js")).toBe(0);
322
+ const all = (await Promise.all((await dumpsIn(dir)).map((n) => readDump(dir, n))))
323
+ .flatMap((d) => d.result).filter((s) => s.url === sharedUrl);
324
+ expect(all).toHaveLength(4);
325
+ expect(new Set(all.map((s) => s.spectestScript))).toEqual(ids);
326
+ });
327
+
300
328
  test("short-lived CLI processes write at exit; the map next to the script is recorded once, no NODE_V8_COVERAGE", async () => {
301
329
  const dir = await scratch();
302
330
  await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
package/src/coverage.ts CHANGED
@@ -342,6 +342,8 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
342
342
  * source map: the inline `data:` map in the text, the file the last
343
343
  * `sourceMappingURL` comment names, or `<script>.map` next to the
344
344
  * script; `sourcesContent` dropped (the sources are the repository).
345
+ * Records are keyed by their descriptor and compiled-source hash, not
346
+ * their URL: a recompilation can reuse a URL with different source.
345
347
  * "Once per branch" is a marker file under `.spectest/scripts/`: the
346
348
  * directory is shared by every process of the service and forks with
347
349
  * the environment, so a record written at bring-up or by an ancestor
@@ -388,7 +390,9 @@ export const NODE_COVERAGE_HOOK = `"use strict";
388
390
  const SELF = url.pathToFileURL(__filename).href;
389
391
  const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
390
392
  const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
391
- const known = new Set();
393
+ // V8 script ids identify a compiled script within this process. A URL
394
+ // can be reused by another process, vm context, or compilation.
395
+ const known = new Map();
392
396
  let seq = 0;
393
397
 
394
398
  function isApp(u) {
@@ -521,7 +525,6 @@ export const NODE_COVERAGE_HOOK = `"use strict";
521
525
  fs.renameSync(tmp, file);
522
526
  }
523
527
  function describe(s, source) {
524
- const id = idOf(s.url);
525
528
  let scriptPath = null;
526
529
  try {
527
530
  scriptPath = url.fileURLToPath(s.url);
@@ -529,16 +532,20 @@ export const NODE_COVERAGE_HOOK = `"use strict";
529
532
  const text = typeof source === "string" ? source : null;
530
533
  const record = {
531
534
  url: s.url,
535
+ sourceHash: text !== null ? crypto.createHash("sha256").update(text).digest("hex") : null,
532
536
  lineLengths: text !== null ? lineLengths(text) : null,
533
537
  functions: s.functions.map(function (f) {
534
538
  return [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset];
535
539
  }),
536
540
  sourceMap: text !== null && scriptPath !== null ? readMap(scriptPath, text) : null,
537
541
  };
538
- writeAtomic(path.join(SCRIPTS, id + ".json"), JSON.stringify({ spectestScript: record }));
539
- try {
542
+ const document = JSON.stringify({ spectestScript: record });
543
+ const id = idOf(document);
544
+ if (!fs.existsSync(path.join(MARKERS, id))) {
545
+ writeAtomic(path.join(SCRIPTS, id + ".json"), document);
540
546
  fs.writeFileSync(path.join(MARKERS, id), "");
541
- } catch (e) {}
547
+ }
548
+ return id;
542
549
  }
543
550
  function take() {
544
551
  const res = post("Profiler.takePreciseCoverage");
@@ -559,9 +566,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
559
566
  if (kept.length === 0 && units.size === 0) return;
560
567
  const fresh = [];
561
568
  for (const s of kept) {
562
- if (known.has(s.url)) continue;
563
- known.add(s.url);
564
- if (!fs.existsSync(path.join(MARKERS, idOf(s.url)))) fresh.push(s);
569
+ if (!known.has(s.scriptId)) fresh.push(s);
565
570
  }
566
571
  if (fresh.length > 0) {
567
572
  try {
@@ -572,7 +577,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
572
577
  for (const s of fresh) {
573
578
  const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
574
579
  try {
575
- describe(s, r && r.scriptSource);
580
+ known.set(s.scriptId, describe(s, r && r.scriptSource));
576
581
  } catch (e) {}
577
582
  }
578
583
  post("Debugger.disable");
@@ -588,7 +593,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
588
593
  result: kept.map(function (s) {
589
594
  return {
590
595
  url: s.url,
591
- spectestScript: idOf(s.url),
596
+ spectestScript: known.get(s.scriptId) || null,
592
597
  functions: s.functions.filter(ran).map(function (f) {
593
598
  const first = f.ranges[0];
594
599
  const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
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);
@@ -41,10 +41,10 @@ describe("source maps", () => {
41
41
  mappings: "AAAA,ICAA;ACIA",
42
42
  });
43
43
  expect(map).not.toBeNull();
44
- expect(map!.sources).toEqual(["src/a.ts", "node_modules/x.js", "src/b.ts"]);
44
+ expect(map!.sources).toEqual(["webpack:///./src/a.ts", "webpack:///./node_modules/x.js", "src/b.ts"]);
45
45
  const files = mapToOriginal(map!, new Map([[1, 2], [2, 0]]));
46
- expect([...files.keys()]).toEqual(["src/a.ts", "src/b.ts"]);
47
- expect(files.get("src/a.ts")!.get(1)).toBe(2);
46
+ expect([...files.keys()]).toEqual(["webpack:///./src/a.ts", "src/b.ts"]);
47
+ expect(files.get("webpack:///./src/a.ts")!.get(1)).toBe(2);
48
48
  expect(files.get("src/b.ts")!.get(5)).toBe(0);
49
49
  });
50
50
 
@@ -54,9 +54,12 @@ describe("source maps", () => {
54
54
  expect(decodeSourceMap("nope")).toBeNull();
55
55
  });
56
56
 
57
- test("normalizeSourcePath strips schemes, roots and queries", () => {
58
- expect(normalizeSourcePath("webpack:///./src/a.ts")).toBe("src/a.ts");
59
- expect(normalizeSourcePath("http://app.test/src/a.ts?t=1")).toBe("/src/a.ts");
57
+ test("normalizeSourcePath preserves namespaces and external origins", () => {
58
+ expect(normalizeSourcePath("webpack:///./src/a.ts")).toBe("webpack:///./src/a.ts");
59
+ expect(normalizeSourcePath("webpack://_N_E/../src/utils.ts")).toBe("webpack://_N_E/../src/utils.ts");
60
+ expect(normalizeSourcePath("webpack://_N_E/./lib/utils.ts")).toBe("webpack://_N_E/./lib/utils.ts");
61
+ expect(normalizeSourcePath("turbopack:///[project]/src/a.ts")).toBe("turbopack:///[project]/src/a.ts");
62
+ expect(normalizeSourcePath("http://app.test/src/a.ts?t=1")).toBe("http://app.test/src/a.ts?t=1");
60
63
  expect(normalizeSourcePath("/@fs/repo/src/a.ts")).toBe("/repo/src/a.ts");
61
64
  expect(normalizeSourcePath("a.ts", "/app/src")).toBe("/app/src/a.ts");
62
65
  expect(normalizeSourcePath("file:///app/src/a.ts")).toBe("/app/src/a.ts");
@@ -119,21 +119,26 @@ export function decodeVlq(segment: string): number[] {
119
119
  }
120
120
 
121
121
  /**
122
- * Normalise a source-map `sources` entry to a plain path. Bundlers prefix
122
+ * Normalise a source-map `sources` entry without discarding its identity. Bundlers prefix
123
123
  * their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
124
124
  * URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
125
- * The server maps these onto the repo by suffix, so all that matters here
126
- * is stripping what is not a path.
125
+ * The server validates file identity against the repository. Preserve
126
+ * virtual namespaces and external URLs; losing them makes foreign
127
+ * sources indistinguishable from project files.
127
128
  */
128
129
  export function normalizeSourcePath(src: string, sourceRoot?: string): string {
129
130
  let s = src;
130
131
  if (sourceRoot && !/^[a-z]+:/i.test(s)) {
131
132
  s = sourceRoot.replace(/\/?$/, "/") + s;
132
133
  }
133
- s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
134
- // A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
135
- const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
136
- if (m) s = m[1];
134
+ // Keep virtual source identity intact. Stripping the scheme turned
135
+ // webpack://_N_E/../src/utils.ts (a foreign source) into a plausible
136
+ // project path; a suffix match then attributed its lines to lib/utils.ts.
137
+ // Do not use URL here: it collapses the traversal we need to preserve.
138
+ if (/^(webpack|turbopack):\/\//.test(s)) return s;
139
+ s = s.replace(/^file:\/\//, "");
140
+ // External URLs keep their identity; Vite's /@fs is an explicit file path.
141
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return s;
137
142
  s = s.replace(/^\/@fs\//, "/");
138
143
  s = s.replace(/\?.*$/, "");
139
144
  // Turbopack (Next.js) roots every source at the `[project]` token —
package/src/index.ts CHANGED
@@ -1272,8 +1272,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
1272
1272
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
1273
1273
  * twin, inside the VM, where the repo sits at the project root. Existence
1274
1274
  * checks go through the project-file resolver, so the same rules as
1275
- * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
1276
- * instead of read stale).
1275
+ * `ctx.readProjectFile` apply.
1277
1276
  */
1278
1277
  export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
1279
1278
  if (image.type !== "dockerfile") return image;
@@ -9,23 +9,16 @@
9
9
  // on EVERY start, warm ones included.
10
10
  //
11
11
  // A warm start happens only when the warm-template hash matches, so /workspace
12
- // is correct for every file that is IN that hash. It can be behind for the two
13
- // kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
- // edit keeps the fast start) and anything a project lists in
15
- // `spectest/.specignore`.
12
+ // is correct for every file that is IN that hash. It can be behind for the one
13
+ // kind of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
+ // edit keeps the fast start).
16
15
  //
17
16
  // Hence the rule below: a path under `spectest/` resolves against the app copy
18
17
  // first, because that copy is always current — this is what lets a fixture live
19
18
  // in `spectest/tests/fixtures/` and still be found after it was added. Any
20
19
  // other path resolves against /workspace, which the hash keeps current.
21
- //
22
- // The remaining hole is a file that `.specignore` excludes AND that sits outside
23
- // `spectest/`: /workspace holds whatever the cold start uploaded, so a later
24
- // edit is invisible. Nothing can repair those bytes at read time, so we refuse
25
- // to read them instead of returning stale content. The control plane writes the
26
- // exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
27
20
 
28
- import { existsSync, readFileSync } from "node:fs";
21
+ import { existsSync } from "node:fs";
29
22
  import path from "node:path";
30
23
 
31
24
  /** The extracted repo — `ctx.projectRoot`. */
@@ -33,25 +26,6 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
33
26
  /** The app dir; the user's `spectest/` sits directly under it. */
34
27
  export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
35
28
 
36
- /** Paths (repo-relative) the warm-template hash skipped because of
37
- * `spectest/.specignore`. Written by the control plane at env start; absent
38
- * when the project ships no `.specignore`. */
39
- const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
40
-
41
- let _envIgnored: Set<string> | undefined;
42
- function envIgnored(): Set<string> {
43
- if (_envIgnored) return _envIgnored;
44
- let paths: string[] = [];
45
- try {
46
- const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8")) as { paths?: string[] };
47
- paths = raw.paths ?? [];
48
- } catch {
49
- /* No file (no .specignore, or an older env) — nothing to refuse. */
50
- }
51
- _envIgnored = new Set(paths);
52
- return _envIgnored;
53
- }
54
-
55
29
  /** Project files resolved since the last take — what a test read
56
30
  * through the SDK. Module memory: forks with the environment; the
57
31
  * coverage capture takes and clears it after every case, so a forked
@@ -92,13 +66,6 @@ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}):
92
66
  const app = path.join(APP_DIR, rel);
93
67
  if (existsSync(app)) return app;
94
68
  }
95
- if (envIgnored().has(rel)) {
96
- throw new Error(
97
- `project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
98
- `is whatever a cold start uploaded and can be out of date. Move it under ` +
99
- `spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`,
100
- );
101
- }
102
69
  return path.join(WORKSPACE, rel);
103
70
  }
104
71
 
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;