@lingxia/test 0.17.0 → 0.19.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 +205 -40
  30. package/dist/fixture.d.ts.map +1 -1
  31. package/dist/fixture.js +1178 -156
  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 +34 -2
  44. package/dist/host.js.map +1 -1
  45. package/dist/ids.d.ts +16 -0
  46. package/dist/ids.d.ts.map +1 -1
  47. package/dist/ids.js +38 -3
  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 +40 -5
  60. package/dist/junit.js.map +1 -1
  61. package/dist/locator.d.ts +116 -7
  62. package/dist/locator.d.ts.map +1 -1
  63. package/dist/locator.js +444 -58
  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 +196 -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 +55 -4
  106. package/dist/runtime.d.ts.map +1 -1
  107. package/dist/runtime.js +1500 -139
  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 +10 -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 +863 -168
  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,13 +1,35 @@
1
- import { AssertionError, expect, setAssertionSink } from "./expect.js";
2
- import { LiveFixture, TimeoutError, protocolStatus, 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
- const fileCounts = new Map();
28
+ const afterHooks = [];
29
+ const resetHooks = [];
30
+ /** Why a companion does not reset its mock handlers, said once per run. */
31
+ const mockResetReasons = new Set();
32
+ const fileConfigs = [];
11
33
  let forceRelaunchNext = false;
12
34
  let trackSurface = false;
13
35
  /**
@@ -28,32 +50,127 @@ function parseArgs(optionsOrBody, maybeBody) {
28
50
  }
29
51
  return { options: optionsOrBody, body: maybeBody };
30
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
+ }
31
135
  function register(annotation, title, optionsOrBody, maybeBody) {
32
136
  if (typeof title !== "string" || title.length === 0) {
33
137
  throw new TypeError("spec() requires a non-empty title");
34
138
  }
35
139
  const { options, body } = parseArgs(optionsOrBody, maybeBody);
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
+ }
149
+ }
150
+ validateOptions(options, `spec ${JSON.stringify(title)}`);
36
151
  // The bundle map is installed after the modules run, so keep the raw frames;
37
152
  // the authored file is only knowable once the run starts. `frames[0]` is a
38
153
  // frame inside this package, identical for every caller, so it can order
39
154
  // registrations but must never stand in for identity.
40
155
  const frames = captureFrames();
41
- const indexInFile = fileCounts.size + 1;
42
- fileCounts.set(String(indexInFile), indexInFile);
43
- specs.push({
156
+ const item = {
44
157
  title,
45
158
  id: options.id,
46
- covers: [...(options.covers ?? [])],
47
- timeout: options.timeout ?? DEFAULT_SPEC_TIMEOUT_MS,
48
- fresh: options.fresh === true,
49
- app: options.app,
50
- forensics: options.forensics !== false,
51
- 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,
52
167
  annotation,
53
168
  body,
54
169
  frames,
55
- indexInFile,
56
- });
170
+ };
171
+ // Settled again at run start, with the file's defaults.
172
+ settle(item, undefined);
173
+ specs.push(item);
57
174
  }
58
175
  const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
59
176
  register("default", title, optionsOrBody, maybeBody);
@@ -74,22 +191,99 @@ const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
74
191
  fail(title, optionsOrBody, maybeBody) {
75
192
  register("fail", title, optionsOrBody, maybeBody);
76
193
  },
194
+ reset(fn) {
195
+ if (typeof fn !== "function")
196
+ throw new TypeError("spec.reset() requires a function");
197
+ resetHooks.push({ frames: captureFrames(), fn });
198
+ },
199
+ afterEach(fn) {
200
+ if (typeof fn !== "function")
201
+ throw new TypeError("spec.afterEach() requires a function");
202
+ afterHooks.push({ frames: captureFrames(), fn });
203
+ },
77
204
  beforeEach(fn) {
78
205
  if (typeof fn !== "function")
79
206
  throw new TypeError("spec.beforeEach() requires a function");
80
207
  hooks.push({ frames: captureFrames(), fn });
81
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
+ },
82
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
+ }
83
231
  function sourceOf(item) {
84
232
  const origin = resolveOrigin(item.frames);
85
233
  return { file: origin.file, line: origin.line };
86
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
+ }
87
279
  function resolvedId(item) {
88
280
  if (item.id && item.id.length > 0)
89
281
  return item.id;
90
282
  const slug = slugTitle(item.title);
91
283
  if (slug)
92
284
  return slug;
285
+ if (item.indexInFile === undefined)
286
+ assignFileIndexes();
93
287
  return `${fileStem(sourceOf(item).file)}-${item.indexInFile}`;
94
288
  }
95
289
  /** Report group: the authored file path, trimmed to the project-relative part. */
@@ -99,27 +293,219 @@ function suiteOf(file) {
99
293
  const cut = trimmed.lastIndexOf("/tests/");
100
294
  return cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
101
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
+ }
102
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.
103
330
  const lx = globalThis.lx;
104
331
  if (!lx || typeof lx.automation !== "function") {
105
- 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");
106
333
  }
107
334
  return lx.automation();
108
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
+ }
109
347
  function pinApp(appId) {
110
348
  const automation = automationRoot();
111
349
  return appId ? automation.lxapp(appId) : automation.lxapp();
112
350
  }
