@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.
- package/dist/android-step.d.ts +22 -0
- package/dist/android-step.js +78 -0
- package/dist/android.js +22 -17
- package/dist/coverage.d.ts +2 -0
- package/dist/coverage.js +15 -10
- package/dist/daemon.js +5 -0
- package/dist/harness/browser-coverage.d.ts +4 -3
- package/dist/harness/browser-coverage.js +14 -8
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/project-files.js +4 -35
- package/dist/recorder.d.ts +3 -0
- package/package.json +1 -1
- package/src/android-step.test.ts +115 -0
- package/src/android-step.ts +107 -0
- package/src/android.test.ts +12 -1
- package/src/android.ts +22 -17
- package/src/coverage.test.ts +28 -0
- package/src/coverage.ts +15 -10
- package/src/daemon.ts +5 -0
- package/src/eval-fetch.test.ts +111 -0
- package/src/harness/browser-coverage.test.ts +9 -6
- package/src/harness/browser-coverage.ts +12 -7
- package/src/index.ts +1 -2
- package/src/project-files.ts +4 -37
- package/src/recorder.ts +3 -0
|
@@ -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(
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
221
|
-
isDisplayed: () => op(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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/coverage.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
}
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
48
|
-
*
|
|
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
|
|
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
|
|
93
|
-
*
|
|
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
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
257
|
-
* instead of read stale).
|
|
256
|
+
* `ctx.readProjectFile` apply.
|
|
258
257
|
*/
|
|
259
258
|
export function validateServiceImage(serviceName, image) {
|
|
260
259
|
if (image.type !== "dockerfile")
|
package/dist/project-files.js
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
// edit keeps the fast start)
|
|
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
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/android.test.ts
CHANGED
|
@@ -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
|
-
|
|
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>(
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
300
|
-
isDisplayed: () => op(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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/coverage.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
}
|
|
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.
|
|
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:
|
|
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
|
|
58
|
-
expect(normalizeSourcePath("webpack:///./src/a.ts")).toBe("src/a.ts");
|
|
59
|
-
expect(normalizeSourcePath("
|
|
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
|
|
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
|
|
126
|
-
*
|
|
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
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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;
|
package/src/project-files.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
// edit keeps the fast start)
|
|
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
|
|
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;
|