@lingxia/test 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/clock.d.ts +30 -0
  2. package/dist/clock.d.ts.map +1 -0
  3. package/dist/clock.js +99 -0
  4. package/dist/clock.js.map +1 -0
  5. package/dist/coverage.d.ts +25 -0
  6. package/dist/coverage.d.ts.map +1 -0
  7. package/dist/coverage.js +92 -0
  8. package/dist/coverage.js.map +1 -0
  9. package/dist/deadline.d.ts +82 -0
  10. package/dist/deadline.d.ts.map +1 -0
  11. package/dist/deadline.js +184 -0
  12. package/dist/deadline.js.map +1 -0
  13. package/dist/dialogs.d.ts +28 -0
  14. package/dist/dialogs.d.ts.map +1 -0
  15. package/dist/dialogs.js +74 -0
  16. package/dist/dialogs.js.map +1 -0
  17. package/dist/equal.d.ts +28 -0
  18. package/dist/equal.d.ts.map +1 -0
  19. package/dist/equal.js +149 -0
  20. package/dist/equal.js.map +1 -0
  21. package/dist/errors.d.ts +8 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +37 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/expect.d.ts +35 -7
  26. package/dist/expect.d.ts.map +1 -1
  27. package/dist/expect.js +216 -68
  28. package/dist/expect.js.map +1 -1
  29. package/dist/fixture.d.ts +201 -23
  30. package/dist/fixture.d.ts.map +1 -1
  31. package/dist/fixture.js +1127 -157
  32. package/dist/fixture.js.map +1 -1
  33. package/dist/format.d.ts +2 -0
  34. package/dist/format.d.ts.map +1 -1
  35. package/dist/format.js +78 -0
  36. package/dist/format.js.map +1 -1
  37. package/dist/host-types.d.ts +65 -0
  38. package/dist/host-types.d.ts.map +1 -0
  39. package/dist/host-types.js +2 -0
  40. package/dist/host-types.js.map +1 -0
  41. package/dist/host.d.ts +20 -0
  42. package/dist/host.d.ts.map +1 -1
  43. package/dist/host.js +33 -1
  44. package/dist/host.js.map +1 -1
  45. package/dist/ids.d.ts +14 -0
  46. package/dist/ids.d.ts.map +1 -1
  47. package/dist/ids.js +37 -2
  48. package/dist/ids.js.map +1 -1
  49. package/dist/index.d.ts +5 -7
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/json-result.d.ts +3 -0
  54. package/dist/json-result.d.ts.map +1 -0
  55. package/dist/json-result.js +62 -0
  56. package/dist/json-result.js.map +1 -0
  57. package/dist/junit.d.ts +1 -1
  58. package/dist/junit.d.ts.map +1 -1
  59. package/dist/junit.js +32 -4
  60. package/dist/junit.js.map +1 -1
  61. package/dist/locator.d.ts +87 -9
  62. package/dist/locator.d.ts.map +1 -1
  63. package/dist/locator.js +413 -86
  64. package/dist/locator.js.map +1 -1
  65. package/dist/mock.d.ts +43 -0
  66. package/dist/mock.d.ts.map +1 -0
  67. package/dist/mock.js +162 -0
  68. package/dist/mock.js.map +1 -0
  69. package/dist/network.d.ts +68 -0
  70. package/dist/network.d.ts.map +1 -0
  71. package/dist/network.js +287 -0
  72. package/dist/network.js.map +1 -0
  73. package/dist/openapi.d.ts +127 -0
  74. package/dist/openapi.d.ts.map +1 -0
  75. package/dist/openapi.js +414 -0
  76. package/dist/openapi.js.map +1 -0
  77. package/dist/pending.d.ts +84 -0
  78. package/dist/pending.d.ts.map +1 -0
  79. package/dist/pending.js +311 -0
  80. package/dist/pending.js.map +1 -0
  81. package/dist/redact.d.ts +41 -0
  82. package/dist/redact.d.ts.map +1 -0
  83. package/dist/redact.js +168 -0
  84. package/dist/redact.js.map +1 -0
  85. package/dist/remote.d.ts +32 -0
  86. package/dist/remote.d.ts.map +1 -0
  87. package/dist/remote.js +117 -0
  88. package/dist/remote.js.map +1 -0
  89. package/dist/report-extras.d.ts +16 -0
  90. package/dist/report-extras.d.ts.map +1 -0
  91. package/dist/report-extras.js +125 -0
  92. package/dist/report-extras.js.map +1 -0
  93. package/dist/report-types.d.ts +428 -0
  94. package/dist/report-types.d.ts.map +1 -0
  95. package/dist/report-types.js +6 -0
  96. package/dist/report-types.js.map +1 -0
  97. package/dist/report.d.ts +15 -1
  98. package/dist/report.d.ts.map +1 -1
  99. package/dist/report.js +195 -19
  100. package/dist/report.js.map +1 -1
  101. package/dist/runner.d.ts +13 -0
  102. package/dist/runner.d.ts.map +1 -0
  103. package/dist/runner.js +11 -0
  104. package/dist/runner.js.map +1 -0
  105. package/dist/runtime.d.ts +54 -4
  106. package/dist/runtime.d.ts.map +1 -1
  107. package/dist/runtime.js +1375 -97
  108. package/dist/runtime.js.map +1 -1
  109. package/dist/schema.d.ts +29 -0
  110. package/dist/schema.d.ts.map +1 -0
  111. package/dist/schema.js +463 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/spec-api.d.ts +7 -2
  114. package/dist/spec-api.d.ts.map +1 -1
  115. package/dist/tags.d.ts +26 -0
  116. package/dist/tags.d.ts.map +1 -0
  117. package/dist/tags.js +78 -0
  118. package/dist/tags.js.map +1 -0
  119. package/dist/types.d.ts +852 -165
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +18 -5
  124. package/schemas/lxdev.schema.json +50 -0
  125. package/schemas/mock-config.schema.json +21 -0
  126. package/schemas/scenario.schema.json +180 -0
package/dist/runtime.js CHANGED
@@ -1,15 +1,35 @@
1
- import { AssertionError, expect, setAssertionSink } from "./expect.js";
2
- import { LiveFixture, TimeoutError, toReportError } from "./fixture.js";
1
+ import { matchesErrorCode } from "./deadline.js";
2
+ import { expect, setExpectScope, setStrayAssertionSink } from "./expect.js";
3
+ import { LiveFixture, SkipSignal, TimeoutError, toReportError } from "./fixture.js";
4
+ import { formatValue } from "./format.js";
3
5
  import { attachText, resolveHost, warnVersionSkew } from "./host.js";
4
- import { captureFrames, fileStem, resolveOrigin, slugTitle } from "./ids.js";
6
+ import { captureFrames, fileStem, isUnattributed, resolveOrigin, resolveOwner, slugTitle } from "./ids.js";
5
7
  import { renderJUnit } from "./junit.js";
8
+ import { resetMocks } from "./mock.js";
9
+ import { createRedactor } from "./redact.js";
6
10
  import { clearInline, countStatuses, renderHtml } from "./report.js";
11
+ import { coverageSummary, parseManifest } from "./coverage.js";
12
+ import { ContractError, ContractLedger, parseOpenApiControl, setActiveOpenApi } from "./openapi.js";
13
+ import { matchesTags, parseTagFilter, tagSummary, validateTags } from "./tags.js";
14
+ import { SCREEN_LOCKED_NOTE, VISIBILITY_PROBE_BUDGET_MS, VISIBILITY_PROBE_SCRIPT, hiddenCause, pageVisibility } from "./locator.js";
7
15
  import { DEFAULT_SPEC_TIMEOUT_MS, FORENSICS_BUDGET_MS, MAX_DEFER_BUDGET_MS, PACKAGE_NAME, VERSION, WEDGED_DEFER_BUDGET_MS, } from "./version.js";
16
+ import { cancelTimersOf, describePending, installPendingTracker, pendingOf, restoreRawAuthority, revokeGrant, revokeRawAuthority, runnerClearTimeout, runnerSetTimeout, setPendingOwner, settleCallsOf, trackAutomationRoot, uninstallPendingTracker } from "./pending.js";
17
+ function copyStepRecord(step) {
18
+ return {
19
+ ...step,
20
+ steps: step.steps.map(copyStepRecord),
21
+ assertions: step.assertions.map((entry) => ({ ...entry })),
22
+ attachments: step.attachments.map((entry) => ({ ...entry })),
23
+ error: step.error ? { ...step.error } : undefined,
24
+ };
25
+ }
8
26
  const specs = [];
9
27
  const hooks = [];
10
28
  const afterHooks = [];
11
29
  const resetHooks = [];
12
- const fileCounts = new Map();
30
+ /** Why a companion does not reset its mock handlers, said once per run. */
31
+ const mockResetReasons = new Set();
32
+ const fileConfigs = [];
13
33
  let forceRelaunchNext = false;
14
34
  let trackSurface = false;