113
- async function relaunchHome(app) {
114
- const pages = await app.pages();
115
- const home = pages[0]?.name ?? "home";
116
- 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
+ }
414
+ }
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 };
117
450
  }
118
- async function run() {
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) {
493
+ var _a;
119
494
  warnVersionSkew();
120
- 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
+ };
121
504
  const started = Date.now();
122
505
  clearInline();
506
+ contractSeq = 0;
507
+ assertAttributed();
508
+ assignFileIndexes();
123
509
  const ids = new Map();
124
510
  for (const item of specs) {
125
511
  const id = resolvedId(item);
@@ -129,78 +515,305 @@ async function run() {
129
515
  }
130
516
  ids.set(id, item.title);
131
517
  }
132
- const grep = host.args.grep;
133
- const forbidOnly = host.args.forbidOnly === "1" || host.args["forbid-only"] === "1";
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");
522
+ const pattern = grep ? new RegExp(grep) : undefined;
523
+ const forbidOnly = control.forbidOnly === "1" || control["forbid-only"] === "1";
134
524
  const hasOnly = specs.some((item) => item.annotation === "only");
135
525
  if (hasOnly && forbidOnly) {
136
526
  throw new Error("spec.only is registered; lxdev test --forbid-only refuses to run");
137
527
  }
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);
548
+ if (!Number.isInteger(retries) || retries < 0 || retries > 10)
549
+ throw new Error("retries must be between 0 and 10");
550
+ const selectedIds = control.ids ? JSON.parse(control.ids) : undefined;
551
+ const shard = control.shard?.split("/").map(Number);
552
+ if (shard && (shard.length !== 2 || shard.some(n => !Number.isInteger(n) || n < 1) || shard[0] > shard[1]))
553
+ throw new Error("shard must be INDEX/TOTAL (1-based)");
554
+ const locations = parseLocations(control.locations);
138
555
  const selected = specs.filter((item) => {
139
556
  if (hasOnly && item.annotation !== "only")
140
557
  return false;
558
+ if (locations && !atLocation(sourceOf(item), locations))
559
+ return false;
560
+ if (selectedIds && !selectedIds.includes(resolvedId(item)))
561
+ return false;
562
+ if (shard && stableHash(resolvedId(item)) % shard[1] !== shard[0] - 1)
563
+ return false;
564
+ if (control.id && resolvedId(item) !== control.id)
565
+ return false;
566
+ if (tagFilter.length > 0 && !matchesTags(tagsOf(item), tagFilter))
567
+ return false;
141
568
  if (!grep)
142
569
  return true;
143
570
  const id = resolvedId(item);
144
- try {
145
- const pattern = new RegExp(grep);
146
- return pattern.test(item.title) || pattern.test(id);
571
+ return pattern.test(item.title) || pattern.test(id);
572
+ });
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") {
577
+ throw new Error("No tests matched this selection. Check the entry and filters, or use --pass-with-no-tests.");
578
+ }
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 ? ", …" : ""}` });
147
601
  }
148
- catch {
149
- return item.title.includes(grep) || id.includes(grep);
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.` });
150
615
  }
151
- });
152
- // Resolve each hook's authored file once, so a `beforeEach` stays scoped to
153
- // the file that declared it rather than running for every spec in the run.
154
- const hookFiles = new Map(hooks.map((hook) => [hook, resolveOrigin(hook.frames).file]));
616
+ }
617
+ if (retries > 0) {
618
+ for (const item of selected.filter(item => !["skip", "fixme"].includes(item.annotation))) {
619
+ if (!resetHooks.some(hook => hookFiles.get(hook) === sourceOf(item).file)) {
620
+ throw new Error(`Retries require spec.reset() in ${sourceOf(item).file}; relaunching a page does not reset app state.`);
621
+ }
622
+ }
623
+ }
155
624
  const subject = await describeSubject();
156
625
  const cases = [];
157
626
  forceRelaunchNext = false;
158
- for (const item of selected) {
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.
629
+ let contaminated = false;
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];
639
+ const attempts = new Map();
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;
159
648
  const id = resolvedId(item);
649
+ const key = executionKey(id, repeat);
160
650
  const timeout = item.timeout;
161
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
+ }
162
663
  const record = {
163
664
  id,
164
665
  title: item.title,
165
666
  name: item.title,
166
- full_name: item.id ? `${item.id} | ${item.title}` : item.title,
667
+ full_name: fullName(item, repeat),
668
+ ...(repeatEach > 1 ? { repeat } : {}),
167
669
  file: source.file,
168
670
  line: source.line,
169
671
  suite: suiteOf(source.file),
170
672
  status: "passed",
171
673
  duration_ms: 0,
172
674
  covers: [...item.covers],
675
+ tags: tagsOf(item),
173
676
  steps: [],
174
677
  assertions: [],
175
678
  attachments: [],
176
679
  timeout_ms: timeout,
680
+ attempt: attempts.get(key)?.length ?? 0,
177
681
  reason: item.reason,
178
682
  };
179
683
  await host.emit({
180
684
  type: "case_started",
685
+ id, file: source.file, line: source.line,
181
686
  name: record.name,
182
687
  full_name: record.full_name,
183
688
  timeout_ms: timeout,
689
+ watchdog_timeout_ms: timeout + (item.timeoutCleanup ?? MAX_DEFER_BUDGET_MS) + FORENSICS_BUDGET_MS + WEDGED_DEFER_BUDGET_MS,
184
690
  covers: record.covers,
691
+ tags: record.tags,
185
692
  });
186
693
  const caseStarted = Date.now();
187
- if (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") {
698
+ if (contaminated)
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;
188
706
  record.status = "skipped";
189
707
  record.duration_ms = Date.now() - caseStarted;
190
708
  cases.push(record);
191
709
  await finishCase(host, record);
192
710
  continue;
193
711
  }
194
- const fixture = new LiveFixture(id, 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
+ }
195
745
  let status = "passed";
196
746
  let error;
197
747
  let phase = "body";
198
- 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;
199
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
+ };
200
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
+ }
201
810
  if (shouldRelaunch)
202
- await relaunchHome(fixture.raw);
811
+ await relaunchHome(fixture.raw, item.start);
203
812
  phase = "beforeEach";