15
35
  /**
@@ -30,37 +50,127 @@ function parseArgs(optionsOrBody, maybeBody) {
30
50
  }
31
51
  return { options: optionsOrBody, body: maybeBody };
32
52
  }
53
+ /** `restoreProfile: { keep }` → the keep globs; `true`/absent → none. */
54
+ function restoreProfileKeep(option) {
55
+ if (option === undefined || typeof option === "boolean")
56
+ return undefined;
57
+ const keep = option && typeof option === "object" ? option.keep : undefined;
58
+ if (!Array.isArray(keep) || keep.some((glob) => typeof glob !== "string" || glob.length === 0)) {
59
+ throw new TypeError("restoreProfile must be true or { keep: string[] } of non-empty storage key globs");
60
+ }
61
+ return [...keep];
62
+ }
63
+ function validateRequires(requires, where) {
64
+ if (requires === undefined)
65
+ return { args: [], openapi: false };
66
+ if (!requires || typeof requires !== "object")
67
+ throw new TypeError(`${where} requires must be an object`);
68
+ const { args, openapi } = requires;
69
+ if (args !== undefined && (!Array.isArray(args) || args.some((key) => typeof key !== "string" || key.length === 0))) {
70
+ throw new TypeError(`${where} requires.args must be an array of non-empty --arg keys`);
71
+ }
72
+ if (openapi !== undefined && typeof openapi !== "boolean")
73
+ throw new TypeError(`${where} requires.openapi must be a boolean`);
74
+ return { args: [...new Set(args ?? [])], openapi: openapi === true };
75
+ }
76
+ /** Checks what `spec()` and `spec.configure()` accept alike; throws on the first problem. */
77
+ function validateOptions(options, where) {
78
+ restoreProfileKeep(options.restoreProfile);
79
+ const start = options.start;
80
+ if (start !== undefined) {
81
+ if (!start || typeof start !== "object" || typeof start.page !== "string" || start.page.length === 0) {
82
+ throw new TypeError(`${where} start must be { page, query? } with a configured page name`);
83
+ }
84
+ if (start.query !== undefined && (!start.query || typeof start.query !== "object" || Array.isArray(start.query))) {
85
+ throw new TypeError(`${where} start.query must be an object`);
86
+ }
87
+ }
88
+ for (const [name, value] of Object.entries({ timeout: options.timeout, timeoutCleanup: options.timeoutCleanup })) {
89
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0))
90
+ throw new TypeError(`${where} ${name} must be a positive finite number`);
91
+ }
92
+ validateTags(options.tags, where);
93
+ validateRequires(options.requires, where);
94
+ if (options.covers !== undefined && (!Array.isArray(options.covers) || options.covers.some((id) => typeof id !== "string"))) {
95
+ throw new TypeError(`${where} covers must be an array of strings`);
96
+ }
97
+ }
98
+ /**
99
+ * The spec's settings: its own options over its file's `spec.configure()`
100
+ * defaults. `tags`, `covers` and `requires` add to the file's.
101
+ */
102
+ function settle(item, file) {
103
+ const own = item.own;
104
+ const pick = (key) => own[key] !== undefined ? own[key] : file?.[key];
105
+ const restoreProfile = pick("restoreProfile");
106
+ const restoreKeep = restoreProfileKeep(restoreProfile);
107
+ const fileRequires = validateRequires(file?.requires, "spec.configure()");
108
+ const ownRequires = validateRequires(own.requires, `spec ${JSON.stringify(item.title)}`);
109
+ item.covers = [...new Set([...(file?.covers ?? []), ...(own.covers ?? [])])];
110
+ item.tags = [...new Set([...validateTags(file?.tags, "spec.configure()"), ...validateTags(own.tags, `spec ${JSON.stringify(item.title)}`)])];
111
+ item.timeout = pick("timeout") ?? DEFAULT_SPEC_TIMEOUT_MS;
112
+ item.timeoutCleanup = pick("timeoutCleanup");
113
+ item.start = pick("start");
114
+ item.restoreProfile = restoreProfile === true || restoreKeep !== undefined;
115
+ item.restoreKeep = restoreKeep;
116
+ item.app = pick("app");
117
+ item.forensics = pick("forensics") !== false;
118
+ item.reason = pick("reason");
119
+ item.requires = {
120
+ args: [...new Set([...fileRequires.args, ...ownRequires.args])],
121
+ openapi: fileRequires.openapi || ownRequires.openapi,
122
+ };
123
+ }
124
+ /** Why the run cannot give the spec what it `requires`, or `undefined`. */
125
+ function unmetRequirements(item, args, openapi) {
126
+ const missing = item.requires.args.filter((key) => args[key] === undefined);
127
+ const needs = [];
128
+ if (missing.length > 0) {
129
+ needs.push(missing.map((key) => `--arg ${key}=<value>`).join(", ") + " (or --secret-arg)");
130
+ }
131
+ if (item.requires.openapi && !openapi)
132
+ needs.push("--openapi <document>");
133
+ return needs.length === 0 ? undefined : `Not run: requires ${needs.join(" and ")}.`;
134
+ }
33
135
  function register(annotation, title, optionsOrBody, maybeBody) {
34
136
  if (typeof title !== "string" || title.length === 0) {
35
137
  throw new TypeError("spec() requires a non-empty title");
36
138
  }
37
139
  const { options, body } = parseArgs(optionsOrBody, maybeBody);
38
- for (const [name, value] of Object.entries({ timeout: options.timeout, timeoutCleanup: options.timeoutCleanup })) {
39
- if (value !== undefined && (!Number.isFinite(value) || value <= 0))
40
- throw new TypeError(`${name} must be a positive finite number`);
140
+ const expected = options.expected;
141
+ if (expected !== undefined) {
142
+ if (annotation !== "fail")
143
+ throw new TypeError("`expected` is only meaningful for spec.fail()");
144
+ if (!expected || typeof expected !== "object")
145
+ throw new TypeError("spec.fail `expected` must be an object");
146
+ if (expected.code === undefined && expected.message === undefined) {
147
+ throw new TypeError("spec.fail `expected` needs a code or a message");
148
+ }
41
149
  }
150
+ validateOptions(options, `spec ${JSON.stringify(title)}`);
42
151
  // The bundle map is installed after the modules run, so keep the raw frames;
43
152
  // the authored file is only knowable once the run starts. `frames[0]` is a
44
153
  // frame inside this package, identical for every caller, so it can order
45
154
  // registrations but must never stand in for identity.
46
155
  const frames = captureFrames();
47
- const indexInFile = fileCounts.size + 1;
48
- fileCounts.set(String(indexInFile), indexInFile);
49
- specs.push({
156
+ const item = {
50
157
  title,
51
158
  id: options.id,
52
- covers: [...(options.covers ?? [])],
53
- timeout: options.timeout ?? DEFAULT_SPEC_TIMEOUT_MS,
54
- timeoutCleanup: options.timeoutCleanup,
55
- fresh: options.fresh === true,
56
- app: options.app,
57
- forensics: options.forensics !== false,
58
- reason: options.reason,
159
+ covers: [],
160
+ tags: [],
161
+ timeout: DEFAULT_SPEC_TIMEOUT_MS,
162
+ restoreProfile: false,
163
+ forensics: true,
164
+ requires: { args: [], openapi: false },
165
+ own: { ...options },
166
+ expected,
59
167
  annotation,
60
168
  body,
61
169
  frames,
62
- indexInFile,
63
- });
170
+ };
171
+ // Settled again at run start, with the file's defaults.
172
+ settle(item, undefined);
173
+ specs.push(item);
64
174
  }
65
175
  const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
66
176
  register("default", title, optionsOrBody, maybeBody);
@@ -96,17 +206,84 @@ const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
96
206
  throw new TypeError("spec.beforeEach() requires a function");
97
207
  hooks.push({ frames: captureFrames(), fn });
98
208
  },
209
+ configure(options) {
210
+ if (!options || typeof options !== "object")
211
+ throw new TypeError("spec.configure() requires an options object");
212
+ if (options.id !== undefined)
213
+ throw new TypeError("spec.configure() cannot set `id`: ids are per spec");
214
+ validateOptions(options, "spec.configure()");
215
+ fileConfigs.push({ frames: captureFrames(), options: { ...options } });
216
+ },
99
217
  });
218
+ /** Two `spec.configure()` calls of one file: later keys win; lists add up. */
219
+ function mergeFileOptions(previous, next) {
220
+ if (!previous)
221
+ return { ...next };
222
+ const merged = { ...previous, ...Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)) };
223
+ merged.tags = [...new Set([...(previous.tags ?? []), ...(next.tags ?? [])])];
224
+ merged.covers = [...new Set([...(previous.covers ?? []), ...(next.covers ?? [])])];
225
+ merged.requires = {
226
+ args: [...new Set([...(previous.requires?.args ?? []), ...(next.requires?.args ?? [])])],
227
+ openapi: previous.requires?.openapi === true || next.requires?.openapi === true,
228
+ };
229
+ return merged;
230
+ }
100
231
  function sourceOf(item) {
101
232
  const origin = resolveOrigin(item.frames);
102
233
  return { file: origin.file, line: origin.line };
103
234
  }
235
+ /**
236
+ * Every spec, `spec.configure()` and hook must resolve to the file that
237
+ * registered it: a file-less spec loses its file's tags and hooks without a
238
+ * word, so the run stops and names what it could not place.
239
+ */
240
+ function assertAttributed() {
241
+ const lost = [];
242
+ for (const item of specs) {
243
+ const origin = resolveOrigin(item.frames);
244
+ if (isUnattributed(origin.file))
245
+ lost.push(`spec ${JSON.stringify(item.title)} (${origin.file}:${origin.line})`);
246
+ }
247
+ const specFiles = new Set(specs.map((item) => sourceOf(item).file));
248
+ for (const [kind, list] of [["configure", fileConfigs], ["beforeEach", hooks], ["afterEach", afterHooks], ["reset", resetHooks]]) {
249
+ for (const entry of list) {
250
+ const owner = resolveOwner(entry.frames, specFiles);
251
+ if (isUnattributed(owner.file))
252
+ lost.push(`spec.${kind}() (${owner.file}:${owner.line})`);
253
+ }
254
+ }
255
+ if (lost.length === 0)
256
+ return;
257
+ const shown = lost.slice(0, 10).join(", ") + (lost.length > 10 ? `, and ${lost.length - 10} more` : "");
258
+ throw new Error(`Cannot tell which file registered ${shown}: the bundle source map has no position for the call. ` +
259
+ "Its file's tags and hooks would not apply, so the run stops. Please report this with the spec file and the lxdev version.");
260
+ }
261
+ function needsGeneratedId(item) {
262
+ return !(item.id && item.id.length > 0) && slugTitle(item.title) === undefined;
263
+ }
264
+ /**
265
+ * Number the specs that need a generated id, per file and in registration
266
+ * order, so adding a spec to one file never renames a spec in another.
267
+ */
268
+ function assignFileIndexes() {
269
+ const counts = new Map();
270
+ for (const item of specs) {
271
+ if (!needsGeneratedId(item))
272
+ continue;
273
+ const file = sourceOf(item).file;
274
+ const next = (counts.get(file) ?? 0) + 1;
275
+ counts.set(file, next);
276
+ item.indexInFile = next;
277
+ }
278
+ }
104
279
  function resolvedId(item) {
105
280
  if (item.id && item.id.length > 0)
106
281
  return item.id;
107
282
  const slug = slugTitle(item.title);
108
283
  if (slug)
109
284
  return slug;
285
+ if (item.indexInFile === undefined)
286
+ assignFileIndexes();
110
287
  return `${fileStem(sourceOf(item).file)}-${item.indexInFile}`;
111
288
  }
112
289
  /** Report group: the authored file path, trimmed to the project-relative part. */
@@ -116,28 +293,219 @@ function suiteOf(file) {
116
293
  const cut = trimmed.lastIndexOf("/tests/");
117
294
  return cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
118
295
  }
296
+ /**
297
+ * During a run, the host's own `lx.automation`: the global is replaced with
298
+ * one that hands spec code `rawAutomation()`'s fenced root, so a spec cannot
299
+ * reach around its grant through the global.
300
+ */
301
+ let hostAutomation;
302
+ function fenceGlobalAutomation() {
303
+ const lx = globalThis.lx;
304
+ if (hostAutomation || !lx || typeof lx.automation !== "function")
305
+ return;
306
+ const automation = lx.automation;
307
+ hostAutomation = { lx: lx, automation };
308
+ try {
309
+ lx.automation = () => trackAutomationRoot(automation.call(lx));
310
+ }
311
+ catch {
312
+ hostAutomation = undefined;
313
+ }
314
+ }
315
+ function unfenceGlobalAutomation() {
316
+ if (!hostAutomation)
317
+ return;
318
+ try {
319
+ hostAutomation.lx.automation = hostAutomation.automation;
320
+ }
321
+ catch { /* the context is going away */ }
322
+ hostAutomation = undefined;
323
+ }
119
324
  function automationRoot() {
325
+ if (hostAutomation)
326
+ return hostAutomation.automation.call(hostAutomation.lx);
327
+ // The host injects the root as `lx.automation()` in the test context; that
328
+ // binding is the transport, not a typed global of the test program, so a
329
+ // spec that type-checks app Logic sees one meaning of `lx`: the app's.
120
330
  const lx = globalThis.lx;
121
331
  if (!lx || typeof lx.automation !== "function") {
122
- throw new Error("lx.automation() is not available in this runtime");
332
+ throw new Error("The automation root is not available: rawAutomation() runs only inside an lxdev test run");
123
333
  }
124
334
  return lx.automation();
125
335
  }
336
+ /**
337
+ * The untraced automation root of this run, with host authority. Specs drive
338
+ * the app through `t.app` / `t.automation`, which trace every call and stop
339
+ * with the spec; use this only where no fixture exists (a setup file that
340
+ * waits for the app before the first spec) or to reach the raw driver, e.g.
341
+ * `rawAutomation().lxapp().eval({ script })`.
342
+ */
343
+ export function rawAutomation() {
344
+ // Refuses once the run has revoked spec code's automation access.
345
+ return trackAutomationRoot(automationRoot());
346
+ }
126
347
  function pinApp(appId) {
127
348
  const automation = automationRoot();
128
349
  return appId ? automation.lxapp(appId) : automation.lxapp();
129
350
  }