813
+ for (const hook of resetHooks) {
814
+ if (hookFiles.get(hook) === source.file)
815
+ await hook.fn(fixture);
816
+ }
204
817
  for (const hook of hooks) {
205
818
  if (hookFiles.get(hook) === source.file)
206
819
  await hook.fn(fixture);
@@ -211,99 +824,329 @@ async function run() {
211
824
  const bodyResult = bodyPromise.then(() => ({ ok: true }), (err) => ({ ok: false, err }));
212
825
  const timeoutError = new TimeoutError(`spec timed out after ${timeout}ms`);
213
826
  let timedOut = false;
827
+ let bodySettled = false;
828
+ void bodyResult.then(() => { bodySettled = true; });
214
829
  let timerHandle;
215
830
  const timer = new Promise((resolve) => {
216
- timerHandle = setTimeout(() => {
831
+ timerHandle = runnerSetTimeout(() => {
217
832
  timedOut = true;
218
833
  fixture.abort(timeoutError);
219
834
  resolve("timeout");
220
835
  }, timeout);
221
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 }));
222
840
  try {
223
- const winner = await Promise.race([bodyResult, timer]);
224
- 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") {
225
857
  status = "timeout";
226
858
  error = timeoutError;
227
859
  phase = "timeout";
228
860
  forceRelaunchNext = true;
229
- await Promise.race([
230
- bodyResult,
231
- new Promise((resolve) => {
232
- setTimeout(resolve, WEDGED_DEFER_BUDGET_MS);
233
- }),
234
- ]);
861
+ try {
862
+ await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after timeout");
863
+ }
864
+ catch {
865
+ const work = stuckWork();
866
+ abandon("its timed-out body", work);
867
+ timeoutError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
868
+ }
235
869
  }
236
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
+ }
237
877
  if (timedOut || fixture.aborted) {
238
878
  status = "timeout";
239
879
  error = timeoutError;
240
880
  phase = "timeout";
241
881
  forceRelaunchNext = true;
242
882
  }
883
+ else if (winner.err instanceof SkipSignal) {
884
+ status = "skipped";
885
+ }
243
886
  else {
244
887
  status = "failed";
245
888
  error = winner.err;
246
889
  fixture.failurePhase = phase;
247
890
  }
248
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
+ }
249
908
  }
250
909
  finally {
251
910
  if (timerHandle !== undefined)
252
- clearTimeout(timerHandle);
911
+ runnerClearTimeout(timerHandle);
253
912
  }
254
- if (status !== "passed" && item.forensics) {
255
- phase = "forensics";
913
+ let evidenceCollected = false;
914
+ let failurePage;
915
+ const collectEvidence = async () => {
916
+ if (evidenceCollected || !item.forensics)
917
+ return;
918
+ evidenceCollected = true;
256
919
  try {
257
920
  // The whole point of the timeout path is that a wedged app does not
258
921
  // stall the run, and these calls bypass `guard` on the raw driver.
259
- await Promise.race([
260
- captureForensics(fixture),
261
- new Promise((resolve) => {
262
- setTimeout(resolve, FORENSICS_BUDGET_MS);
263
- }),
264
- ]);
922
+ failurePage = await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
265
923
  }
266
924
  catch (forensicsError) {
267
- if (status !== "timeout") {
268
- status = "failed";
269
- error = forensicsError;
270
- fixture.failurePhase = "forensics";
925
+ // Evidence failures must never replace the product failure.
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 });
271
928
  }
929
+ await host.emit({ type: "diagnostic", phase: "forensics", message: String(forensicsError) });
272
930
  }
273
- }
931
+ };
932
+ if (status !== "passed" && status !== "skipped")
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
+ };
274
945
  phase = "defer";
275
946
  const deferErrors = [];
276
- // A wedged app must not stall the run, but a healthy one deserves the time
277
- // its cleanup actually needs — a relaunch plus a page wait routinely
278
- // outruns the post-timeout budget, and a cut-short cleanup leaks the
279
- // fixture's state into every spec that follows.
280
- if (fixture.defers.length > 0) {
281
- // Cleanup is not spent from the spec's budget: a 3s spec whose defer
282
- // relaunches a page would otherwise be failed for its own tidying.
283
- fixture.allowCleanup(status === "timeout" ? WEDGED_DEFER_BUDGET_MS : MAX_DEFER_BUDGET_MS);
284
- }
285
- for (let index = fixture.defers.length - 1; index >= 0; index -= 1) {
947
+ const cleanupBudget = item.timeoutCleanup ?? (status === "timeout" ? WEDGED_DEFER_BUDGET_MS : MAX_DEFER_BUDGET_MS);
948
+ const afterEach = afterHooks.filter(hook => hookFiles.get(hook) === source.file);
949
+ const hasCleanup = afterEach.length > 0 || fixture.defers.length > 0;
950
+ function* cleanupTasks() {
951
+ for (const hook of afterEach)
952
+ yield () => hook.fn(fixture);
953
+ while (fixture.defers.length > 0)
954
+ yield fixture.defers.pop();
955
+ }
956
+ // Never reopen a timed-out fixture while its body can still issue commands.
957
+ if (hasCleanup && bodySettled) {
958
+ fixture.allowCleanup(cleanupBudget);
959
+ const deadline = Date.now() + cleanupBudget;
960
+ for (const cleanup of cleanupTasks()) {
961
+ let settled = false;
962
+ const task = Promise.resolve().then(cleanup).finally(() => { settled = true; });
963
+ try {
964
+ await within(task, Math.max(0, deadline - Date.now()), "fixture cleanup budget exceeded");
965
+ }
966
+ catch (err) {
967
+ deferErrors.push(err);
968
+ }
969
+ if (!settled) {
970
+ abandon("its cleanup (t.defer / afterEach)", stuckWork());
971
+ break;
972
+ }
973
+ }
974
+ }
975
+ else if (hasCleanup) {
976
+ deferErrors.push(new Error("Cleanup skipped because the timed-out body is still running"));
977
+ }
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;
286
1100
  try {
287
- await fixture.defers[index]();
1101
+ checked = await checkContract(ledger, fixture, id, host);
288
1102
  }
289
- catch (deferError) {
290
- deferErrors.push(deferError);
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
+ }
291
1129
  }
292
1130
  }
293
1131
  if (item.annotation === "fail") {
294
- const bodyAssertion = status === "failed" &&
295
- fixture.failurePhase === "body" &&
296
- error instanceof AssertionError;
297
- if (bodyAssertion) {
298
- 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;
299
1143
  }
300
1144
  else if (status === "passed") {
301
1145
  status = "xpass";
302
1146
  error = new Error("spec.fail passed (xpass)");
1147
+ fixture.failurePhase = "body";
303
1148
  }
304
1149
  }
305
- if (fixture.defers.length > 0)
306
- fixture.endCleanup();
307
1150
  if (deferErrors.length > 0) {
308
1151
  // A spec that hung is reported as a timeout; cleanup that could not
309
1152
  // finish afterwards is a consequence of the hang, not a different
@@ -322,105 +1165,513 @@ async function run() {
322
1165
  error = new Error(`t.defer failed:\n${cleanupText}`);
323
1166
  }
324
1167
  }
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")
1200
+ await collectEvidence();
1201
+ if (status === "skipped")
1202
+ record.reason = fixture.skipReason ?? record.reason;
1203
+ fixture.close();
325
1204
  record.status = status;
326
1205
  record.duration_ms = Date.now() - caseStarted;
327
- record.steps = fixture.steps;
328
- 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
+ }
329
1230
  if (fixture.observed.size > 0)
330
1231
  record.observed = [...fixture.observed].sort();