130
- async function relaunchHome(app) {
131
- const pages = await app.pages();
132
- const home = pages[0]?.name ?? "home";
133
- await app.nav.relaunch({ page: home });
351
+ /** How long a closing app under test gets to finish closing before it is reopened. */
352
+ const REOPEN_CLOSING_WAIT_MS = 5000;
353
+ /**
354
+ * Reopen the app under test when it is no longer running, and wait for its
355
+ * home page. Resolves `undefined` when it was running (or cannot be told);
356
+ * otherwise what happened, with the error if reopening failed.
357
+ */
358
+ async function reopenAppUnderTest(appId) {
359
+ if (!appId)
360
+ return undefined;
361
+ let root;
362
+ try {
363
+ root = automationRoot();
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ const status = async () => {
369
+ const apps = await root.lxapps.list();
370
+ return apps.find((app) => app.appId === appId)?.status;
371
+ };
372
+ try {
373
+ let current = await status();
374
+ const deadline = Date.now() + REOPEN_CLOSING_WAIT_MS;
375
+ while (current === "closing" && Date.now() < deadline) {
376
+ await new Promise((resolve) => { runnerSetTimeout(resolve, 100); });
377
+ current = await status();
378
+ }
379
+ if (current === "opened" || current === "opening" || current === "restarting")
380
+ return undefined;
381
+ }
382
+ catch {
383
+ // A host without `lxapps` cannot say; leave the app alone.
384
+ return undefined;
385
+ }
386
+ try {
387
+ await root.lxapps.open({ appId });
388
+ await relaunchHome(root.lxapp(appId));
389
+ return { appId };
390
+ }
391
+ catch (error) {
392
+ return { appId, error: String(error?.message ?? error) };
393
+ }
394
+ }
395
+ /** How long recovering the app under test may take before the run stops. */
396
+ const RECOVERY_BUDGET_MS = 20000;
397
+ const RECOVER_COMMAND = "lxdev lxapp restart";
398
+ /**
399
+ * After a spec left work pending: the same recovery a spec start and the run
400
+ * start use — reopen the app if it is closed, then relaunch its home page.
401
+ * Resolves `undefined` when the app is back, otherwise why it is not.
402
+ */
403
+ async function recoverAppUnderTest(appId) {
404
+ const reopened = await reopenAppUnderTest(appId);
405
+ if (reopened?.error)
406
+ return reopened.error;
407
+ try {
408
+ await within(relaunchHome(pinApp(appId)), RECOVERY_BUDGET_MS, `relaunching the home page did not finish within ${RECOVERY_BUDGET_MS}ms`);
409
+ return undefined;
410
+ }
411
+ catch (error) {
412
+ return String(error?.message ?? error);
413
+ }
134
414
  }
135
- async function run() {
415
+ /** Relaunch on the spec's `start` page, or the app's home page. */
416
+ async function relaunchHome(app, start) {
417
+ const page = start?.page ?? (await app.pages())[0]?.name ?? "home";
418
+ try {
419
+ await app.nav.relaunch({ page, ...(start?.query ? { query: start.query } : {}), waitUntil: "ready" });
420
+ }
421
+ catch (error) {
422
+ // The home page may hand off to another page itself (a gate, a login
423
+ // redirect); that is the app's own start, not a failed relaunch.
424
+ if (!/was disposed before runtime became ready/.test(String(error?.message ?? error)))
425
+ throw error;
426
+ }
427
+ }
428
+ /**
429
+ * Controls that carry file contents: the report names the files
430
+ * (`openapiFiles`, `coversManifestFile`) instead of repeating them.
431
+ */
432
+ const PAYLOAD_CONTROL_KEYS = ["openapi", "coversManifest"];
433
+ /**
434
+ * The run budget lxdev asked for: a fixed `budgetMs`, or scaled to the
435
+ * planned executions — `max(budgetMinMs, planned × budgetPerSpecMs)`, capped
436
+ * at `budgetMaxMs`. `undefined` when lxdev sent neither (an older lxdev: the
437
+ * host's own deadline is the only budget).
438
+ */
439
+ export function runBudget(control, planned) {
440
+ const fixed = Number(control.budgetMs);
441
+ if (control.budgetMs !== undefined && Number.isFinite(fixed) && fixed > 0)
442
+ return { ms: fixed, auto: false };
443
+ const perSpec = Number(control.budgetPerSpecMs);
444
+ if (control.budgetPerSpecMs === undefined || !Number.isFinite(perSpec) || perSpec <= 0)
445
+ return undefined;
446
+ const min = Number(control.budgetMinMs ?? 0);
447
+ const max = Number(control.budgetMaxMs ?? Number.POSITIVE_INFINITY);
448
+ const scaled = Math.max(Number.isFinite(min) ? min : 0, planned * perSpec);
449
+ return { ms: Math.min(scaled, Number.isFinite(max) && max > 0 ? max : scaled), auto: true };
450
+ }
451
+ /** Seeded Fisher–Yates (mulberry32), so a printed seed reproduces the order. */
452
+ export function shuffleWithSeed(items, seed) {
453
+ let state = seed >>> 0;
454
+ const next = () => {
455
+ state = (state + 0x6d2b79f5) >>> 0;
456
+ let t = state;
457
+ t = Math.imul(t ^ (t >>> 15), t | 1);
458
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
459
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
460
+ };
461
+ const out = [...items];
462
+ for (let i = out.length - 1; i > 0; i -= 1) {
463
+ const j = Math.floor(next() * (i + 1));
464
+ [out[i], out[j]] = [out[j], out[i]];
465
+ }
466
+ return out;
467
+ }
468
+ /** `secretArgs` is a JSON list of `--secret-arg` keys. */
469
+ function secretKeys(listed) {
470
+ if (!listed)
471
+ return [];
472
+ let parsed;
473
+ try {
474
+ parsed = JSON.parse(listed);
475
+ }
476
+ catch {
477
+ throw new Error(`the host's secretArgs control is not a JSON list: ${JSON.stringify(listed)}`);
478
+ }
479
+ if (!Array.isArray(parsed)) {
480
+ throw new Error(`the host's secretArgs control is not a JSON list: ${JSON.stringify(listed)}`);
481
+ }
482
+ return parsed.filter((key) => typeof key === "string");
483
+ }
484
+ /** Run the selected specs. */
485
+ function run() {
486
+ return runSpecs(false);
487
+ }
488
+ /** `lxdev test --list`: the selected specs, in `listed`; nothing runs. */
489
+ function list() {
490
+ return runSpecs(true);
491
+ }
492
+ async function runSpecs(listOnly) {
136
493
  var _a;
137
494
  warnVersionSkew();
138
- const host = resolveHost();
495
+ const rawHost = resolveHost();
496
+ const { args, control } = rawHost;
497
+ // Secret args reach the spec through `t.arg()` but never an event or report.
498
+ const redact = createRedactor(args, secretKeys(control.secretArgs));
499
+ const host = {
500
+ ...rawHost,
501
+ args,
502
+ emit: (event) => rawHost.emit(redact.deep(event)),
503
+ };
139
504
  const started = Date.now();
140
505
  clearInline();
506
+ contractSeq = 0;
507
+ assertAttributed();
508
+ assignFileIndexes();
141
509
  const ids = new Map();
142
510
  for (const item of specs) {
143
511
  const id = resolvedId(item);
@@ -147,44 +515,105 @@ async function run() {
147
515
  }
148
516
  ids.set(id, item.title);
149
517
  }
150
- const grep = host.args.grep;
518
+ const grep = control.grep;
519
+ const recordNetwork = control.recordNetwork === "1";
520
+ if (recordNetwork && !host.networkRecord)
521
+ throw new Error("--record-network needs a host that records network traffic");
151
522
  const pattern = grep ? new RegExp(grep) : undefined;
152
- const forbidOnly = host.args.forbidOnly === "1" || host.args["forbid-only"] === "1";
523
+ const forbidOnly = control.forbidOnly === "1" || control["forbid-only"] === "1";
153
524
  const hasOnly = specs.some((item) => item.annotation === "only");
154
525
  if (hasOnly && forbidOnly) {
155
526
  throw new Error("spec.only is registered; lxdev test --forbid-only refuses to run");
156
527
  }
157
- const retries = Number(host.args.retries ?? 0);
528
+ const tagFilter = control.tags ? parseTagFilter(JSON.parse(control.tags)) : [];
529
+ const manifest = parseManifest(control.coversManifest);
530
+ const openapi = parseOpenApiControl(control.openapi);
531
+ setActiveOpenApi(openapi);
532
+ const ledger = openapi ? new ContractLedger(openapi) : undefined;
533
+ const specFiles = new Set(specs.map((item) => sourceOf(item).file));
534
+ const fileOptions = new Map();
535
+ for (const config of fileConfigs) {
536
+ const file = resolveOwner(config.frames, specFiles).file;
537
+ if (!specFiles.has(file)) {
538
+ await host.emit({ type: "diagnostic", phase: "collect",
539
+ message: `spec.configure() called from ${file} applies to no spec: no spec file is on its call stack. Call it from a spec file's top level.` });
540
+ continue;
541
+ }
542
+ fileOptions.set(file, mergeFileOptions(fileOptions.get(file), config.options));
543
+ }
544
+ for (const item of specs)
545
+ settle(item, fileOptions.get(sourceOf(item).file));
546
+ const tagsOf = (item) => item.tags;
547
+ const retries = Number(control.retries ?? 0);
158
548
  if (!Number.isInteger(retries) || retries < 0 || retries > 10)
159
549
  throw new Error("retries must be between 0 and 10");
160
- const selectedIds = host.args.ids ? JSON.parse(host.args.ids) : undefined;
161
- const shard = host.args.shard?.split("/").map(Number);
550
+ const selectedIds = control.ids ? JSON.parse(control.ids) : undefined;
551
+ const shard = control.shard?.split("/").map(Number);
162
552
  if (shard && (shard.length !== 2 || shard.some(n => !Number.isInteger(n) || n < 1) || shard[0] > shard[1]))
163
553
  throw new Error("shard must be INDEX/TOTAL (1-based)");
554
+ const locations = parseLocations(control.locations);
164
555
  const selected = specs.filter((item) => {
165
556
  if (hasOnly && item.annotation !== "only")
166
557
  return false;
558
+ if (locations && !atLocation(sourceOf(item), locations))
559
+ return false;
167
560
  if (selectedIds && !selectedIds.includes(resolvedId(item)))
168
561
  return false;
169
562
  if (shard && stableHash(resolvedId(item)) % shard[1] !== shard[0] - 1)
170
563
  return false;
171
- if (host.args.id && resolvedId(item) !== host.args.id)
564
+ if (control.id && resolvedId(item) !== control.id)
565
+ return false;
566
+ if (tagFilter.length > 0 && !matchesTags(tagsOf(item), tagFilter))
172
567
  return false;
173
568
  if (!grep)
174
569
  return true;
175
570
  const id = resolvedId(item);
176
571
  return pattern.test(item.title) || pattern.test(id);
177
572
  });
178
- if (selected.length === 0 && host.args.passWithNoTests !== "1") {
573
+ if (listOnly) {
574
+ return listing(selected.map((item) => ({ id: resolvedId(item), title: item.title, ...sourceOf(item), tags: tagsOf(item) })));
575
+ }
576
+ if (selected.length === 0 && control.passWithNoTests !== "1") {
179
577
  throw new Error("No tests matched this selection. Check the entry and filters, or use --pass-with-no-tests.");
180
578
  }
181
- await host.emit({ type: "run_started", schema_version: 1, total: selected.length,
182
- cases: selected.map(item => ({ id: resolvedId(item), title: item.title, name: item.title,
183
- full_name: item.id ? `${item.id} | ${item.title}` : item.title, ...sourceOf(item),
184
- suite: suiteOf(sourceOf(item).file), timeout_ms: item.timeout, covers: item.covers })) });
185
- // Resolve each hook's authored file once, so a `beforeEach` stays scoped to
186
- // the file that declared it rather than running for every spec in the run.
187
- const hookFiles = new Map([...hooks, ...afterHooks, ...resetHooks].map((hook) => [hook, resolveOrigin(hook.frames).file]));
579
+ const repeatEach = Number(control.repeatEach ?? 1);
580
+ if (!Number.isInteger(repeatEach) || repeatEach < 1 || repeatEach > 100)
581
+ throw new Error("repeatEach must be between 1 and 100");
582
+ const shuffleSeed = control.shuffle === undefined ? undefined : Number(control.shuffle);
583
+ if (shuffleSeed !== undefined && (!Number.isInteger(shuffleSeed) || shuffleSeed < 0))
584
+ throw new Error("shuffle seed must be a non-negative integer");
585
+ const repeated = selected.flatMap((item) => Array.from({ length: repeatEach }, (_, index) => ({ item, repeat: index + 1 })));
586
+ const plan = shuffleSeed === undefined ? repeated : shuffleWithSeed(repeated, shuffleSeed);
587
+ const budget = runBudget(control, plan.length);
588
+ const fullName = (item, repeat) => (item.id ? `${item.id} | ${item.title}` : item.title) + (repeatEach > 1 ? ` [repeat ${repeat}/${repeatEach}]` : "");
589
+ // `args` is the masked view, so lxdev can write the same one into any
590
+ // report it has to complete itself.
591
+ await host.emit({ type: "run_started", schema_version: 1, total: plan.length, args: redact.args(args),
592
+ cases: plan.map(({ item, repeat }) => ({ id: resolvedId(item), title: item.title, name: item.title,
593
+ full_name: fullName(item, repeat), ...sourceOf(item), ...(repeatEach > 1 ? { repeat } : {}),
594
+ suite: suiteOf(sourceOf(item).file), timeout_ms: item.timeout, covers: item.covers, tags: tagsOf(item) })) });
595
+ if (manifest) {
596
+ const known = new Set(manifest.map((entry) => entry.id));
597
+ const stray = [...new Set(specs.flatMap((item) => item.covers).filter((id) => !known.has(id)))];
598
+ if (stray.length > 0) {
599
+ await host.emit({ type: "diagnostic", phase: "coverage",
600
+ message: `${stray.length} covers id(s) not in the coverage manifest: ${stray.slice(0, 10).join(", ")}${stray.length > 10 ? ", …" : ""}` });
601
+ }
602
+ }
603
+ // Resolve each hook's spec file once, so a `beforeEach` stays scoped to the
604
+ // file that registered it rather than running for every spec in the run. A
605
+ // hook registered by a shared helper belongs to the spec file that called
606
+ // the helper (the nearest spec file on its registration stack).
607
+ const hookFiles = new Map([...hooks, ...afterHooks, ...resetHooks].map((hook) => [hook, resolveOwner(hook.frames, specFiles).file]));
608
+ for (const [kind, list] of [["beforeEach", hooks], ["afterEach", afterHooks], ["reset", resetHooks]]) {
609
+ for (const hook of list) {
610
+ const file = hookFiles.get(hook);
611
+ if (specFiles.has(file))
612
+ continue;
613
+ await host.emit({ type: "diagnostic", phase: "collect",
614
+ message: `spec.${kind}() registered from ${file} never runs: no spec file is on its call stack. Call it (or the helper that registers it) from a spec file's top level.` });
615
+ }
616
+ }
188
617
  if (retries > 0) {
189
618
  for (const item of selected.filter(item => !["skip", "fixme"].includes(item.annotation))) {
190
619
  if (!resetHooks.some(hook => hookFiles.get(hook) === sourceOf(item).file)) {
@@ -195,29 +624,60 @@ async function run() {
195
624
  const subject = await describeSubject();
196
625
  const cases = [];
197
626
  forceRelaunchNext = false;
627
+ // Set only when the run cannot go on safely: recovering the app after a
628
+ // spec left work pending failed, or a restoreProfile rollback did not run.
198
629
  let contaminated = false;
199
- const queue = [...selected];
630
+ let contaminationReason;
631
+ installPendingTracker();
632
+ restoreRawAuthority();
633
+ fenceGlobalAutomation();
634
+ // Late assertions are charged to the spec the run last abandoned, never
635
+ // to the one running.
636
+ let lastAbandoned;
637
+ setStrayAssertionSink(strayAssertionSink(host, () => lastAbandoned));
638
+ const queue = [...plan];
200
639
  const attempts = new Map();
201
- for (const item of queue) {
640
+ const executionKey = (id, repeat) => `${id}#${repeat}`;
641
+ let budgetExhausted;
642
+ // Set once, when a spec is about to start on a locked screen: every page is
643
+ // hidden until someone unlocks it, which the run cannot do.
644
+ let screenLocked;
645
+ const executed = () => new Set(cases.map((done) => executionKey(done.id, done.repeat ?? 1))).size;
646
+ for (const planned of queue) {
647
+ const { item, repeat } = planned;
202
648
  const id = resolvedId(item);
649
+ const key = executionKey(id, repeat);
203
650
  const timeout = item.timeout;
204
651
  const source = sourceOf(item);
652
+ // Checked between specs: a spec already running keeps its own timeout.
653
+ if (!budgetExhausted && budget && Date.now() - started >= budget.ms) {
654
+ budgetExhausted = { after: executed() };
655
+ await host.emit({ type: "diagnostic", phase: "budget",
656
+ message: `Run budget of ${Math.round(budget.ms / 1000)}s exhausted after ${budgetExhausted.after}/${plan.length} specs; the rest are reported as not run. Raise --timeout-secs, or split the suite with --shard.` });
657
+ }
658
+ if (!budgetExhausted && !contaminated && !screenLocked && host.screenLocked()) {
659
+ screenLocked = { after: executed() };
660
+ await host.emit({ type: "diagnostic", phase: "screen_locked",
661
+ message: `${capitalize(SCREEN_LOCKED_NOTE)}. Stopped after ${screenLocked.after}/${plan.length} specs; the rest are reported as not run.` });
662
+ }
205
663
  const record = {
206
664
  id,
207
665
  title: item.title,
208
666
  name: item.title,
209
- full_name: item.id ? `${item.id} | ${item.title}` : item.title,
667
+ full_name: fullName(item, repeat),
668
+ ...(repeatEach > 1 ? { repeat } : {}),
210
669
  file: source.file,
211
670
  line: source.line,
212
671
  suite: suiteOf(source.file),
213
672
  status: "passed",
214
673
  duration_ms: 0,
215
674
  covers: [...item.covers],
675
+ tags: tagsOf(item),
216
676
  steps: [],
217
677
  assertions: [],
218
678
  attachments: [],
219
679
  timeout_ms: timeout,
220
- attempt: attempts.get(id)?.length ?? 0,
680
+ attempt: attempts.get(key)?.length ?? 0,
221
681
  reason: item.reason,
222
682
  };
223
683
  await host.emit({
@@ -228,26 +688,127 @@ async function run() {
228
688
  timeout_ms: timeout,
229
689
  watchdog_timeout_ms: timeout + (item.timeoutCleanup ?? MAX_DEFER_BUDGET_MS) + FORENSICS_BUDGET_MS + WEDGED_DEFER_BUDGET_MS,
230
690
  covers: record.covers,
691
+ tags: record.tags,
231
692
  });
232
693
  const caseStarted = Date.now();
233
- if (contaminated || item.annotation === "skip" || item.annotation === "fixme") {
694
+ const unmet = item.annotation === "skip" || item.annotation === "fixme"
695
+ ? undefined
696
+ : unmetRequirements(item, args, openapi !== undefined);
697
+ if (budgetExhausted || contaminated || screenLocked || unmet !== undefined || item.annotation === "skip" || item.annotation === "fixme") {
234
698
  if (contaminated)
235
- record.reason = "Not run: a previous spec left asynchronous work pending; restart the run.";
699
+ record.reason = contaminationReason ?? `Not run: an earlier spec left the run unable to continue. Recover: ${RECOVER_COMMAND}`;
700
+ else if (budgetExhausted && budget)
701
+ record.reason = `Not run: the run budget of ${Math.round(budget.ms / 1000)}s was exhausted after ${budgetExhausted.after}/${plan.length} specs.`;
702
+ else if (screenLocked)
703
+ record.reason = `Not run: ${SCREEN_LOCKED_NOTE}.`;
704
+ else if (unmet !== undefined)
705
+ record.reason = unmet;
236
706
  record.status = "skipped";
237
707
  record.duration_ms = Date.now() - caseStarted;
238
708
  cases.push(record);
239
709
  await finishCase(host, record);
240
710
  continue;
241
711
  }
242
- const fixture = new LiveFixture(`${encodeURIComponent(id)}/attempt-${record.attempt}`, pinApp(item.app), host, host.args, automationRoot(), timeout);
712
+ // One spec that leaves the app under test closed (a crash, a dev reload,
713
+ // Logic torn down after a hung eval) must not fail every spec after it.
714
+ const reopenStarted = Date.now();
715
+ const reopened = await reopenAppUnderTest(item.app ?? subject?.appid);
716
+ if (reopened) {
717
+ const previous = cases[cases.length - 1];
718
+ await host.emit({ type: "diagnostic", phase: reopened.error ? "recovery_failed" : "recovery",
719
+ message: `The app under test (${reopened.appId}) was not running before "${item.title}"` +
720
+ (previous ? `; it stopped during or after "${previous.title}" (${previous.status})` : "") +
721
+ `. Reopened it on its home page${reopened.error ? `, which failed: ${reopened.error}` : ""}.` });
722
+ }
723
+ // What the spec installs from here on — routes, a mock scenario, test
724
+ // clocks — belongs to this attempt; the host removes it when the
725
+ // attempt ends, before the next spec starts.
726
+ const attempt = host.beginAttempt?.();
727
+ const fixture = new LiveFixture(`${encodeURIComponent(id)}${repeatEach > 1 ? `/repeat-${repeat}` : ""}/attempt-${record.attempt}`,
728
+ // The app under test by id: "current" follows whichever app a spec
729
+ // brought to the front.
730
+ pinApp(item.app ?? subject?.appid), host, args, automationRoot(), timeout, redact, item.app ?? subject?.appid, caseStarted);
731
+ if (reopened) {
732
+ fixture.steps.push({ name: "app.reopen", kind: "action", path: "app.reopen",
733
+ detail: `${reopened.appId} was not running`, status: reopened.error ? "failed" : "passed",
734
+ duration_ms: Date.now() - reopenStarted, steps: [], attachments: [], assertions: [] });
735
+ }
736
+ // Toasts are recorded and modals / action sheets answered for this spec
737
+ // only; the attempt's end removes the watch whatever happens below.
738
+ let dialogSetupError;
739
+ try {
740
+ await fixture.watchPrimaryDialogs();
741
+ }
742
+ catch (error) {
743
+ dialogSetupError = error ?? new Error("dialog watch failed");
744
+ }
243
745
  let status = "passed";
244
746
  let error;
245
747
  let phase = "body";
246
- const shouldRelaunch = item.fresh || forceRelaunchNext;
748
+ let recordingNetwork = false;
749
+ let recordingSetupError;
750
+ if (recordNetwork) {
751
+ try {
752
+ await startNetworkRecording(host);
753
+ recordingNetwork = true;
754
+ }
755
+ catch (startError) {
756
+ recordingSetupError = startError;
757
+ }
758
+ }
759
+ const shouldRelaunch = item.start !== undefined || item.restoreProfile || forceRelaunchNext;
247
760
  forceRelaunchNext = false;
761
+ // `restoreProfile`: snapshot the isolated profile now and roll back to it
762
+ // after the spec. Registered as the first cleanup, so it runs last.
763
+ let profileRestored = false;
764
+ // Timers and fetches from here on belong to this spec, so one it leaves
765
+ // pending can be named.
766
+ const grant = setPendingOwner(record.full_name);
767
+ const stuckWork = () => [
768
+ ...fixture.pendingCalls().map((work) => ({ ...work, owner: record.full_name })),
769
+ ...pendingOf(record.full_name),
770
+ ];
771
+ // Work this spec left pending when the run stopped waiting for it.
772
+ // `spec`: spec code (its body, cleanup or calls it did not await) may
773
+ // still run; the runner's own evidence capture is not spec code.
774
+ let stuck;
775
+ // The run gives up on spec code: from here its raw drivers refuse, and
776
+ // what it may still do is decided below.
777
+ const abandon = (what, work) => {
778
+ stuck ?? (stuck = { what, work, spec: true });
779
+ revokeGrant(grant, `"${record.full_name}" was abandoned (${what})`);
780
+ };
248
781
  const bodyPromise = (async () => {
782
+ // Each spec starts with fresh mock handler state, before its hooks.
783
+ phase = "beforeEach";
784
+ if (dialogSetupError !== undefined)
785
+ throw dialogSetupError;
786
+ if (recordingSetupError !== undefined)
787
+ throw recordingSetupError;
788
+ if (ledger) {
789
+ await within(Promise.resolve(fixture.raw.network.captureResponses()), CONTRACT_CALL_MS, "captureResponses timed out");
790
+ }
791
+ const mockNote = await resetMocks(fixture.raw, mockResetReasons).catch((error) => `mock handler state was not reset: ${String(error?.message ?? error)}`);
792
+ if (mockNote)
793
+ await host.emit({ type: "diagnostic", phase: "mock", message: mockNote });
794
+ phase = "body";
795
+ if (item.restoreProfile) {
796
+ phase = "beforeEach";
797
+ const checkpoint = await fixture.app.profile.checkpoint();
798
+ fixture.defer(async () => {
799
+ try {
800
+ await fixture.app.profile.restore(checkpoint, item.restoreKeep ? { keep: item.restoreKeep } : undefined);
801
+ profileRestored = true;
802
+ }
803
+ finally {
804
+ // A failed rollback must not leave its copy on disk for the rest of the run.
805
+ await Promise.resolve(fixture.app.profile.drop(checkpoint)).catch(() => { });
806
+ }
807
+ });
808
+ phase = "body";
809
+ }
249
810
  if (shouldRelaunch)
250
- await relaunchHome(fixture.raw);
811
+ await relaunchHome(fixture.raw, item.start);
251
812
  phase = "beforeEach";
252
813
  for (const hook of resetHooks) {
253
814
  if (hookFiles.get(hook) === source.file)
@@ -267,15 +828,32 @@ async function run() {
267
828
  void bodyResult.then(() => { bodySettled = true; });
268
829
  let timerHandle;
269
830
  const timer = new Promise((resolve) => {
270
- timerHandle = setTimeout(() => {
831
+ timerHandle = runnerSetTimeout(() => {
271
832
  timedOut = true;
272
833
  fixture.abort(timeoutError);
273
834
  resolve("timeout");
274
835
  }, timeout);
275
836
  });
837
+ // A modal or action sheet with no answer queued fails the spec at once,
838
+ // instead of leaving it waiting on a dialog nobody answers.
839
+ const unanswered = fixture.dialogFailure.then((message) => ({ unanswered: message }));
276
840
  try {
277
- const winner = await Promise.race([bodyResult, timer]);
278
- if (winner === "timeout") {
841
+ const winner = await Promise.race([bodyResult, timer, unanswered]);
842
+ if (typeof winner === "object" && "unanswered" in winner) {
843
+ const dialogError = new Error(`Unanswered dialog: ${winner.unanswered}`);
844
+ status = "failed";
845
+ error = dialogError;
846
+ fixture.abort(dialogError, "failed");
847
+ try {
848
+ await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after an unanswered dialog");
849
+ }
850
+ catch {
851
+ const work = stuckWork();
852
+ abandon("its body after an unanswered dialog", work);
853
+ dialogError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
854
+ }
855
+ }
856
+ else if (winner === "timeout") {
279
857
  status = "timeout";
280
858
  error = timeoutError;
281
859
  phase = "timeout";
@@ -284,28 +862,56 @@ async function run() {
284
862
  await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after timeout");
285
863
  }
286
864
  catch {
287
- contaminated = true;
865
+ const work = stuckWork();
866
+ abandon("its timed-out body", work);
867
+ timeoutError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
288
868
  }
289
869
  }
290
870
  else if (!winner.ok) {
871
+ // The first error wins; calls the body left running are stopped
872
+ // before cleanup all the same.
873
+ if (fixture.unsettledCalls().length > 0 && !timedOut && !fixture.aborted &&
874
+ !await fixture.stopUnsettled(winner.err instanceof Error ? winner.err : new Error(String(winner.err)), WEDGED_DEFER_BUDGET_MS)) {
875
+ abandon("fixture calls its body did not await", stuckWork());
876
+ }
291
877
  if (timedOut || fixture.aborted) {
292
878
  status = "timeout";
293
879
  error = timeoutError;
294
880
  phase = "timeout";
295
881
  forceRelaunchNext = true;
296
882
  }
883
+ else if (winner.err instanceof SkipSignal) {
884
+ status = "skipped";
885
+ }
297
886
  else {
298
887
  status = "failed";
299
888
  error = winner.err;
300
889
  fixture.failurePhase = phase;
301
890
  }
302
891
  }
892
+ else if (fixture.skipReason !== undefined) {
893
+ // The body caught the signal; the skip was still requested.
894
+ status = "skipped";
895
+ }
896
+ else {
897
+ // A call the body did not await would turn a failure into a pass.
898
+ const unsettled = fixture.unsettledCalls();
899
+ if (unsettled.length > 0) {
900
+ status = "failed";
901
+ error = new Error(unawaitedMessage(item.title, unsettled));
902
+ fixture.failurePhase = "body";
903
+ if (!await fixture.stopUnsettled(error, WEDGED_DEFER_BUDGET_MS)) {
904
+ abandon("fixture calls its body did not await", stuckWork());
905
+ }
906
+ }
907
+ }
303
908
  }
304
909
  finally {
305
910
  if (timerHandle !== undefined)
306
- clearTimeout(timerHandle);
911
+ runnerClearTimeout(timerHandle);
307
912
  }
308
913
  let evidenceCollected = false;
914
+ let failurePage;
309
915
  const collectEvidence = async () => {
310
916
  if (evidenceCollected || !item.forensics)
311
917
  return;
@@ -313,17 +919,29 @@ async function run() {
313
919
  try {
314
920
  // The whole point of the timeout path is that a wedged app does not
315
921
  // stall the run, and these calls bypass `guard` on the raw driver.
316
- await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
922
+ failurePage = await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
317
923
  }
318
924
  catch (forensicsError) {
319
925
  // Evidence failures must never replace the product failure.
320
- if (forensicsError instanceof TimeoutError)
321
- contaminated = true;
926
+ if (forensicsError instanceof TimeoutError) {
927
+ stuck ?? (stuck = { what: "failure evidence capture", work: [{ kind: "action", detail: "screenshot, page and log capture", owner: record.full_name }], spec: false });
928
+ }
322
929
  await host.emit({ type: "diagnostic", phase: "forensics", message: String(forensicsError) });
323
930
  }
324
931
  };
325
- if (status !== "passed")
932
+ if (status !== "passed" && status !== "skipped")
326
933
  await collectEvidence();
934
+ // Before the defers remove it: the scenario and what reached it.
935
+ const scenarioEvidence = status !== "passed" && status !== "skipped"
936
+ ? await fixture.scenarioScope.report(SCENARIO_EVIDENCE_MS)
937
+ : undefined;
938
+ const selectedScenario = fixture.scenarioScope.current;
939
+ if (selectedScenario)
940
+ record.scenario = {
941
+ label: selectedScenario.label,
942
+ name: selectedScenario.raw.name,
943
+ variant: selectedScenario.raw.variant,
944
+ };
327
945
  phase = "defer";
328
946
  const deferErrors = [];
329
947
  const cleanupBudget = item.timeoutCleanup ?? (status === "timeout" ? WEDGED_DEFER_BUDGET_MS : MAX_DEFER_BUDGET_MS);
@@ -349,7 +967,7 @@ async function run() {
349
967
  deferErrors.push(err);
350
968
  }
351
969
  if (!settled) {
352
- contaminated = true;
970
+ abandon("its cleanup (t.defer / afterEach)", stuckWork());
353
971
  break;
354
972
  }
355
973
  }
@@ -358,12 +976,170 @@ async function run() {
358
976
  deferErrors.push(new Error("Cleanup skipped because the timed-out body is still running"));
359
977
  }
360
978
  fixture.endCleanup();
979
+ // A spec that returned with fetches or raw driver calls still in flight
980
+ // could act on the next spec when they settle: give them a moment, and
981
+ // stop the run if they do not settle.
982
+ if (!stuck) {
983
+ const open = await settleCallsOf(record.full_name, WEDGED_DEFER_BUDGET_MS);
984
+ if (open.length > 0) {
985
+ abandon("calls it did not await", open);
986
+ if (status === "passed" || status === "skipped") {
987
+ status = "failed";
988
+ error = new Error(`${JSON.stringify(item.title)} ended while ${describePending(open)} ` +
989
+ `${open.length === 1 ? "was" : "were"} still running, and ${open.length === 1 ? "it" : "they"} did not settle ` +
990
+ `within ${WEDGED_DEFER_BUDGET_MS}ms (was it awaited?)`);
991
+ fixture.failurePhase = "body";
992
+ }
993
+ }
994
+ }
995
+ // Its timers must not fire into the next spec.
996
+ let cancelled = cancelTimersOf(record.full_name);
997
+ if (cancelled > 0 && !stuck) {
998
+ await host.emit({ type: "diagnostic", phase: "cleanup",
999
+ message: `"${record.full_name}" ended with ${cancelled} test-context ${cancelled === 1 ? "timer" : "timers"} pending; ` +
1000
+ `cancelled so ${cancelled === 1 ? "it never fires" : "they never fire"} into a later spec.` });
1001
+ }
1002
+ // An answer the spec queued that no dialog used: the dialog it expected
1003
+ // never came.
1004
+ let dialogCleanupError;
1005
+ let unusedAnswers;
1006
+ if (!stuck) {
1007
+ try {
1008
+ const dialogs = await within(fixture.endDialogWatches(), WEDGED_DEFER_BUDGET_MS, "dialog unwatch timed out");
1009
+ unusedAnswers = dialogs.unused.length > 0 ? dialogs.unused.join("; ") : undefined;
1010
+ dialogCleanupError = dialogs.failures.length > 0 ? `dialog observation could not finish: ${dialogs.failures.join("; ")}` : undefined;
1011
+ }
1012
+ catch (error) {
1013
+ dialogCleanupError = `dialog observation could not finish: ${String(error)}`;
1014
+ }
1015
+ }
1016
+ if (unusedAnswers !== undefined) {
1017
+ if (status === "passed") {
1018
+ status = "failed";
1019
+ error = new Error(`${JSON.stringify(item.title)}: ${unusedAnswers}.`);
1020
+ fixture.failurePhase = "body";
1021
+ }
1022
+ else if (error instanceof Error) {
1023
+ error.message += `\nAlso: ${unusedAnswers}.`;
1024
+ }
1025
+ }
1026
+ // Framework cleanup is not the spec's: it runs whether or not the body
1027
+ // settled, and a resource it cannot remove fails the spec and stops the
1028
+ // run — a later spec would run against it.
1029
+ const reclaimFailures = dialogCleanupError ? [dialogCleanupError] : [];
1030
+ if (recordingNetwork) {
1031
+ const recordingError = await saveNetworkRecording(host, fixture, record.title);
1032
+ if (recordingError !== undefined)
1033
+ reclaimFailures.push(`the network recording could not be completed: ${recordingError}`);
1034
+ }
1035
+ else if (recordingSetupError !== undefined) {
1036
+ // Start may have reached the host before its acknowledgement failed.
1037
+ try {
1038
+ host.networkRecord?.("stop", record.title);
1039
+ }
1040
+ catch (stopError) {
1041
+ reclaimFailures.push(`the unconfirmed network recording could not be stopped: ${errorMessage(stopError)}`);
1042
+ }
1043
+ }
1044
+ try {
1045
+ reclaimFailures.push(...await within(fixture.reclaim(), RECLAIM_BUDGET_MS, `removing the spec's routes, mock scenario and test clocks did not finish within ${RECLAIM_BUDGET_MS}ms`));
1046
+ }
1047
+ catch (reclaimError) {
1048
+ reclaimFailures.push(errorMessage(reclaimError));
1049
+ }
1050
+ // Closing the attempt removes what it installed and refuses installs
1051
+ // until the next opens, whether or not its code still runs: its drivers
1052
+ // were revoked when it was abandoned.
1053
+ if (attempt !== undefined) {
1054
+ try {
1055
+ const swept = await within(host.endAttempt(attempt), RECLAIM_BUDGET_MS, `the host did not finish removing what the spec installed within ${RECLAIM_BUDGET_MS}ms`);
1056
+ if (swept.droppedTimers > 0)
1057
+ forceRelaunchNext = true;
1058
+ // Left for the host to sweep: installed where the fixture could not
1059
+ // track it (the raw driver), so the spec did not clean it up.
1060
+ if (swept.clocks > 0) {
1061
+ await host.emit({ type: "diagnostic", phase: "clock",
1062
+ message: `"${record.full_name}" left ${swept.clocks} test clock${swept.clocks === 1 ? "" : "s"} installed outside t.app.clock; ` +
1063
+ `the spec's end removed ${swept.clocks === 1 ? "it" : "them"}` +
1064
+ (swept.droppedTimers > 0 ? ` and dropped ${swept.droppedTimers} pending test timer${swept.droppedTimers === 1 ? "" : "s"}; they never fired.` : ".") });
1065
+ }
1066
+ }
1067
+ catch (sweepError) {
1068
+ reclaimFailures.push(`the host could not remove what the spec installed: ${errorMessage(sweepError)}`);
1069
+ }
1070
+ }
1071
+ if (reclaimFailures.length > 0) {
1072
+ deferErrors.push(...reclaimFailures.map((failure) => new Error(failure)));
1073
+ if (!contaminated) {
1074
+ contaminated = true;
1075
+ const what = `"${record.full_name}" left test resources the run could not remove (${reclaimFailures.join("; ")}); ` +
1076
+ "a later spec would run against them.";
1077
+ contaminationReason = `Not run: ${what} Recover: ${RECOVER_COMMAND}`;
1078
+ await host.emit({ type: "diagnostic", phase: "run_stopped",
1079
+ message: `${what} The remaining specs are not run. Recover: ${RECOVER_COMMAND}` });
1080
+ }
1081
+ }
1082
+ // Test timers dropped with the spec's clock leave the app's polling loops
1083
+ // and debounces dead; the next spec starts from a relaunched home page.
1084
+ if (fixture.clockScope.dropped > 0)
1085
+ forceRelaunchNext = true;
1086
+ // An abandoned spec stops the run below with its own reason.
1087
+ if (item.restoreProfile && !profileRestored && !contaminated && !stuck) {
1088
+ // The next spec would start on this spec's data.
1089
+ contaminated = true;
1090
+ const what = `"${record.full_name}" could not roll the app's data back (restoreProfile); a later spec would run on its data.`;
1091
+ contaminationReason = `Not run: ${what} Restart the run.`;
1092
+ await host.emit({ type: "diagnostic", phase: "run_stopped",
1093
+ message: `${what} The remaining specs are not run.` });
1094
+ }
1095
+ // Routed responses that break the contract fail the spec like a body
1096
+ // failure would, so `spec.fail({ expected: { code: 'E_OPENAPI_CONTRACT' } })`
1097
+ // can declare one.
1098
+ if (ledger) {
1099
+ let checked = undefined;
1100
+ try {
1101
+ checked = await checkContract(ledger, fixture, id, host);
1102
+ }
1103
+ catch (captureError) {
1104
+ const message = `OpenAPI response capture failed: ${captureError instanceof Error ? captureError.message : String(captureError)}`;
1105
+ if (error instanceof Error)
1106
+ error.message += `\n${message}`;
1107
+ else
1108
+ error = new Error(message);
1109
+ if (status === "passed")
1110
+ status = "failed";
1111
+ fixture.failurePhase = "capture";
1112
+ }
1113
+ if (checked) {
1114
+ record.contract = checked;
1115
+ if (checked.violations.length > 0 && status !== "skipped") {
1116
+ const contractError = new ContractError(checked.violations);
1117
+ if (status === "failed" || status === "timeout") {
1118
+ if (error instanceof Error)
1119
+ error.message += `\n${contractError.message}`;
1120
+ else
1121
+ error = contractError;
1122
+ }
1123
+ else {
1124
+ status = "failed";
1125
+ error = contractError;
1126
+ fixture.failurePhase = "contract";
1127
+ }
1128
+ }
1129
+ }
1130
+ }
361
1131
  if (item.annotation === "fail") {
362
- const bodyAssertion = status === "failed" &&
363
- fixture.failurePhase === "body" &&
364
- error instanceof AssertionError;
365
- if (bodyAssertion) {
366
- status = "xfail";
1132
+ // Without `expected`, the declared failure is whatever the body does to
1133
+ // fail: a product rejection counts as much as an assertion. With it,
1134
+ // only a matching failure is the known one; anything else — a mistyped
1135
+ // selector, a dead fixture server — is a real failure. Setup failures,
1136
+ // skips and timeouts keep their own verdicts.
1137
+ if (status === "failed" && (fixture.failurePhase === "body" || fixture.failurePhase === "contract")) {
1138
+ const mismatch = item.expected ? expectedMismatch(error, item.expected) : undefined;
1139
+ if (mismatch === undefined)
1140
+ status = "xfail";
1141
+ else
1142
+ error = mismatch;
367
1143
  }
368
1144
  else if (status === "passed") {
369
1145
  status = "xpass";
@@ -389,64 +1165,257 @@ async function run() {
389
1165
  error = new Error(`t.defer failed:\n${cleanupText}`);
390
1166
  }
391
1167
  }
392
- if (status !== "passed")
1168
+ // Overlapping steps are a misuse even when the body swallowed the error,
1169
+ // and never the failure `spec.fail` declared.
1170
+ if (fixture.stepConflict) {
1171
+ if (status === "passed" || status === "xfail" || status === "xpass") {
1172
+ status = "failed";
1173
+ error = fixture.stepConflict;
1174
+ fixture.failurePhase = "body";
1175
+ }
1176
+ else if (status === "failed" && error instanceof Error && error !== fixture.stepConflict) {
1177
+ error.message += `\nAlso: ${fixture.stepConflict.message}`;
1178
+ }
1179
+ }
1180
+ // A timed-out remote task may still run in the app: the case fails even
1181
+ // if it caught the timeout, and the next spec starts from a relaunch.
1182
+ if (fixture.remoteTimeoutKind) {
1183
+ const reason = `a timed-out ${fixture.remoteTimeoutKind} may still run in the app`;
1184
+ // An xfail may have declared this very timeout; the relaunch still applies.
1185
+ if ((status === "passed" || status === "xpass") && !fixture.stepConflict) {
1186
+ status = "failed";
1187
+ error = new Error(`${reason}; the next spec starts from a relaunched app`);
1188
+ fixture.failurePhase = "body";
1189
+ }
1190
+ forceRelaunchNext = true;
1191
+ await host.emit({ type: "diagnostic", phase: "recovery",
1192
+ message: `"${record.full_name}": ${reason}; the next spec starts from a relaunched app.` });
1193
+ }
1194
+ else if (fixture.detachedInFlight()) {
1195
+ forceRelaunchNext = true;
1196
+ await host.emit({ type: "diagnostic", phase: "recovery",
1197
+ message: `"${record.full_name}": a read t.waitFor gave up on is still running in the app; the next spec starts from a relaunched app.` });
1198
+ }
1199
+ if (status !== "passed" && status !== "skipped")
393
1200
  await collectEvidence();
1201
+ if (status === "skipped")
1202
+ record.reason = fixture.skipReason ?? record.reason;
394
1203
  fixture.close();
395
1204
  record.status = status;
396
1205
  record.duration_ms = Date.now() - caseStarted;
397
- record.steps = fixture.steps;
398
- record.assertions = fixture.assertions;
1206
+ // Copies: code of this spec that is still running cannot change a
1207
+ // finished record.
1208
+ record.steps = fixture.steps.map(copyStepRecord);
1209
+ record.assertions = fixture.assertions.map((entry) => ({ ...entry }));
1210
+ if (fixture.traceTruncated)
1211
+ record.trace_truncated = true;
1212
+ // Counted over the host's whole call log; only the latest are listed.
1213
+ const attemptNetwork = networkCalls(host, caseStarted, SUMMARIZED_NETWORK_CALLS);
1214
+ const recentNetwork = attemptNetwork.slice(-REPORTED_NETWORK_CALLS);
1215
+ if (recentNetwork.length > 0) {
1216
+ record.network_calls = recentNetwork.map((call) => {
1217
+ const at_ms = Math.max(0, call.time - caseStarted);
1218
+ const observed_during = observedStepAt(fixture.steps, at_ms);
1219
+ return { ...call, at_ms,
1220
+ ...(observed_during ? { observed_during } : {}),
1221
+ ...(call.route ? { route: { ...call.route } } : {}) };
1222
+ });
1223
+ record.network_summary = {
1224
+ observed: attemptNetwork.length,
1225
+ routed: attemptNetwork.filter((call) => call.source === "route").length,
1226
+ real: attemptNetwork.filter((call) => call.source === "network").length,
1227
+ limit_reached: attemptNetwork.length > REPORTED_NETWORK_CALLS,
1228
+ };
1229
+ }
399
1230
  if (fixture.observed.size > 0)
400
1231
  record.observed = [...fixture.observed].sort();
401
- record.attachments = fixture.attachments;
402
- setAssertionSink();
1232
+ record.attachments = [...fixture.attachments];
1233
+ setExpectScope();
403
1234
  if (error)
404
1235
  record.error = toReportError(error, fixture.currentStepPath());
405
1236
  if (record.error) {
406
1237
  record.error.phase = status === "timeout" ? "timeout" : fixture.failurePhase ?? phase;
407
1238
  // JSC tail calls can omit the authored assertion frame.
408
1239
  (_a = record.error).location ?? (_a.location = `${source.file}:${source.line}:1`);
1240
+ // Only the action that produced this very error explains it: an action
1241
+ // the spec expected to fail (`t.reject`) must not be blamed later.
1242
+ if (fixture.failedAction && fixture.failedAction.error === error) {
1243
+ record.error.failedAction = fixture.failedAction.action;
1244
+ }
1245
+ // A spec that never reached a page is not "on" the app's current one.
1246
+ const current = fixture.usedPage || item.start ? failurePage : undefined;
1247
+ const page = current ? uiFailureNote(current, record.error, error, fixture) : pageFromErrorData(record.error.data);
1248
+ if (page)
1249
+ record.error.page = page;
1250
+ const calls = withFunctionCalls(networkCalls(host, caseStarted), scenarioEvidence?.functionCalls ?? []);
1251
+ if (calls.length > 0)
1252
+ record.error.network = calls;
1253
+ if (scenarioEvidence)
1254
+ record.error.scenario = scenarioEvidence.scenario;
409
1255
  }
410
- const history = attempts.get(id) ?? [];
1256
+ const history = attempts.get(key) ?? [];
411
1257
  history.push(record);
412
- attempts.set(id, history);
1258
+ attempts.set(key, history);
413
1259
  const finished = { ...record, attempts: history.length > 1 ? history.map(attempt => ({ ...attempt })) : undefined,
414
1260
  flaky: record.status === "passed" && history.length > 1 };
415
- const previous = cases.findIndex(item => item.id === id);
1261
+ const previous = cases.findIndex(item => item.id === id && (item.repeat ?? 1) === repeat);
416
1262
  if (previous >= 0)
417
1263
  cases[previous] = finished;
418
1264
  else
419
1265
  cases.push(finished);
420
1266
  await finishCase(host, finished);
1267
+ if (stuck) {
1268
+ // Shared-context user continuations cannot be proven dead. Reclaim and
1269
+ // recover for inspection, then stop; only runner-owned evidence work can
1270
+ // time out without abandoning user code.
1271
+ if (stuck.spec)
1272
+ lastAbandoned = record.full_name;
1273
+ const inFlight = stuck.spec ? await abandonedWorkInFlight(record.full_name, fixture) : [];
1274
+ cancelled += cancelTimersOf(record.full_name);
1275
+ const failure = await recoverAppUnderTest(item.app ?? subject?.appid);
1276
+ cancelled += cancelTimersOf(record.full_name);
1277
+ const unfenced = stuck.spec && grant ? [...grant.unfenced] : [];
1278
+ const summary = `"${record.full_name}" left ${stuck.what} pending: ${describePending(stuck.work)}` +
1279
+ (cancelled > 0 ? `; cancelled its ${cancelled} pending ${cancelled === 1 ? "timer" : "timers"}` : "");
1280
+ const unsafe = [
1281
+ ...(stuck.spec ? ["abandoned code can resume through an untracked promise in this shared JS context"] : []),
1282
+ ...(inFlight.length > 0
1283
+ ? [`${describePending(inFlight)} ${inFlight.length === 1 ? "is" : "are"} still running after ${WEDGED_DEFER_BUDGET_MS}ms and would resume its code during a later spec`]
1284
+ : []),
1285
+ ...(unfenced.length > 0
1286
+ ? [`it holds host drivers the run cannot revoke (${unfenced.map((name) => `rawAutomation().${name}`).join(", ")})`]
1287
+ : []),
1288
+ ];
1289
+ if (stuck.spec) {
1290
+ // Isolation cannot be guaranteed: stop, and revoke all spec code.
1291
+ const revokeError = await revokeSpecAuthority(host, undefined, `${summary}; the run stopped`);
1292
+ const recovered = failure === undefined
1293
+ ? "Relaunched the app under test on its home page for inspection"
1294
+ : `Recovering the app failed: ${failure}`;
1295
+ const reason = `${summary}. The run stopped: ${unsafe.join("; ")}. ${recovered}` +
1296
+ (revokeError ? `; revoking its automation access failed: ${revokeError}` : "") +
1297
+ ". Make the spec settle (await its work), or give it a longer timeout.";
1298
+ const recover = failure !== undefined || revokeError ? ` Recover: ${RECOVER_COMMAND}` : "";
1299
+ if (!contaminated) {
1300
+ contaminated = true;
1301
+ contaminationReason = `Not run: ${reason}${recover}`;
1302
+ }
1303
+ forceRelaunchNext = false;
1304
+ await host.emit({ type: "diagnostic", phase: failure === undefined ? "run_stopped" : "recovery_failed",
1305
+ message: `${reason} The remaining specs are not run.${recover}` });
1306
+ }
1307
+ else if (failure === undefined) {
1308
+ forceRelaunchNext = false;
1309
+ await host.emit({ type: "diagnostic", phase: "recovery",
1310
+ message: `${summary}. Relaunched the app under test on its home page; the run continues.` });
1311
+ }
1312
+ else if (!contaminated) {
1313
+ contaminated = true;
1314
+ contaminationReason = `Not run: ${summary}, and recovering the app failed: ${failure}. Recover: ${RECOVER_COMMAND}`;
1315
+ if (stuck.spec)
1316
+ await revokeSpecAuthority(host, undefined, `${summary}; the run stopped`);
1317
+ await host.emit({ type: "diagnostic", phase: "recovery_failed",
1318
+ message: `${summary}, and recovering the app failed: ${failure}. The remaining specs are not run. Recover: ${RECOVER_COMMAND}` });
1319
+ }
1320
+ }
1321
+ setPendingOwner(undefined);
421
1322
  if (!contaminated && (status === "failed" || status === "timeout") && history.length <= retries) {
422
- queue.splice(queue.indexOf(item) + 1, 0, { ...item });
1323
+ queue.splice(queue.indexOf(planned) + 1, 0, { item: { ...item }, repeat });
1324
+ }
1325
+ }
1326
+ uninstallPendingTracker();
1327
+ unfenceGlobalAutomation();
1328
+ setStrayAssertionSink();
1329
+ // The run ends as each spec starts: with the app under test running, so
1330
+ // what comes next (a rerun, `lxdev lxapp`, a developer) does not meet a
1331
+ // closed app. Not after pending async work, which reopening could race.
1332
+ if (!contaminated && cases.length > 0) {
1333
+ const reopened = await reopenAppUnderTest(subject?.appid);
1334
+ if (reopened) {
1335
+ await host.emit({ type: "diagnostic", phase: reopened.error ? "recovery_failed" : "recovery",
1336
+ message: `The app under test (${reopened.appId}) was not running at the end of the run` +
1337
+ (reopened.error ? `, and reopening it failed: ${reopened.error}` : "; reopened it on its home page.") });
423
1338
  }
424
1339
  }
425
1340
  const counts = countStatuses(cases);
426
1341
  const duration_ms = Date.now() - started;
1342
+ const reportedControl = { ...control };
1343
+ for (const key of PAYLOAD_CONTROL_KEYS)
1344
+ delete reportedControl[key];
427
1345
  const json = {
428
1346
  schema_version: 1,
429
1347
  framework: { name: PACKAGE_NAME, version: VERSION },
430
1348
  meta: {
431
1349
  started_at: new Date(started).toISOString(),
432
1350
  duration_ms,
433
- args: { ...host.args },
434
- platform: host.args.platform,
435
- framework: host.args.framework,
1351
+ args: redact.args(args),
1352
+ run: redact.deep(reportedControl),
1353
+ platform: control.platform,
1354
+ framework: args.framework,
436
1355
  subject,
437
1356
  surface_coverage: trackSurface,
1357
+ ...(budget ? { budget: { ms: budget.ms, auto: budget.auto, planned: plan.length,
1358
+ ...(budgetExhausted ? { exhausted_after: budgetExhausted.after } : {}) } } : {}),
1359
+ ...(shuffleSeed !== undefined ? { shuffle_seed: shuffleSeed } : {}),
1360
+ ...(repeatEach > 1 ? { repeat_each: repeatEach } : {}),
438
1361
  },
439
- partial: contaminated,
440
- filtered: Boolean(grep || host.args.id || host.args.ids || shard) || hasOnly,
1362
+ partial: contaminated || budgetExhausted !== undefined || screenLocked !== undefined,
1363
+ filtered: Boolean(grep || control.id || control.ids || shard || locations || tagFilter.length > 0) || hasOnly,
441
1364
  duration_ms,
442
1365
  ...counts,
443
- cases,
1366
+ cases: redact.deep(cases),
444
1367
  };
1368
+ json.failures = failureRecords(json.cases);
1369
+ const tagRows = tagSummary(json.cases);
1370
+ if (tagRows.length > 0)
1371
+ json.tag_summary = tagRows;
1372
+ if (manifest) {
1373
+ json.coverage = coverageSummary(manifest, json.cases, specs.map((item) => ({ id: resolvedId(item), title: item.title, covers: item.covers })));
1374
+ }
1375
+ if (ledger)
1376
+ json.openapi = redact.deep(ledger.summary);
445
1377
  await attachText(host, "report.json", JSON.stringify(json, null, 2), "application/json");
446
1378
  await attachText(host, "report.html", renderHtml(json), "text/html; charset=utf-8");
447
1379
  await attachText(host, "junit.xml", renderJUnit(json), "application/xml; charset=utf-8");
448
1380
  return json;
449
1381
  }
1382
+ /** Calls a body returned without awaiting; more than this many are counted. */
1383
+ const LISTED_UNAWAITED = 5;
1384
+ function unawaitedMessage(title, calls) {
1385
+ const count = calls.length === 1 ? "1 fixture call was" : `${calls.length} fixture calls were`;
1386
+ const lines = calls.slice(0, LISTED_UNAWAITED).map((entry) => ` ${entry.call} at ${entry.at} (started ${entry.ageMs}ms ago)`);
1387
+ if (calls.length > LISTED_UNAWAITED)
1388
+ lines.push(` … and ${calls.length - LISTED_UNAWAITED} more`);
1389
+ return [`${JSON.stringify(title)} returned while ${count} still running (was it awaited?):`, ...lines].join("\n");
1390
+ }
1391
+ /**
1392
+ * `undefined` when `error` is the failure `spec.fail` declared; otherwise the
1393
+ * error to report, saying what was expected instead.
1394
+ */
1395
+ function expectedMismatch(error, expected) {
1396
+ const record = (error && typeof error === "object" ? error : {});
1397
+ const message = error instanceof Error ? error.message : typeof record.message === "string" ? record.message : String(error);
1398
+ const problems = [];
1399
+ if (expected.code !== undefined && !matchesErrorCode(error, expected.code)) {
1400
+ problems.push(`code ${formatValue(expected.code)}, got ${formatValue(record.code)}`);
1401
+ }
1402
+ if (typeof expected.message === "string" && !message.includes(expected.message)) {
1403
+ problems.push(`a message containing ${formatValue(expected.message)}`);
1404
+ }
1405
+ if (expected.message instanceof RegExp) {
1406
+ expected.message.lastIndex = 0;
1407
+ if (!expected.message.test(message))
1408
+ problems.push(`a message matching ${String(expected.message)}`);
1409
+ }
1410
+ if (problems.length === 0)
1411
+ return undefined;
1412
+ const header = `spec.fail expected a failure with ${problems.join(" and ")}; the body failed differently:`;
1413
+ if (error instanceof Error) {
1414
+ error.message = `${header}\n${error.message}`;
1415
+ return error;
1416
+ }
1417
+ return new Error(`${header}\n${message}`);
1418
+ }
450
1419
  async function finishCase(host, record) {
451
1420
  await host.emit({
452
1421
  type: "case_finished",
@@ -459,31 +1428,250 @@ async function finishCase(host, record) {
459
1428
  record,
460
1429
  });
461
1430
  }
462
- /** Best-effort: a run against an unreachable app still reports its cases. */
1431
+ /** How long a run start waits for the host to name its current lxapp. */
1432
+ const SUBJECT_WAIT_MS = 15000;
1433
+ /**
1434
+ * The app under test: the host's current lxapp. A host whose runtime just
1435
+ * (re)connected may not have shown its app yet, so this waits for it, and
1436
+ * opens an app the host names but has not opened. Best-effort: a run
1437
+ * against an unreachable app still reports its cases.
1438
+ */
463
1439
  async function describeSubject() {
464
- try {
465
- const info = (await pinApp().info());
1440
+ const deadline = Date.now() + SUBJECT_WAIT_MS;
1441
+ let opened;
1442
+ for (;;) {
1443
+ try {
1444
+ const info = (await pinApp().info());
1445
+ return {
1446
+ appid: asText(info.appId),
1447
+ app_name: asText(info.appName),
1448
+ version: asText(info.version),
1449
+ release_type: asText(info.releaseType),
1450
+ pages: typeof info.pagesCount === "number" ? info.pagesCount : undefined,
1451
+ };
1452
+ }
1453
+ catch (error) {
1454
+ const message = errorMessage(error);
1455
+ const inactive = /lxapp is not active: (\S+)/.exec(message)?.[1];
1456
+ if (inactive !== undefined && opened !== inactive) {
1457
+ opened = inactive;
1458
+ await reopenAppUnderTest(inactive);
1459
+ continue;
1460
+ }
1461
+ if (!(inactive !== undefined || /no current lxapp/.test(message)) || Date.now() >= deadline)
1462
+ return undefined;
1463
+ await new Promise((resolve) => { runnerSetTimeout(resolve, 200); });
1464
+ }
1465
+ }
1466
+ }
1467
+ function capitalize(text) {
1468
+ return text.charAt(0).toUpperCase() + text.slice(1);
1469
+ }
1470
+ function asText(value) {
1471
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1472
+ }
1473
+ /** The current page as a driver error's `data.current` names it. */
1474
+ function pageFromErrorData(data) {
1475
+ const current = data && typeof data === "object" ? data.current : undefined;
1476
+ return current && typeof current === "object" ? toFailurePage(current) : undefined;
1477
+ }
1478
+ function toFailurePage(value) {
1479
+ if (!value || typeof value !== "object")
1480
+ return undefined;
1481
+ const page = value;
1482
+ const name = typeof page.name === "string" ? page.name : null;
1483
+ const instanceId = typeof page.instanceId === "string" ? page.instanceId : null;
1484
+ return name === null && instanceId === null ? undefined : { name, instanceId };
1485
+ }
1486
+ /** `failures[]`: every failing case, flat, with what failed and where. */
1487
+ export function failureRecords(cases) {
1488
+ return cases
1489
+ .filter((item) => item.status === "failed" || item.status === "timeout" || item.status === "xpass")
1490
+ .map((item) => {
1491
+ const error = item.error;
1492
+ const screenshot = item.attachments.find((attachment) => attachment.name === "failure.png")?.path;
466
1493
  return {
467
- appid: asText(info.appid),
468
- app_name: asText(info.app_name),
469
- version: asText(info.version),
470
- release_type: asText(info.release_type),
471
- pages: typeof info.pages_count === "number" ? info.pages_count : undefined,
1494
+ id: item.id,
1495
+ title: item.title,
1496
+ file: item.file,
1497
+ line: item.line,
1498
+ phase: error?.phase,
1499
+ code: error?.code,
1500
+ message: error?.message ?? item.status,
1501
+ failedAction: error?.failedAction,
1502
+ page: error?.page,
1503
+ screenshot,
1504
+ ...(error?.network && error.network.length > 0 ? { network: error.network } : {}),
1505
+ ...(error?.scenario ? { scenario: error.scenario } : {}),
472
1506
  };
1507
+ });
1508
+ }
1509
+ /** The per-spec attachment `lxdev test --record-network` collects. */
1510
+ const RECORDED_SCENARIO = "network.scenario.json";
1511
+ /** Logic network calls a failed spec reports: the last 20 since it started. */
1512
+ const REPORTED_NETWORK_CALLS = 20;
1513
+ /** The host's call log bound: a summary counts every call it still holds. */
1514
+ const SUMMARIZED_NETWORK_CALLS = 200;
1515
+ function networkCalls(host, since, limit = REPORTED_NETWORK_CALLS) {
1516
+ if (!host.networkLog)
1517
+ return [];
1518
+ try {
1519
+ const calls = host.networkLog(since, limit);
1520
+ return Array.isArray(calls) ? calls.slice(-limit) : [];
473
1521
  }
474
1522
  catch {
1523
+ // The call log is evidence; it never replaces the failure.
1524
+ return [];
1525
+ }
1526
+ }
1527
+ /** Only a temporal association: app work can start during a step for unrelated reasons. */
1528
+ function observedStepAt(steps, atMs) {
1529
+ for (const step of steps) {
1530
+ if (step.kind === "action" || step.at_ms === undefined || step.duration_unknown)
1531
+ continue;
1532
+ if (atMs < step.at_ms || atMs > step.at_ms + step.duration_ms)
1533
+ continue;
1534
+ return observedStepAt(step.steps, atMs) ?? step.path;
1535
+ }
1536
+ return undefined;
1537
+ }
1538
+ /** How long a failed spec waits for its scenario's calls. */
1539
+ const SCENARIO_EVIDENCE_MS = 2000;
1540
+ /** The Logic calls and the scenario's Function calls, newest 20 by time. */
1541
+ function withFunctionCalls(calls, functions) {
1542
+ if (functions.length === 0)
1543
+ return calls;
1544
+ const converted = functions.map((call) => ({
1545
+ time: call.time,
1546
+ kind: "function",
1547
+ method: "",
1548
+ url: "",
1549
+ function: call.function,
1550
+ outcome: call.outcome,
1551
+ status: null,
1552
+ durationMs: null,
1553
+ source: call.rule === null ? "network" : "route",
1554
+ answeredBy: call.answeredBy,
1555
+ ...(call.noMatch ? { noMatch: call.noMatch } : {}),
1556
+ }));
1557
+ return [...calls, ...converted].sort((a, b) => a.time - b.time).slice(-REPORTED_NETWORK_CALLS);
1558
+ }
1559
+ /** `--record-network`: capture this spec's real Logic traffic. */
1560
+ async function startNetworkRecording(host) {
1561
+ try {
1562
+ host.networkRecord("start");
1563
+ }
1564
+ catch (error) {
1565
+ const message = `network recording could not start: ${errorMessage(error)}`;
1566
+ await host.emit({ type: "diagnostic", phase: "record-network", message });
1567
+ throw new Error(message);
1568
+ }
1569
+ }
1570
+ /** Stop this spec's recording and attach it; resolves why it failed, if it did. */
1571
+ async function saveNetworkRecording(host, fixture, title) {
1572
+ let scenario;
1573
+ try {
1574
+ scenario = host.networkRecord?.("stop", title);
1575
+ }
1576
+ catch (error) {
1577
+ return errorMessage(error);
1578
+ }
1579
+ try {
1580
+ if (scenario && typeof scenario === "object")
1581
+ await fixture.attachRaw(RECORDED_SCENARIO, scenario);
1582
+ }
1583
+ catch (error) {
1584
+ return `network recording could not be attached: ${errorMessage(error)}`;
1585
+ }
1586
+ return undefined;
1587
+ }
1588
+ /** How long removing one spec's routes, scenario and clocks may take. */
1589
+ const RECLAIM_BUDGET_MS = 10000;
1590
+ function errorMessage(error) {
1591
+ return String(error?.message ?? error);
1592
+ }
1593
+ /**
1594
+ * Stop spec code of this run from driving the app: `rawAutomation()` and
1595
+ * every proxy it handed out refuse from now on, and the host refuses the
1596
+ * context's driver calls and removes what the open attempt installed.
1597
+ * Resolves why the host could not, if it could not.
1598
+ */
1599
+ async function revokeSpecAuthority(host, attempt, reason) {
1600
+ revokeRawAuthority(reason);
1601
+ try {
1602
+ if (host.revoke)
1603
+ await within(host.revoke(reason), RECLAIM_BUDGET_MS, `the host did not finish revoking within ${RECLAIM_BUDGET_MS}ms`);
1604
+ else if (attempt !== undefined && host.endAttempt)
1605
+ await within(host.endAttempt(attempt), RECLAIM_BUDGET_MS, `the host did not finish removing what the spec installed within ${RECLAIM_BUDGET_MS}ms`);
475
1606
  return undefined;
476
1607
  }
1608
+ catch (error) {
1609
+ return errorMessage(error);
1610
+ }
477
1611
  }
478
- function asText(value) {
479
- return typeof value === "string" && value.length > 0 ? value : undefined;
1612
+ /**
1613
+ * Work an abandoned spec dispatched that is still running after a grace
1614
+ * period: fetches and raw driver calls (and what their continuations start
1615
+ * meanwhile), and fixture calls the abort could not stop. Empty when all
1616
+ * settled.
1617
+ */
1618
+ async function abandonedWorkInFlight(name, fixture) {
1619
+ const deadline = Date.now() + WEDGED_DEFER_BUDGET_MS;
1620
+ const open = await settleCallsOf(name, WEDGED_DEFER_BUDGET_MS);
1621
+ let calls = fixture.pendingCalls();
1622
+ while (calls.length > 0 && Date.now() < deadline) {
1623
+ await new Promise((resolve) => { runnerSetTimeout(resolve, 50); });
1624
+ calls = fixture.pendingCalls();
1625
+ }
1626
+ return [...open, ...calls.map((work) => ({ ...work, owner: name }))];
1627
+ }
1628
+ /** Assertions noted while no spec ran, beyond which they are only counted. */
1629
+ const LISTED_STRAY_ASSERTIONS = 5;
1630
+ /** A diagnostic for each assertion that ran while no spec was running. */
1631
+ function strayAssertionSink(host, abandoned) {
1632
+ let seen = 0;
1633
+ return (entry) => {
1634
+ seen += 1;
1635
+ if (seen > LISTED_STRAY_ASSERTIONS)
1636
+ return;
1637
+ const from = abandoned();
1638
+ const message = `An assertion ran while no spec was running, so no case records it: ` +
1639
+ `expect(received).${entry.matcher} ${entry.passed ? "passed" : "failed"} (expected ${entry.expected}, received ${entry.actual}). ` +
1640
+ (from !== undefined
1641
+ ? `It comes from ${JSON.stringify(from)}, which the run abandoned; its verdict stands.`
1642
+ : "Code of a spec the run stopped waiting for is still running.") +
1643
+ (seen === LISTED_STRAY_ASSERTIONS ? " Further ones are not listed." : "");
1644
+ void host.emit({ type: "diagnostic", phase: "late_assertion", message, ...(from !== undefined ? { case: from } : {}) }).catch(() => { });
1645
+ };
480
1646
  }
1647
+ /** Locator actions and one-shot reads, as the trace names them: `page.<verb> [#instance] selector`. */
1648
+ const LOCATOR_ACTION = /^page\.(?:click|fill|type|press|waitFor|count|isVisible|textContent|inputValue|getAttribute)(?: #(\S+))?/;
1649
+ /**
1650
+ * Whether the page looked hidden explains a failure only when a locator
1651
+ * failed; and when the locator's page was not the current one, that is the
1652
+ * explanation instead.
1653
+ */
1654
+ function uiFailureNote(page, report, error, fixture) {
1655
+ const { hidden, ...rest } = page;
1656
+ const asserted = error && typeof error === "object" ? fixture.locatorFailures.get(error) : undefined;
1657
+ const acted = report.failedAction ? LOCATOR_ACTION.exec(report.failedAction) : null;
1658
+ if (report.phase === "contract" || (asserted === undefined && !acted))
1659
+ return rest;
1660
+ // `#foo` may be a CSS id selector on the current page, not a bound instance.
1661
+ const actedOn = acted?.[1] !== undefined && fixture.boundPages.has(acted[1]) ? acted[1] : null;
1662
+ const target = asserted !== undefined ? asserted : actedOn;
1663
+ if (target !== null && page.instanceId && target !== page.instanceId) {
1664
+ return { ...rest, hidden: `the locator's page #${target} was not the current page` };
1665
+ }
1666
+ return hidden ? { ...rest, hidden } : rest;
1667
+ }
1668
+ /** Attach failure evidence; resolves to the page that was current, if known. */
481
1669
  async function captureForensics(fixture) {
482
1670
  try {
483
1671
  const shot = await fixture.raw.page.screenshot();
484
1672
  const payload = encodeScreenshot(shot);
485
1673
  if (payload) {
486
- await fixture.attachRaw("failure.png", payload);
1674
+ await fixture.attachRaw("failure.png", payload, "failure_evidence");
487
1675
  }
488
1676
  }
489
1677
  catch {
@@ -503,16 +1691,29 @@ async function captureForensics(fixture) {
503
1691
  catch {
504
1692
  info = undefined;
505
1693
  }
1694
+ let visibility;
1695
+ try {
1696
+ visibility = pageVisibility(await fixture.raw.page.eval({
1697
+ script: VISIBILITY_PROBE_SCRIPT, timeoutMs: VISIBILITY_PROBE_BUDGET_MS
1698
+ }));
1699
+ }
1700
+ catch {
1701
+ visibility = undefined;
1702
+ }
506
1703
  const forensics = {
507
1704
  route,
508
1705
  info,
509
1706
  step: fixture.currentStepPath() ?? null,
1707
+ visibility: visibility ?? null,
510
1708
  };
511
- await fixture.attachRaw("forensics.json", forensics);
1709
+ await fixture.attachRaw("forensics.json", forensics, "failure_evidence");
512
1710
  const logs = await fixtureHostLogs();
513
1711
  if (logs !== undefined) {
514
- await fixture.attachRaw("logs.txt", logs);
1712
+ await fixture.attachRaw("logs.txt", logs, "failure_evidence");
515
1713
  }
1714
+ const page = toFailurePage(route);
1715
+ const hidden = hiddenCause(visibility?.note);
1716
+ return page && hidden ? { ...page, hidden } : page;
516
1717
  }
517
1718
  async function fixtureHostLogs() {
518
1719
  const host = resolveHost();
@@ -528,17 +1729,23 @@ function encodeScreenshot(shot) {
528
1729
  }
529
1730
  function reset() {
530
1731
  specs.length = 0;
1732
+ fileConfigs.length = 0;
1733
+ setActiveOpenApi(undefined);
531
1734
  hooks.length = 0;
532
1735
  afterHooks.length = 0;
533
1736
  resetHooks.length = 0;
534
1737
  trackSurface = false;
535
- fileCounts.clear();
536
1738
  forceRelaunchNext = false;
1739
+ uninstallPendingTracker();
1740
+ unfenceGlobalAutomation();
1741
+ restoreRawAuthority();
537
1742
  clearInline();
538
- setAssertionSink();
1743
+ setExpectScope();
1744
+ setStrayAssertionSink();
539
1745
  }
540
1746
  const controller = {
541
1747
  run,
1748
+ list,
542
1749
  version: VERSION,
543
1750
  reset,
544
1751
  };
@@ -550,18 +1757,89 @@ if (!globalThis.__LINGXIA_TEST__) {
550
1757
  writable: false,
551
1758
  });
552
1759
  }
553
- export { spec, expect, run, reset, resolvedId, trackPublicSurface };
1760
+ export { spec, expect, run, list, reset, resolvedId, trackPublicSurface };
1761
+ /** Bound on the capture driver calls a contract check makes. */
1762
+ const CONTRACT_CALL_MS = 5000;
1763
+ /** Newest captured response already checked, per run. */
1764
+ let contractSeq = 0;
1765
+ /**
1766
+ * Check the responses captured since the last spec: routed mismatches come
1767
+ * back as violations, the real server's as warnings (also a diagnostic).
1768
+ */
1769
+ async function checkContract(ledger, fixture, id, host) {
1770
+ let records;
1771
+ try {
1772
+ records = await within(Promise.resolve(fixture.raw.network.responses({ since: contractSeq, waitMs: 2500 })), CONTRACT_CALL_MS, "reading captured responses timed out");
1773
+ }
1774
+ catch (readError) {
1775
+ await host.emit({ type: "diagnostic", phase: "contract",
1776
+ message: `${id}: captured responses could not be read: ${readError instanceof Error ? readError.message : String(readError)}` });
1777
+ throw readError;
1778
+ }
1779
+ for (const record of records)
1780
+ contractSeq = Math.max(contractSeq, record.seq);
1781
+ if (records.length === 0)
1782
+ return undefined;
1783
+ const result = ledger.checkSpec(id, records);
1784
+ for (const warning of result.warnings.slice(0, 3)) {
1785
+ const first = warning.issues[0];
1786
+ await host.emit({ type: "diagnostic", phase: "contract",
1787
+ message: `${id}: ${warning.operation} → ${warning.status} from the server does not match ${warning.schema}` +
1788
+ (first ? ` (at ${first.path || "/"}: ${first.message})` : "") });
1789
+ }
1790
+ return result;
1791
+ }
554
1792
  async function within(task, ms, message) {
555
1793
  let handle;
556
1794
  try {
557
1795
  return await Promise.race([task, new Promise((_, reject) => {
558
- handle = setTimeout(() => reject(new TimeoutError(message)), ms);
1796
+ handle = runnerSetTimeout(() => reject(new TimeoutError(message)), ms);
559
1797
  })]);
560
1798
  }
561
1799
  finally {
562
1800
  if (handle !== undefined)
563
- clearTimeout(handle);
1801
+ runnerClearTimeout(handle);
1802
+ }
1803
+ }
1804
+ function parseLocations(raw) {
1805
+ if (raw === undefined)
1806
+ return undefined;
1807
+ const parsed = JSON.parse(raw);
1808
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1809
+ throw new Error(`the host's locations control is not an object: ${JSON.stringify(raw)}`);
1810
+ }
1811
+ const out = new Map();
1812
+ for (const [file, ranges] of Object.entries(parsed)) {
1813
+ if (ranges !== null && !(Array.isArray(ranges) && ranges.every((r) => Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n))))) {
1814
+ throw new Error(`the host's locations control has a bad range for ${file}`);
1815
+ }
1816
+ out.set(normalizeFile(file), ranges);
564
1817
  }
1818
+ return out;
1819
+ }
1820
+ function normalizeFile(file) {
1821
+ return file.replace(/\\/g, "/");
1822
+ }
1823
+ function atLocation(source, locations) {
1824
+ const file = normalizeFile(source.file);
1825
+ if (!locations.has(file))
1826
+ return false;
1827
+ const ranges = locations.get(file);
1828
+ return ranges === null || ranges.some(([from, to]) => source.line >= from && source.line <= to);
1829
+ }
1830
+ /** `lxdev test --list`: the selection, with nothing run. */
1831
+ function listing(listed) {
1832
+ return {
1833
+ schema_version: 1,
1834
+ framework: { name: PACKAGE_NAME, version: VERSION },
1835
+ meta: { started_at: new Date().toISOString(), duration_ms: 0, args: {} },
1836
+ partial: false,
1837
+ filtered: true,
1838
+ total: 0, passed: 0, failed: 0, skipped: 0, xfail: 0, xpass: 0, timeout: 0,
1839
+ duration_ms: 0,
1840
+ cases: [],
1841
+ listed,
1842
+ };
565
1843
  }
566
1844
  function stableHash(value) {
567
1845
  let hash = 2166136261;