331
- record.attachments = fixture.attachments;
332
- setAssertionSink();
333
- if (error && status !== "xfail") {
1232
+ record.attachments = [...fixture.attachments];
1233
+ setExpectScope();
1234
+ if (error)
334
1235
  record.error = toReportError(error, fixture.currentStepPath());
1236
+ if (record.error) {
1237
+ record.error.phase = status === "timeout" ? "timeout" : fixture.failurePhase ?? phase;
1238
+ // JSC tail calls can omit the authored assertion frame.
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;
335
1255
  }
336
- else if (status === "xfail" && error) {
337
- record.error = toReportError(error, fixture.currentStepPath());
1256
+ const history = attempts.get(key) ?? [];
1257
+ history.push(record);
1258
+ attempts.set(key, history);
1259
+ const finished = { ...record, attempts: history.length > 1 ? history.map(attempt => ({ ...attempt })) : undefined,
1260
+ flaky: record.status === "passed" && history.length > 1 };
1261
+ const previous = cases.findIndex(item => item.id === id && (item.repeat ?? 1) === repeat);
1262
+ if (previous >= 0)
1263
+ cases[previous] = finished;
1264
+ else
1265
+ cases.push(finished);
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);
1322
+ if (!contaminated && (status === "failed" || status === "timeout") && history.length <= retries) {
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.") });
338
1338
  }
339
- cases.push(record);
340
- await finishCase(host, record);
341
1339
  }
342
1340
  const counts = countStatuses(cases);
343
1341
  const duration_ms = Date.now() - started;
1342
+ const reportedControl = { ...control };
1343
+ for (const key of PAYLOAD_CONTROL_KEYS)
1344
+ delete reportedControl[key];
344
1345
  const json = {
1346
+ schema_version: 1,
345
1347
  framework: { name: PACKAGE_NAME, version: VERSION },
346
1348
  meta: {
347
1349
  started_at: new Date(started).toISOString(),
348
1350
  duration_ms,
349
- args: { ...host.args },
350
- platform: host.args.platform,
351
- framework: host.args.framework,
1351
+ args: redact.args(args),
1352
+ run: redact.deep(reportedControl),
1353
+ platform: control.platform,
1354
+ framework: args.framework,
352
1355
  subject,
353
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 } : {}),
354
1361
  },
355
- partial: false,
356
- filtered: Boolean(grep) || hasOnly,
1362
+ partial: contaminated || budgetExhausted !== undefined || screenLocked !== undefined,
1363
+ filtered: Boolean(grep || control.id || control.ids || shard || locations || tagFilter.length > 0) || hasOnly,
357
1364
  duration_ms,
358
1365
  ...counts,
359
- cases,
1366
+ cases: redact.deep(cases),
360
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);
361
1377
  await attachText(host, "report.json", JSON.stringify(json, null, 2), "application/json");
362
1378
  await attachText(host, "report.html", renderHtml(json), "text/html; charset=utf-8");
363
1379
  await attachText(host, "junit.xml", renderJUnit(json), "application/xml; charset=utf-8");
364
- const protocol = {
365
- total: cases.length,
366
- passed: cases.filter((item) => protocolStatus(item.status) === "passed").length,
367
- failed: cases.filter((item) => protocolStatus(item.status) === "failed").length,
368
- skipped: cases.filter((item) => protocolStatus(item.status) === "skipped").length,
369
- duration_ms: json.duration_ms,
370
- cases: cases.map((item) => ({
371
- name: item.name,
372
- full_name: item.full_name,
373
- status: protocolStatus(item.status),
374
- duration_ms: item.duration_ms,
375
- error: item.error && protocolStatus(item.status) === "failed"
376
- ? {
377
- name: item.error.name,
378
- message: item.error.message,
379
- stack: item.error.stack,
380
- causes: [],
381
- }
382
- : undefined,
383
- })),
384
- };
385
- return protocol;
1380
+ return json;
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}`);
386
1418
  }
387
1419
  async function finishCase(host, record) {
388
1420
  await host.emit({
389
1421
  type: "case_finished",
390
1422
  name: record.name,
391
1423
  full_name: record.full_name,
392
- status: protocolStatus(record.status),
1424
+ status: record.status,
1425
+ id: record.id,
393
1426
  duration_ms: record.duration_ms,
394
- error: record.error && protocolStatus(record.status) === "failed"
395
- ? record.error
396
- : undefined,
1427
+ error: record.error,
1428
+ record,
397
1429
  });
398
1430
  }
399
- /** 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
+ */
400
1439
  async function describeSubject() {
401
- try {
402
- 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;
403
1493
  return {
404
- appid: asText(info.appid),
405
- app_name: asText(info.app_name),
406
- version: asText(info.version),
407
- release_type: asText(info.release_type),
408
- 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 } : {}),
409
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) : [];
410
1521
  }
411
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`);
412
1606
  return undefined;
413
1607
  }
1608
+ catch (error) {
1609
+ return errorMessage(error);
1610
+ }
414
1611
  }
415
- function asText(value) {
416
- 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
+ };
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;
417
1667
  }
1668
+ /** Attach failure evidence; resolves to the page that was current, if known. */
418
1669
  async function captureForensics(fixture) {
419
1670
  try {
420
1671
  const shot = await fixture.raw.page.screenshot();
421
1672
  const payload = encodeScreenshot(shot);
422
1673
  if (payload) {
423
- await fixture.attachRaw("failure.png", payload);
1674
+ await fixture.attachRaw("failure.png", payload, "failure_evidence");
424
1675
  }
425
1676
  }
426
1677
  catch {
@@ -440,16 +1691,29 @@ async function captureForensics(fixture) {
440
1691
  catch {
441
1692
  info = undefined;
442
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
+ }
443
1703
  const forensics = {
444
1704
  route,
445
1705
  info,
446
1706
  step: fixture.currentStepPath() ?? null,
1707
+ visibility: visibility ?? null,
447
1708
  };
448
- await fixture.attachRaw("forensics.json", forensics);
1709
+ await fixture.attachRaw("forensics.json", forensics, "failure_evidence");
449
1710
  const logs = await fixtureHostLogs();
450
1711
  if (logs !== undefined) {
451
- await fixture.attachRaw("logs.txt", logs);
1712
+ await fixture.attachRaw("logs.txt", logs, "failure_evidence");
452
1713
  }
1714
+ const page = toFailurePage(route);
1715
+ const hidden = hiddenCause(visibility?.note);
1716
+ return page && hidden ? { ...page, hidden } : page;
453
1717
  }
454
1718
  async function fixtureHostLogs() {
455
1719
  const host = resolveHost();
@@ -465,15 +1729,23 @@ function encodeScreenshot(shot) {
465
1729
  }
466
1730
  function reset() {
467
1731
  specs.length = 0;
1732
+ fileConfigs.length = 0;
1733
+ setActiveOpenApi(undefined);
468
1734
  hooks.length = 0;
1735
+ afterHooks.length = 0;
1736
+ resetHooks.length = 0;
469
1737
  trackSurface = false;
470
- fileCounts.clear();
471
1738
  forceRelaunchNext = false;
1739
+ uninstallPendingTracker();
1740
+ unfenceGlobalAutomation();
1741
+ restoreRawAuthority();
472
1742
  clearInline();
473
- setAssertionSink();
1743
+ setExpectScope();
1744
+ setStrayAssertionSink();
474
1745
  }
475
1746
  const controller = {
476
1747
  run,
1748
+ list,
477
1749
  version: VERSION,
478
1750
  reset,
479
1751
  };
@@ -485,5 +1757,94 @@ if (!globalThis.__LINGXIA_TEST__) {
485
1757
  writable: false,
486
1758
  });
487
1759
  }
488
- 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
+ }
1792
+ async function within(task, ms, message) {
1793
+ let handle;
1794
+ try {
1795
+ return await Promise.race([task, new Promise((_, reject) => {
1796
+ handle = runnerSetTimeout(() => reject(new TimeoutError(message)), ms);
1797
+ })]);
1798
+ }
1799
+ finally {
1800
+ if (handle !== undefined)
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);
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
+ };
1843
+ }
1844
+ function stableHash(value) {
1845
+ let hash = 2166136261;
1846
+ for (let i = 0; i < value.length; i++)
1847
+ hash = Math.imul(hash ^ value.charCodeAt(i), 16777619);
1848
+ return hash >>> 0;
1849
+ }
489
1850
  //# sourceMappingURL=runtime.js.map