@lingxia/test 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/clock.d.ts +30 -0
  2. package/dist/clock.d.ts.map +1 -0
  3. package/dist/clock.js +99 -0
  4. package/dist/clock.js.map +1 -0
  5. package/dist/coverage.d.ts +25 -0
  6. package/dist/coverage.d.ts.map +1 -0
  7. package/dist/coverage.js +92 -0
  8. package/dist/coverage.js.map +1 -0
  9. package/dist/deadline.d.ts +82 -0
  10. package/dist/deadline.d.ts.map +1 -0
  11. package/dist/deadline.js +184 -0
  12. package/dist/deadline.js.map +1 -0
  13. package/dist/dialogs.d.ts +28 -0
  14. package/dist/dialogs.d.ts.map +1 -0
  15. package/dist/dialogs.js +74 -0
  16. package/dist/dialogs.js.map +1 -0
  17. package/dist/equal.d.ts +28 -0
  18. package/dist/equal.d.ts.map +1 -0
  19. package/dist/equal.js +149 -0
  20. package/dist/equal.js.map +1 -0
  21. package/dist/errors.d.ts +8 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +37 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/expect.d.ts +35 -7
  26. package/dist/expect.d.ts.map +1 -1
  27. package/dist/expect.js +216 -68
  28. package/dist/expect.js.map +1 -1
  29. package/dist/fixture.d.ts +201 -23
  30. package/dist/fixture.d.ts.map +1 -1
  31. package/dist/fixture.js +1127 -157
  32. package/dist/fixture.js.map +1 -1
  33. package/dist/format.d.ts +2 -0
  34. package/dist/format.d.ts.map +1 -1
  35. package/dist/format.js +78 -0
  36. package/dist/format.js.map +1 -1
  37. package/dist/host-types.d.ts +65 -0
  38. package/dist/host-types.d.ts.map +1 -0
  39. package/dist/host-types.js +2 -0
  40. package/dist/host-types.js.map +1 -0
  41. package/dist/host.d.ts +20 -0
  42. package/dist/host.d.ts.map +1 -1
  43. package/dist/host.js +33 -1
  44. package/dist/host.js.map +1 -1
  45. package/dist/ids.d.ts +14 -0
  46. package/dist/ids.d.ts.map +1 -1
  47. package/dist/ids.js +37 -2
  48. package/dist/ids.js.map +1 -1
  49. package/dist/index.d.ts +5 -7
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/json-result.d.ts +3 -0
  54. package/dist/json-result.d.ts.map +1 -0
  55. package/dist/json-result.js +62 -0
  56. package/dist/json-result.js.map +1 -0
  57. package/dist/junit.d.ts +1 -1
  58. package/dist/junit.d.ts.map +1 -1
  59. package/dist/junit.js +32 -4
  60. package/dist/junit.js.map +1 -1
  61. package/dist/locator.d.ts +87 -9
  62. package/dist/locator.d.ts.map +1 -1
  63. package/dist/locator.js +413 -86
  64. package/dist/locator.js.map +1 -1
  65. package/dist/mock.d.ts +43 -0
  66. package/dist/mock.d.ts.map +1 -0
  67. package/dist/mock.js +162 -0
  68. package/dist/mock.js.map +1 -0
  69. package/dist/network.d.ts +68 -0
  70. package/dist/network.d.ts.map +1 -0
  71. package/dist/network.js +287 -0
  72. package/dist/network.js.map +1 -0
  73. package/dist/openapi.d.ts +127 -0
  74. package/dist/openapi.d.ts.map +1 -0
  75. package/dist/openapi.js +414 -0
  76. package/dist/openapi.js.map +1 -0
  77. package/dist/pending.d.ts +84 -0
  78. package/dist/pending.d.ts.map +1 -0
  79. package/dist/pending.js +311 -0
  80. package/dist/pending.js.map +1 -0
  81. package/dist/redact.d.ts +41 -0
  82. package/dist/redact.d.ts.map +1 -0
  83. package/dist/redact.js +168 -0
  84. package/dist/redact.js.map +1 -0
  85. package/dist/remote.d.ts +32 -0
  86. package/dist/remote.d.ts.map +1 -0
  87. package/dist/remote.js +117 -0
  88. package/dist/remote.js.map +1 -0
  89. package/dist/report-extras.d.ts +16 -0
  90. package/dist/report-extras.d.ts.map +1 -0
  91. package/dist/report-extras.js +125 -0
  92. package/dist/report-extras.js.map +1 -0
  93. package/dist/report-types.d.ts +428 -0
  94. package/dist/report-types.d.ts.map +1 -0
  95. package/dist/report-types.js +6 -0
  96. package/dist/report-types.js.map +1 -0
  97. package/dist/report.d.ts +15 -1
  98. package/dist/report.d.ts.map +1 -1
  99. package/dist/report.js +195 -19
  100. package/dist/report.js.map +1 -1
  101. package/dist/runner.d.ts +13 -0
  102. package/dist/runner.d.ts.map +1 -0
  103. package/dist/runner.js +11 -0
  104. package/dist/runner.js.map +1 -0
  105. package/dist/runtime.d.ts +54 -4
  106. package/dist/runtime.d.ts.map +1 -1
  107. package/dist/runtime.js +1375 -97
  108. package/dist/runtime.js.map +1 -1
  109. package/dist/schema.d.ts +29 -0
  110. package/dist/schema.d.ts.map +1 -0
  111. package/dist/schema.js +463 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/spec-api.d.ts +7 -2
  114. package/dist/spec-api.d.ts.map +1 -1
  115. package/dist/tags.d.ts +26 -0
  116. package/dist/tags.d.ts.map +1 -0
  117. package/dist/tags.js +78 -0
  118. package/dist/tags.js.map +1 -0
  119. package/dist/types.d.ts +852 -165
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +18 -5
  124. package/schemas/lxdev.schema.json +50 -0
  125. package/schemas/mock-config.schema.json +21 -0
  126. package/schemas/scenario.schema.json +180 -0
package/dist/fixture.js CHANGED
@@ -1,21 +1,41 @@
1
- import { AssertionError, applyMatcher, expect as immediateExpect, popAssertionSilence, pushAssertionSilence, setAssertionSink, } from "./expect.js";
2
- import { formatValue, truncate } from "./format.js";
1
+ import { AssertionError, applyMatcher, check, popAssertionSilence, pushAssertionSilence, setExpectScope, } from "./expect.js";
2
+ import { firstDifference, formatValue, truncate } from "./format.js";
3
3
  import { encodeAttachPayload, remapStack } from "./host.js";
4
4
  import { rememberInline } from "./report.js";
5
+ import { NetworkScope, wrapNetwork } from "./network.js";
6
+ import { ScenarioScope, installScenario } from "./mock.js";
7
+ import { ClockScope, wrapClock } from "./clock.js";
8
+ import { watchDialogs, wrapDialogs } from "./dialogs.js";
9
+ import { activeOpenApi } from "./openapi.js";
10
+ import { ActionDeadline, TimeoutError, asFixtureTimeout } from "./deadline.js";
11
+ import { isTransientTransportError, matchesErrorCode } from "./deadline.js";
12
+ import { checkJsonArgs, explainRemoteError, isRetryableRemoteError, functionDetail, logicScript, pageScript } from "./remote.js";
5
13
  import { callerLocation, displayLocation, isFrameworkFrame, parseFrames, resolveOrigin } from "./ids.js";
6
- import { PageLocator, sleep, testIdSelector, } from "./locator.js";
14
+ import { PageLocator, normalizeText, sleep, testIdSelector, } from "./locator.js";
7
15
  import { DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_SPEC_TIMEOUT_MS, MAX_ACTIONS, MAX_EVAL_BUDGET_MS, WEDGED_DEFER_BUDGET_MS, } from "./version.js";
8
- export class TimeoutError extends Error {
9
- constructor() {
10
- super(...arguments);
11
- this.name = "TimeoutError";
16
+ export { TimeoutError };
17
+ /**
18
+ * Thrown by `t.skip()`. It unwinds the spec like any throw, but the runtime
19
+ * grades it `skipped`, never `failed` — and not `xfail` under `spec.fail`.
20
+ * Internal: a spec never needs to name it.
21
+ */
22
+ export class SkipSignal extends Error {
23
+ constructor(reason) {
24
+ super(`skipped: ${reason}`);
25
+ this.reason = reason;
26
+ this.name = "SkipSignal";
27
+ this.code = "E_SKIPPED";
12
28
  }
13
29
  }
14
30
  export class LiveFixture {
15
- constructor(specId, rawApp, host, args, automation, specBudgetMs = DEFAULT_SPEC_TIMEOUT_MS) {
31
+ traceMeta() {
32
+ return { sequence: ++this.eventSequence, at_ms: Math.max(0, Date.now() - this.startedAt) };
33
+ }
34
+ constructor(specId, rawApp, host, args, automation, specBudgetMs = DEFAULT_SPEC_TIMEOUT_MS, redactor, appId, traceOriginMs) {
16
35
  this.specId = specId;
17
36
  this.host = host;
18
37
  this.specBudgetMs = specBudgetMs;
38
+ this.redactor = redactor;
19
39
  this.steps = [];
20
40
  /**
21
41
  * `lx.*` members this spec's evals actually reached. Collected so the report
@@ -30,57 +50,236 @@ export class LiveFixture {
30
50
  this.abortError = null;
31
51
  this.actionSilence = 0;
32
52
  this.actionCount = 0;
53
+ this.traceTruncated = false;
54
+ this.eventSequence = 0;
33
55
  /** Actions still in flight, so an abort can mark them instead of leaving
34
56
  * them at their optimistic default. */
35
57
  this.openActions = new Set();
58
+ /** Fixture calls that have started and not settled; see `track`. */
59
+ this.inFlight = new Set();
36
60
  this.cleanupUntil = 0;
37
61
  this.cleanupActive = false;
62
+ /**
63
+ * The spec reached an app page (a locator, view, nav or page-bound Logic
64
+ * call). A failure names the current page only then: a unit spec that
65
+ * never touched one is not "on" whatever page the app happens to show.
66
+ */
67
+ this.usedPage = false;
38
68
  this.failurePhase = null;
69
+ /** Locator assertions that timed out, by error: the page instance they were bound to. */
70
+ this.locatorFailures = new WeakMap();
71
+ /** Instance ids `t.app.page()` bound, so a trace detail's `#id` is not read from a CSS selector. */
72
+ this.boundPages = new Set();
39
73
  this.stepStack = [];
40
- this.rawApp = rawApp;
41
- this.args = args;
42
- setAssertionSink((entry) => this.noteAssertion(entry));
43
- const root = guardObject(automation, this, "", ["lxapp"]);
74
+ this.stepStarted = new WeakMap();
75
+ /** Every app a fixture app reaches; a profile switch re-selects them. */
76
+ this.appRefs = [];
77
+ this.dialogWatches = new Map();
78
+ this.dialogInitializers = new Map();
79
+ this.networkScope = new NetworkScope();
80
+ /** The scenario this spec installed; removed when it ends. */
81
+ this.scenarioScope = new ScenarioScope();
82
+ /** Test clocks this spec installed; uninstalled when it ends. */
83
+ this.clockScope = new ClockScope();
84
+ this.pinned = { appid: appId, driver: rawApp };
85
+ this.appRefs.push(this.pinned);
86
+ this.hostAutomation = automation;
87
+ this.startedAt = traceOriginMs ?? Date.now();
88
+ this.argValues = args;
89
+ this.specDeadline = Date.now() + specBudgetMs;
90
+ let resolveDialogFailure;
91
+ this.dialogFailure = new Promise((resolve) => { resolveDialogFailure = resolve; });
92
+ this.resolveDialogFailure = resolveDialogFailure;
93
+ setExpectScope({
94
+ note: (entry) => this.noteAssertion(entry),
95
+ locator: (locator, message) => this.locatorMatchers(locator, false, message),
96
+ poll: (read, options) => this.pollMatchers(read, options, false, "expect.poll"),
97
+ });
98
+ const root = guardObject(automation, this, "", ["lxapp", ...HOST_TIERS]);
44
99
  this.automation = new Proxy(root, {
45
100
  get: (target, prop) => prop === "lxapp"
46
101
  ? (appId) => {
47
102
  this.assertRunnable();
48
- return this.wrapApp(appId === undefined ? automation.lxapp() : automation.lxapp(appId));
103
+ return this.appOf(appId === undefined ? this.pinned : this.refFor(appId));
49
104
  }
50
- : Reflect.get(target, prop),
105
+ // Reading a host tier never throws, even on a host without it; each
106
+ // call resolves it and rejects there instead.
107
+ : HOST_TIERS.includes(prop)
108
+ ? lazyDriver(() => ({ owner: automation, value: Reflect.get(automation, prop) }), this, `${String(prop)}.`)
109
+ : Reflect.get(target, prop),
51
110
  });
52
- this.apps = { lxapp: (appId) => this.automation.lxapp(appId) };
53
111
  }
54
112
  get app() {
55
- return this.wrapApp(this.rawApp);
113
+ return this.appOf(this.pinned);
114
+ }
115
+ get scenario() {
116
+ return { use: async (definition, options) => {
117
+ const app = options?.app;
118
+ if (app === undefined) {
119
+ return installScenario(() => this.pinned.driver, this, this.scenarioScope, definition, options?.variant);
120
+ }
121
+ if (typeof app !== "string" || app.trim() === "") {
122
+ throw new TypeError("t.scenario.use { app } must be a non-empty app id");
123
+ }
124
+ // Function rules go to the run's companion, which no app scopes.
125
+ const appScoped = "t.scenario.use { app } takes a scenario of http rules only; function rules are not app-scoped";
126
+ if (hasFunctionRules(definition, options?.variant))
127
+ throw new TypeError(appScoped);
128
+ const ref = this.refFor(app);
129
+ const scenario = await installScenario(() => ref.driver, this, this.scenarioScope, definition, options?.variant);
130
+ if (scenario.rules.some((rule) => rule.kind === "function")) {
131
+ await scenario.remove();
132
+ throw new TypeError(appScoped);
133
+ }
134
+ return scenario;
135
+ } };
56
136
  }
57
137
  get raw() {
58
- return this.rawApp;
138
+ return this.pinned.driver;
139
+ }
140
+ async watchPrimaryDialogs() {
141
+ await this.ensureDialogWatch(this.pinned);
142
+ }
143
+ ensureDialogWatch(ref) {
144
+ const pending = this.dialogInitializers.get(ref);
145
+ if (pending)
146
+ return pending;
147
+ const task = this.startDialogWatch(ref).finally(() => { this.dialogInitializers.delete(ref); });
148
+ this.dialogInitializers.set(ref, task);
149
+ return task;
150
+ }
151
+ async startDialogWatch(ref) {
152
+ const appid = ref.appid ?? (await new ActionDeadline(DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom())
153
+ .call("dialog watch app info", () => ref.driver.info(), () => "starting dialog watch")).appId;
154
+ ref.appid = appid;
155
+ if (this.dialogWatches.has(appid))
156
+ return;
157
+ const watch = watchDialogs(ref.driver);
158
+ // Only another app's dialog needs its app named.
159
+ const label = (message) => ref === this.pinned ? message : `${appid}: ${message}`;
160
+ this.dialogWatches.set(appid, { ref, watch, label });
161
+ if (watch)
162
+ void watch.unanswered.then((message) => this.resolveDialogFailure(label(message)));
163
+ }
164
+ async endDialogWatches() {
165
+ const unused = [];
166
+ const failures = [];
167
+ for (const [appid, entry] of this.dialogWatches) {
168
+ try {
169
+ const message = await entry.watch?.end(entry.ref.driver);
170
+ if (message)
171
+ unused.push(entry.label(message));
172
+ }
173
+ catch (error) {
174
+ failures.push(entry.label(String(error)));
175
+ }
176
+ this.dialogWatches.delete(appid);
177
+ }
178
+ return { unused, failures };
179
+ }
180
+ /** One fixture app per reached app, so a saved handle stays the same object. */
181
+ appOf(ref) {
182
+ return ref.app ?? (ref.app = this.wrapApp(ref));
183
+ }
184
+ refFor(appid) {
185
+ return this.appRefs.find((ref) => ref.appid === appid) ??
186
+ this.trackRef({ appid, driver: this.hostAutomation.lxapp(appid) });
187
+ }
188
+ trackRef(ref) {
189
+ this.appRefs.push(ref);
190
+ return ref;
191
+ }
192
+ get openapi() {
193
+ const index = activeOpenApi();
194
+ if (!index)
195
+ return undefined;
196
+ return {
197
+ documents: index.documents.map((doc) => ({
198
+ name: doc.name,
199
+ version: doc.version,
200
+ ...(doc.title ? { title: doc.title } : {}),
201
+ })),
202
+ };
203
+ }
204
+ /** `t.app.profile`: it re-selects the app after a switch. */
205
+ profileFixture(ref) {
206
+ return {
207
+ checkpoint: () => this.act("profile.checkpoint", "", () => this.reopening(ref, async (driver) => ({ id: await driver.profile.checkpoint() }))),
208
+ restore: (checkpoint, options) => {
209
+ const id = checkpointId(checkpoint, "t.app.profile.restore");
210
+ return this.act("profile.restore", options?.keep?.length ? `${id} keep ${options.keep.join(",")}` : id, () => this.reopening(ref, (driver) => options?.keep?.length ? driver.profile.restore(id, { keep: [...options.keep] }) : driver.profile.restore(id)));
211
+ },
212
+ drop: (checkpoint) => {
213
+ const id = checkpointId(checkpoint, "t.app.profile.drop");
214
+ return this.act("profile.drop", id, async () => { await ref.driver.profile.drop(id); });
215
+ },
216
+ };
217
+ }
218
+ /**
219
+ * A profile switch closes the app and reopens it as a new instance, which
220
+ * the old driver no longer reaches: select the same lxapp again after it,
221
+ * for every fixture app that reaches it. Fixture apps read `ref.driver` on
222
+ * each call, so one saved before the switch follows the reopened app.
223
+ */
224
+ async reopening(ref, op) {
225
+ const appid = ref.appid ?? (await ref.driver.info()).appId;
226
+ ref.appid = appid;
227
+ // The no-arg alias is `pinned`; an explicit same-app alias can still be
228
+ // reached before its id was known. A closed unrelated app must not block
229
+ // this profile switch just because its handle was read earlier.
230
+ if (this.pinned.appid === undefined && ref !== this.pinned) {
231
+ try {
232
+ this.pinned.appid = (await this.pinned.driver.info()).appId;
233
+ }
234
+ catch { /* unavailable alias */ }
235
+ }
236
+ try {
237
+ return await op(ref.driver);
238
+ }
239
+ finally {
240
+ for (const reached of this.appRefs) {
241
+ if (reached.appid === appid)
242
+ reached.driver = this.hostAutomation.lxapp(appid);
243
+ }
244
+ }
59
245
  }
60
246
  step(name, body) {
61
- return this.guard(async () => {
247
+ return this.runStep(name, body);
248
+ }
249
+ runStep(name, body, parent) {
250
+ return this.track("t.step", name, () => this.guard(async () => {
251
+ const active = this.stepStack[this.stepStack.length - 1];
252
+ if (active !== parent) {
253
+ const conflict = new Error("Overlapping t.step calls cannot share an implicit step owner. Await top-level steps sequentially; nest with the callback scope: t.step('outer', async (step) => step.step('inner', ...)).");
254
+ this.stepConflict ?? (this.stepConflict = conflict);
255
+ throw conflict;
256
+ }
62
257
  const record = {
63
258
  name,
64
- path: [...this.stepStack.map((step) => step.name), name].join(" > "),
259
+ ...this.traceMeta(),
260
+ path: parent ? `${parent.path} > ${name}` : name,
65
261
  status: "passed",
66
262
  duration_ms: 0,
67
263
  steps: [],
68
264
  attachments: [],
69
265
  assertions: [],
70
266
  };
71
- const parent = this.stepStack[this.stepStack.length - 1];
72
267
  (parent ? parent.steps : this.steps).push(record);
73
268
  this.stepStack.push(record);
74
- await this.host.emit({
269
+ const started = Date.now();
270
+ this.stepStarted.set(record, started);
271
+ await this.emitTrace({
75
272
  type: "step_started",
76
273
  name,
77
274
  path: record.path,
78
275
  });
79
- const started = Date.now();
80
276
  try {
81
- const result = await body();
277
+ const scope = {
278
+ step: (childName, childBody) => this.runStep(childName, childBody, record),
279
+ };
280
+ const result = await body(scope);
82
281
  record.duration_ms = Date.now() - started;
83
- await this.host.emit({
282
+ await this.emitTrace({
84
283
  type: "step_finished",
85
284
  name,
86
285
  path: record.path,
@@ -91,10 +290,21 @@ export class LiveFixture {
91
290
  }
92
291
  catch (error) {
93
292
  record.duration_ms = Date.now() - started;
293
+ if (error instanceof SkipSignal) {
294
+ record.status = "skipped";
295
+ await this.emitTrace({
296
+ type: "step_finished",
297
+ name,
298
+ path: record.path,
299
+ status: record.status,
300
+ duration_ms: record.duration_ms,
301
+ });
302
+ throw error;
303
+ }
94
304
  record.status = error instanceof TimeoutError ? "timeout" : "failed";
95
305
  record.error = toReportError(error, record.path);
96
306
  this.lastStepPath = record.path;
97
- await this.host.emit({
307
+ await this.emitTrace({
98
308
  type: "step_finished",
99
309
  name,
100
310
  path: record.path,
@@ -105,14 +315,12 @@ export class LiveFixture {
105
315
  throw error;
106
316
  }
107
317
  finally {
108
- this.stepStack.pop();
318
+ this.stepStarted.delete(record);
319
+ const index = this.stepStack.lastIndexOf(record);
320
+ if (index >= 0)
321
+ this.stepStack.splice(index, 1);
109
322
  }
110
- });
111
- }
112
- get expect() {
113
- const fn = ((locator) => this.locatorMatchers(locator, false));
114
- fn.poll = (read, options) => this.pollMatchers(read, options, false);
115
- return fn;
323
+ }));
116
324
  }
117
325
  async reject(operation, expected = {}) {
118
326
  return this.guard(async () => {
@@ -142,7 +350,7 @@ export class LiveFixture {
142
350
  ].filter(Boolean).join("\n"));
143
351
  }
144
352
  const record = received;
145
- if (expected.code !== undefined && record.code !== expected.code) {
353
+ if (expected.code !== undefined && !matchesErrorCode(received, expected.code)) {
146
354
  this.noteAssertion({
147
355
  matcher: "reject",
148
356
  expected: formatValue(expected.code),
@@ -158,10 +366,10 @@ export class LiveFixture {
158
366
  ].filter(Boolean).join("\n"));
159
367
  }
160
368
  if (typeof expected.message === "string") {
161
- immediateExpect(String(record.message)).toContain(expected.message);
369
+ check(String(record.message)).toContain(expected.message);
162
370
  }
163
371
  if (expected.message instanceof RegExp) {
164
- immediateExpect(String(record.message)).toMatch(expected.message);
372
+ check(String(record.message)).toMatch(expected.message);
165
373
  }
166
374
  this.noteAssertion({
167
375
  matcher: "reject",
@@ -175,12 +383,114 @@ export class LiveFixture {
175
383
  defer(cleanup) {
176
384
  this.defers.push(cleanup);
177
385
  }
386
+ arg(name, options = {}) {
387
+ const value = this.argValues[name];
388
+ if (value !== undefined)
389
+ return value;
390
+ if (options.default !== undefined)
391
+ return options.default;
392
+ if (options.required === false)
393
+ return undefined;
394
+ // Keys are case-sensitive: `LXDEV_SECRET_PASSWORD` is `PASSWORD`.
395
+ const other = Object.keys(this.argValues).find((key) => key !== name && key.toLowerCase() === name.toLowerCase());
396
+ const hint = other === undefined
397
+ ? ""
398
+ : `; "${other}" was given, and arg keys are case-sensitive ` +
399
+ `(LXDEV_ARG_<KEY> / LXDEV_SECRET_<KEY> keep the case of <KEY>)`;
400
+ throw new Error(`Missing test arg "${name}": pass --arg ${name}=<value> ` +
401
+ `(or --secret-arg ${name}=<value>) to lxdev test${hint}`);
402
+ }
403
+ waitFor(read, options = {}) {
404
+ const location = callerLocation();
405
+ if (typeof options === "function") {
406
+ throw new TypeError("t.waitFor(read, { until }) takes its acceptance test as `until`, not as a second argument");
407
+ }
408
+ const accept = options.until ?? Boolean;
409
+ const requested = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
410
+ const interval = options.interval ?? DEFAULT_POLL_INTERVAL_MS;
411
+ if (!Number.isFinite(requested) || requested <= 0 || !Number.isFinite(interval) || interval <= 0) {
412
+ throw new TypeError("t.waitFor timeout and interval must be positive finite numbers");
413
+ }
414
+ const retryIf = options.retryIf ?? isRetryableReadError;
415
+ const detail = truncate(read.name || functionDetail(read), 80);
416
+ return this.act("waitFor", detail, async () => {
417
+ const deadline = new ActionDeadline(requested, this.budgetRoom());
418
+ let attempts = 0;
419
+ let hasValue = false;
420
+ let lastValue;
421
+ let lastError;
422
+ this.silenceActions();
423
+ try {
424
+ for (;;) {
425
+ if (deadline.expired())
426
+ break;
427
+ attempts += 1;
428
+ const before = new Set(this.inFlight);
429
+ try {
430
+ const value = await deadline.call("t.waitFor read", read, () => `${detail} at ${displayLocation(location.file, location.line, location.column)}`);
431
+ if (deadline.expired())
432
+ break;
433
+ lastValue = value;
434
+ hasValue = true;
435
+ lastError = undefined;
436
+ if (accept(value))
437
+ return value;
438
+ }
439
+ catch (error) {
440
+ if (error instanceof SkipSignal || this.aborted)
441
+ throw error;
442
+ if (error instanceof TimeoutError && deadline.expired()) {
443
+ for (const call of this.inFlight)
444
+ if (!before.has(call))
445
+ call.detached = true;
446
+ break;
447
+ }
448
+ if (!retryIf(error))
449
+ throw error;
450
+ lastError = error;
451
+ }
452
+ const pause = Math.min(interval, deadline.remaining());
453
+ if (pause <= 0)
454
+ break;
455
+ await sleep(pause);
456
+ if (this.aborted && this.abortError)
457
+ throw this.abortError;
458
+ }
459
+ }
460
+ finally {
461
+ this.resumeActions();
462
+ }
463
+ const last = lastError !== undefined
464
+ ? `Last error: ${errorLine(lastError)}`
465
+ : hasValue
466
+ ? `Last value: ${formatValue(lastValue)}${accept === Boolean ? " (waiting for a truthy value)" : " (rejected by until)"}`
467
+ : "No read completed.";
468
+ throw new TimeoutError([
469
+ `t.waitFor timed out after ${deadline.elapsed()}ms (${attempts} ${attempts === 1 ? "read" : "reads"}).`,
470
+ last,
471
+ deadline.clampNote(),
472
+ `at ${displayLocation(location.file, location.line, location.column)}`,
473
+ this.stepPathLine(),
474
+ ].filter(Boolean).join("\n"));
475
+ });
476
+ }
477
+ skip(reason) {
478
+ // Cleanup runs after the verdict; a skip there cannot mean anything.
479
+ if (this.cleanupActive)
480
+ throw new Error("t.skip cannot be called during cleanup (t.defer or afterEach)");
481
+ const text = typeof reason === "string" && reason.trim().length > 0 ? reason : "skipped at runtime";
482
+ this.skipReason ?? (this.skipReason = text);
483
+ throw new SkipSignal(text);
484
+ }
178
485
  async attach(name, data) {
179
486
  await this.guard(async () => {
180
487
  await this.attachRaw(name, data);
181
488
  });
182
489
  }
183
- async attachRaw(name, data) {
490
+ async attachRaw(name, input, purpose) {
491
+ // Declared secrets are masked in the data itself, before it is encoded
492
+ // or previewed, so neither the file nor the report carries them.
493
+ const data = this.redactor ? this.redactor.attachment(input) : input;
184
494
  const payload = encodeAttachPayload(data);
185
495
  if (typeof data === "object" && data && "base64" in data && !("mimeType" in data)) {
186
496
  if (name.endsWith(".png"))
@@ -196,18 +506,26 @@ export class LiveFixture {
196
506
  else if (isPreviewable(payload.mimeType)) {
197
507
  rememberInline(this.specId, name, { text: previewText(data, payload) });
198
508
  }
199
- const ref = { name, path, mimeType: payload.mimeType };
200
509
  const current = this.stepStack[this.stepStack.length - 1];
510
+ const ref = { name, path, mimeType: payload.mimeType, ...this.traceMeta(),
511
+ ...(current ? { step: current.path } : {}), ...(purpose ? { purpose } : {}) };
201
512
  (current ? current.attachments : this.attachments).push(ref);
202
513
  return ref;
203
514
  }
204
- abort(reason) {
515
+ /**
516
+ * Stop the body: its fixture calls reject with `reason` from now on. A
517
+ * timeout marks the actions still open as timed out; a failure (an
518
+ * unanswered dialog) as failed.
519
+ */
520
+ abort(reason, as = "timeout") {
205
521
  this.aborted = true;
206
522
  this.abortError = reason;
207
- this.failurePhase = "timeout";
523
+ this.failurePhase = as === "timeout" ? "timeout" : "body";
524
+ this.finishOpenSteps(reason, as);
208
525
  for (const record of this.openActions) {
209
- record.status = "timeout";
526
+ record.status = as;
210
527
  record.error = toReportError(reason, record.path);
528
+ this.noteFailedAction(record.name, record.detail, reason);
211
529
  }
212
530
  this.openActions.clear();
213
531
  }
@@ -223,17 +541,108 @@ export class LiveFixture {
223
541
  * trace of what it did. Retry loops silence themselves: a five-second poll
224
542
  * would otherwise bury the report in a hundred identical rows.
225
543
  */
226
- async act(name, detail, op) {
544
+ act(name, detail, op) {
545
+ if (PAGE_ACTION.test(name))
546
+ this.usedPage = true;
547
+ return this.track(name, detail, () => this.recordAct(name, detail, async () => {
548
+ try {
549
+ return await op();
550
+ }
551
+ catch (error) {
552
+ const code = error && typeof error === "object" ? error.code : undefined;
553
+ if (code === "E_EVAL_TIMEOUT" && (name === "logic.eval" || name === "page.data" || name === "view.eval")) {
554
+ this.remoteTimeoutKind ?? (this.remoteTimeoutKind = name === "view.eval" ? "View eval" : "Logic eval");
555
+ }
556
+ else if (code === "E_AUTOMATION_TIMEOUT" && name === "page.action") {
557
+ this.remoteTimeoutKind ?? (this.remoteTimeoutKind = "page action");
558
+ }
559
+ throw asFixtureTimeout(error);
560
+ }
561
+ }));
562
+ }
563
+ /**
564
+ * Run one fixture call while it counts as in flight, so a body that
565
+ * returns without awaiting it can be named (with where it was started),
566
+ * and a timed-out body can say what it still awaits.
567
+ */
568
+ track(name, detail, op, label) {
569
+ const call = { name, detail, label, started: Date.now(), origin: new Error() };
570
+ this.inFlight.add(call);
571
+ const promise = (async () => {
572
+ try {
573
+ return await op();
574
+ }
575
+ finally {
576
+ this.inFlight.delete(call);
577
+ }
578
+ })();
579
+ call.promise = promise;
580
+ return promise;
581
+ }
582
+ /** A read `t.waitFor` gave up on is still running in the app. */
583
+ detachedInFlight() {
584
+ return [...this.inFlight].some((entry) => entry.detached);
585
+ }
586
+ /**
587
+ * The fixture calls still running, as a body that returned without
588
+ * awaiting them left them: what each is and where the spec started it.
589
+ */
590
+ unsettledCalls() {
591
+ const now = Date.now();
592
+ return [...this.inFlight].filter((entry) => !entry.detached).map((entry) => {
593
+ const frame = resolveOrigin(parseFrames(entry.origin.stack));
594
+ return {
595
+ call: entry.label ?? (entry.detail ? `${entry.name} ${entry.detail}` : entry.name),
596
+ at: displayLocation(frame.file, frame.line, frame.column),
597
+ ageMs: now - entry.started,
598
+ };
599
+ });
600
+ }
601
+ /**
602
+ * Stop the calls a returned body left running: each fails at its next
603
+ * fixture guard or retry, and its rejection is marked handled (nobody
604
+ * awaits it). Resolves `true` once they settled, `false` if one is still
605
+ * waiting on the app after `ms`. The fixture is usable again afterwards,
606
+ * for cleanup.
607
+ */
608
+ async stopUnsettled(reason, ms) {
609
+ for (const entry of this.inFlight)
610
+ entry.promise?.catch(() => { });
611
+ this.aborted = true;
612
+ this.abortError = reason;
613
+ const deadline = Date.now() + ms;
614
+ while (this.inFlight.size > 0 && Date.now() < deadline)
615
+ await sleep(10);
616
+ this.aborted = false;
617
+ this.abortError = null;
618
+ return this.inFlight.size === 0;
619
+ }
620
+ /**
621
+ * Fixture calls that have not returned, e.g. a hung `t.app.logic.eval` a
622
+ * timed-out body still awaits. Unlike the trace rows, this includes calls
623
+ * made from silenced retry loops.
624
+ */
625
+ pendingCalls() {
626
+ return [...this.inFlight].map((call) => ({
627
+ kind: /^(?:logic|view)\.eval$|^page\.data$/.test(call.name) ? "eval" : "action",
628
+ detail: call.detail ? `${call.name} ${call.detail}` : call.name,
629
+ owner: this.specId,
630
+ at_ms: call.started - this.startedAt,
631
+ }));
632
+ }
633
+ async recordAct(name, detail, op) {
227
634
  if (this.actionSilence > 0)
228
635
  return this.guard(op);
229
636
  // Past the cap, keep recording failures: the action that finally breaks is
230
637
  // the one row worth having, and dropping it leaves nothing pointing at it.
231
638
  if (this.actionCount >= MAX_ACTIONS) {
639
+ this.traceTruncated = true;
232
640
  try {
233
641
  return await this.guard(op);
234
642
  }
235
643
  catch (error) {
236
644
  this.recordFailedAction(name, detail, error, 0);
645
+ this.noteFailedAction(name, detail, error);
237
646
  throw error;
238
647
  }
239
648
  }
@@ -245,7 +654,8 @@ export class LiveFixture {
245
654
  if (previous?.kind === "action" &&
246
655
  previous.status === "passed" &&
247
656
  previous.name === name &&
248
- previous.detail === detail) {
657
+ previous.detail === detail &&
658
+ previous.sequence === this.eventSequence) {
249
659
  const started = Date.now();
250
660
  try {
251
661
  const result = await this.guard(op);
@@ -257,6 +667,7 @@ export class LiveFixture {
257
667
  // A failure is its own row: it is the one attempt worth reading.
258
668
  const failed = {
259
669
  name,
670
+ ...this.traceMeta(),
260
671
  detail,
261
672
  kind: "action",
262
673
  path: previous.path,
@@ -268,12 +679,14 @@ export class LiveFixture {
268
679
  error: toReportError(error, previous.path),
269
680
  };
270
681
  siblings.push(failed);
682
+ this.noteFailedAction(name, detail, error);
271
683
  throw error;
272
684
  }
273
685
  }
274
686
  this.actionCount += 1;
275
687
  const record = {
276
688
  name,
689
+ ...this.traceMeta(),
277
690
  detail,
278
691
  kind: "action",
279
692
  path: [...this.stepStack.map((step) => step.name), name].join(" > "),
@@ -289,7 +702,7 @@ export class LiveFixture {
289
702
  // body, so a record left at its optimistic default would serialise as an
290
703
  // instant success — the hung call rendered as the fastest one in the trace.
291
704
  this.openActions.add(record);
292
- await this.host.emit({ type: "step_started", name, path: record.path });
705
+ await this.emitTrace({ type: "step_started", name, path: record.path });
293
706
  try {
294
707
  const result = await this.guard(op);
295
708
  record.duration_ms = Date.now() - started;
@@ -301,18 +714,64 @@ export class LiveFixture {
301
714
  record.status = error instanceof TimeoutError ? "timeout" : "failed";
302
715
  record.error = toReportError(error, record.path);
303
716
  this.openActions.delete(record);
717
+ this.noteFailedAction(name, detail, error);
304
718
  throw error;
305
719
  }
306
720
  finally {
307
- await this.host.emit({ type: "step_finished", name, path: record.path,
721
+ await this.emitTrace({ type: "step_finished", name, path: record.path,
308
722
  status: record.status, duration_ms: record.duration_ms, error: record.error });
309
723
  }
310
724
  }
725
+ /**
726
+ * A trace event is progress for lxdev, not part of the spec: one the
727
+ * transport drops is sent once more and then given up, and never fails the
728
+ * action it describes. The report is built from the fixture's own records.
729
+ */
730
+ async emitTrace(event) {
731
+ for (let attempt = 0; attempt < 2; attempt += 1) {
732
+ try {
733
+ await this.host.emit(event);
734
+ return;
735
+ }
736
+ catch {
737
+ // Next attempt, then give up.
738
+ }
739
+ }
740
+ }
741
+ /**
742
+ * A note beside the trace. A warning unless `level` is `"info"`: expected
743
+ * housekeeping, such as the timers the spec's own clock dropped, which
744
+ * lxdev shows only with `--verbose`.
745
+ */
746
+ async diagnostic(phase, message, level) {
747
+ await this.emitTrace({ type: "diagnostic", phase, message, ...(level ? { level } : {}) });
748
+ }
749
+ /**
750
+ * An idempotent driver read, retried when the transport between the test
751
+ * runtime and the app dropped it. Never used for input or other calls with
752
+ * side effects: those may already have landed.
753
+ */
754
+ async readRetrying(read) {
755
+ for (let attempt = 0;; attempt += 1) {
756
+ try {
757
+ return await read();
758
+ }
759
+ catch (error) {
760
+ if (attempt >= TRANSPORT_RETRIES || !isTransientTransportError(error) || this.aborted)
761
+ throw error;
762
+ await sleep(TRANSPORT_RETRY_DELAY_MS * (attempt + 1));
763
+ }
764
+ }
765
+ }
766
+ noteFailedAction(name, detail, error) {
767
+ this.failedAction = { action: detail ? `${name} ${detail}` : name, error };
768
+ }
311
769
  recordFailedAction(name, detail, error, duration) {
312
770
  const parent = this.stepStack[this.stepStack.length - 1];
313
771
  const path = [...this.stepStack.map((step) => step.name), name].join(" > ");
314
772
  (parent ? parent.steps : this.steps).push({
315
773
  name,
774
+ ...this.traceMeta(),
316
775
  detail,
317
776
  kind: "action",
318
777
  path,
@@ -336,6 +795,18 @@ export class LiveFixture {
336
795
  this.assertRunnable();
337
796
  return result;
338
797
  }
798
+ /**
799
+ * Milliseconds an action or assertion may still take. Short of the spec's
800
+ * own deadline by a margin, so the action fails first and names its step
801
+ * instead of losing the race to the anonymous spec timeout. During cleanup
802
+ * the cleanup budget bounds it instead.
803
+ */
804
+ budgetRoom() {
805
+ if (this.cleanupActive)
806
+ return this.cleanupUntil > 0 ? this.cleanupUntil - Date.now() : Number.POSITIVE_INFINITY;
807
+ const margin = Math.min(250, Math.floor(this.specBudgetMs / 20));
808
+ return this.specDeadline - margin - Date.now();
809
+ }
339
810
  currentStepPath() {
340
811
  const current = this.stepStack[this.stepStack.length - 1];
341
812
  return current?.path ?? this.lastStepPath;
@@ -343,12 +814,48 @@ export class LiveFixture {
343
814
  noteAssertion(entry) {
344
815
  const record = {
345
816
  ...entry,
817
+ ...this.traceMeta(),
346
818
  step: this.currentStepPath(),
347
819
  };
348
820
  const current = this.stepStack[this.stepStack.length - 1];
349
821
  (current ? current.assertions : this.assertions).push(record);
350
822
  }
351
- close() { this.closed = true; }
823
+ close() {
824
+ this.closed = true;
825
+ this.finishOpenSteps(new Error("step was still running when the spec ended"), "failed");
826
+ }
827
+ finishOpenSteps(reason, status) {
828
+ for (const record of this.stepStack) {
829
+ if (record.status !== "passed")
830
+ continue;
831
+ record.status = status;
832
+ record.duration_ms = Date.now() - (this.stepStarted.get(record) ?? Date.now());
833
+ record.error = toReportError(reason, record.path);
834
+ }
835
+ }
836
+ /**
837
+ * Remove what the framework installed for this spec — its routes, mock
838
+ * scenario and test clocks — apart from the spec's own cleanup, and
839
+ * whether or not its body settled. Resolves one message per failure; a
840
+ * resource already gone is removed.
841
+ */
842
+ async reclaim() {
843
+ const failures = [];
844
+ const tasks = [
845
+ () => this.networkScope.reclaim(),
846
+ () => this.scenarioScope.reclaim(),
847
+ () => this.clockScope.reclaim((phase, message, level) => this.diagnostic(phase, message, level)),
848
+ ];
849
+ for (const task of tasks) {
850
+ try {
851
+ await task();
852
+ }
853
+ catch (error) {
854
+ failures.push(String(error?.message ?? error));
855
+ }
856
+ }
857
+ return failures;
858
+ }
352
859
  assertRunnable() {
353
860
  if (this.closed)
354
861
  throw new Error("Test fixture is closed");
@@ -365,152 +872,407 @@ export class LiveFixture {
365
872
  const path = this.currentStepPath();
366
873
  return path ? `in step ${JSON.stringify(path)}` : "";
367
874
  }
368
- wrapApp(driver) {
369
- const page = this.wrapPage(driver.page);
875
+ wrapApp(ref) {
876
+ const fixture = this;
877
+ const driver = () => ref.driver;
878
+ const input = lazyDriver(() => ({ owner: driver().page, value: driver().page }), fixture, "window.");
370
879
  return {
371
- page,
372
- nav: guardObject(driver.nav, this, "nav."),
373
- info: () => this.act("app.info", "", () => driver.info()),
374
- pages: () => this.act("app.pages", "", () => driver.pages()),
375
- surfaceLayout: () => this.act("app.surfaceLayout", "", () => driver.surfaceLayout()),
376
- eval: (options) => this.act("app.eval", summarise(options), async () => {
377
- // Ask the runtime which `lx.*` the script reached, and hand the
378
- // caller only the value — the observation is the report's business,
379
- // not the spec author's, and specs must not have to opt in for their
380
- // coverage to be measured.
381
- const result = (await driver.eval({
382
- ...this.withEvalBudget(options),
383
- captureCalls: true,
384
- }));
385
- // The marker, not the shape, identifies the envelope: a script that
386
- // returns undefined loses its `value` key on the wire, and sniffing
387
- // for that key handed the envelope itself back as the result.
388
- if (result && typeof result === "object" && result.__lxEval === 1) {
389
- const { calls, value } = result;
390
- if (Array.isArray(calls)) {
391
- for (const call of calls) {
392
- if (typeof call === "string")
393
- this.observed.add(call);
394
- }
880
+ page: (selector, options) => this.bindPage(driver, selector, options),
881
+ view: this.wrapView(() => driver().page, undefined),
882
+ window: {
883
+ get pointer() { return input.pointer; },
884
+ get key() { return input.key; },
885
+ },
886
+ logic: this.wrapLogic(driver),
887
+ nav: this.wrapNav(() => driver().nav),
888
+ // Lazy: the driver is read inside each traced call.
889
+ get network() {
890
+ return wrapNetwork(() => driver().network, fixture, fixture.networkScope);
891
+ },
892
+ get profile() {
893
+ return fixture.profileFixture(ref);
894
+ },
895
+ // Lazy and non-throwing like `network`; spec-scoped: uninstalled when
896
+ // the spec ends.
897
+ get clock() {
898
+ return wrapClock(() => ({ driver: driver().clock, appid: async () => (await driver().info()).appId }), fixture, fixture.clockScope, () => fixture.hostAutomation);
899
+ },
900
+ // Lazy and non-throwing like `clock`; the runner watches the app under
901
+ // test for each spec.
902
+ get dialogs() {
903
+ return wrapDialogs(() => driver().dialogs, fixture, () => fixture.ensureDialogWatch(ref));
904
+ },
905
+ info: () => this.act("app.info", "", () => this.readRetrying(() => driver().info())),
906
+ pages: () => this.act("app.pages", "", () => this.readRetrying(() => driver().pages())),
907
+ surfaceLayout: () => this.act("app.surfaceLayout", "", () => this.readRetrying(() => driver().surfaceLayout())),
908
+ };
909
+ }
910
+ /**
911
+ * Fixture navigation takes the fixture's option names and waits for the
912
+ * landed page's `onReady` unless the caller picks `waitUntil: 'commit'`;
913
+ * reads retry a dropped transport.
914
+ */
915
+ wrapNav(nav) {
916
+ const land = (verb) => (options) => this.act(`nav.${verb}`, summarise(options), () => nav()[verb](this.navOptions(options, `t.app.nav.${verb}`)));
917
+ const read = (verb, op, detail) => this.act(`nav.${verb}`, summarise(detail), () => this.readRetrying(op));
918
+ return {
919
+ to: land("to"),
920
+ redirect: land("redirect"),
921
+ switchTab: land("switchTab"),
922
+ relaunch: land("relaunch"),
923
+ back: (options) => this.act("nav.back", summarise(options), () => nav().back(this.navOptions(options ?? {}, "t.app.nav.back"))),
924
+ current: () => read("current", () => nav().current()),
925
+ info: (options) => read("info", () => nav().info(options), options),
926
+ stack: () => read("stack", () => nav().stack()),
927
+ };
928
+ }
929
+ bindPage(driver, selector, options) {
930
+ if (selector !== undefined) {
931
+ const keys = [selector?.name, selector?.instanceId].filter(value => value !== undefined);
932
+ if (keys.length !== 1 || typeof keys[0] !== "string" || keys[0].trim() === "") {
933
+ return Promise.reject(new TypeError("t.app.page(selector): select exactly one non-empty name or instanceId"));
934
+ }
935
+ }
936
+ const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
937
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
938
+ return Promise.reject(new TypeError("t.app.page(selector, { timeout }) takes a positive number of ms"));
939
+ }
940
+ const wanted = selector === undefined ? "the current page" : selector.name !== undefined
941
+ ? `page ${JSON.stringify(selector.name)}` : `page instance #${selector.instanceId}`;
942
+ return this.act("page.bind", summarise(selector), async () => {
943
+ // Capture this app driver: reopening the app must not retarget the handle.
944
+ const app = driver();
945
+ const deadline = new ActionDeadline(timeout, this.budgetRoom());
946
+ let info;
947
+ let last = "not open";
948
+ for (;;) {
949
+ try {
950
+ const found = await deadline.call("t.app.page binding read", () => selector
951
+ ? app.nav.info({ page: selector.instanceId ?? selector.name })
952
+ : app.nav.current(), () => `waiting for ${wanted}`);
953
+ if (deadline.expired())
954
+ break;
955
+ if (found?.instanceId) {
956
+ info = found;
957
+ break;
395
958
  }
396
- return value;
959
+ last = "no live instance";
397
960
  }
398
- // An older runtime ignores `captureCalls` and returns the bare value.
399
- return result;
400
- }),
961
+ catch (error) {
962
+ if (error instanceof TimeoutError && deadline.expired())
963
+ break;
964
+ // Not open yet is "not yet"; an unknown page name is an answer.
965
+ if (!matchesErrorCode(error, "E_PAGE_NOT_ACTIVE") && !isTransientTransportError(error))
966
+ throw error;
967
+ last = error instanceof Error ? error.message : String(error);
968
+ }
969
+ if (deadline.expired()) {
970
+ throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms waiting for ${wanted} to open: ${last}.` +
971
+ (deadline.clampNote() ? `\n${deadline.clampNote()}` : ""));
972
+ }
973
+ await sleep(Math.min(DEFAULT_POLL_INTERVAL_MS, Math.max(1, deadline.remaining())));
974
+ }
975
+ if (!info) {
976
+ throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms waiting for ${wanted} to open: ${last}.` +
977
+ (deadline.clampNote() ? `\n${deadline.clampNote()}` : ""));
978
+ }
979
+ const instanceId = info.instanceId;
980
+ if (selector?.name !== undefined) {
981
+ const stack = await deadline.call("t.app.page stack read", () => app.nav.stack(), () => `checking whether ${wanted} is ambiguous`);
982
+ if (deadline.expired()) {
983
+ throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms checking ${wanted}.`);
984
+ }
985
+ const matches = stack.filter(page => page.path === info.path && page.instanceId);
986
+ if (matches.length > 1) {
987
+ throw new Error(`t.app.page: ${wanted} has ${matches.length} live instances ` +
988
+ `(${matches.map(page => `#${page.instanceId}`).join(", ")}); select one by instanceId`);
989
+ }
990
+ }
991
+ this.boundPages.add(instanceId);
992
+ const view = this.wrapView(() => app.page, instanceId);
993
+ const invoke = (name, args, timeout) => {
994
+ if (typeof name !== "string" || name.trim() === "") {
995
+ return Promise.reject(new TypeError("page.invoke needs a non-empty action name"));
996
+ }
997
+ if (args.length > 1) {
998
+ return Promise.reject(new TypeError(`page.actions.${name} takes at most one JSON payload`));
999
+ }
1000
+ if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
1001
+ return Promise.reject(new TypeError("page.invoke({ timeout }) takes a positive number of ms"));
1002
+ }
1003
+ checkJsonArgs(args, `page.actions.${name}`);
1004
+ // A timeout stops waiting, but cannot undo a side effect already
1005
+ // performed by the action. Never retry dispatch automatically.
1006
+ return this.act("page.action", `${name} #${instanceId}`, () => {
1007
+ const budget = Math.floor(Math.min(timeout ?? Number.POSITIVE_INFINITY, this.budgetRoom()));
1008
+ if (budget < 1) {
1009
+ throw new TimeoutError(`page.action ${name} #${instanceId} was not dispatched: its action budget expired`);
1010
+ }
1011
+ return app.page.action({
1012
+ page: instanceId, name, ...(args.length > 0 ? { payload: args[0] } : {}),
1013
+ timeoutMs: budget,
1014
+ });
1015
+ });
1016
+ };
1017
+ const actions = new Proxy(Object.create(null), {
1018
+ get: (_, name) => {
1019
+ if (typeof name !== "string" || name === "then")
1020
+ return undefined;
1021
+ return (...args) => invoke(name, args);
1022
+ },
1023
+ });
1024
+ return {
1025
+ instanceId,
1026
+ name: info.name ?? info.path,
1027
+ view,
1028
+ actions,
1029
+ invoke: (name, options) => {
1030
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
1031
+ return Promise.reject(new TypeError("page.invoke(name, { payload?, timeout? }) needs an options object"));
1032
+ }
1033
+ return invoke(name, Object.prototype.hasOwnProperty.call(options, "payload") ? [options.payload] : [], options.timeout);
1034
+ },
1035
+ data: () => this.act("page.data", `#${instanceId}`, () => remote("page.data", "logic", () => this.evalLogic(app, {
1036
+ script: logicScript(readPageData, [instanceId], "page.data", "snapshot"),
1037
+ }))),
1038
+ };
1039
+ });
1040
+ }
1041
+ /** The driver's options for a fixture nav action. */
1042
+ navOptions(options, api) {
1043
+ if (!options || typeof options !== "object")
1044
+ throw new TypeError(`${api} takes an options object`);
1045
+ if ("timeoutMs" in options)
1046
+ throw new TypeError(`${api} takes { timeout } in ms`);
1047
+ const { timeout, waitUntil = "ready", ...rest } = options;
1048
+ if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
1049
+ throw new TypeError(`${api}({ timeout }) takes a positive number of ms`);
1050
+ }
1051
+ if (waitUntil !== "ready")
1052
+ return { ...rest, waitUntil };
1053
+ const room = Math.max(1, Math.floor(this.budgetRoom()));
1054
+ return { ...rest, waitUntil, timeoutMs: Math.min(timeout ?? NAV_READY_TIMEOUT_MS, room) };
1055
+ }
1056
+ wrapLogic(driver) {
1057
+ return {
1058
+ eval: (...input) => {
1059
+ const { options, fn, args } = evalInput(input);
1060
+ if (typeof fn !== "function") {
1061
+ throw new TypeError("t.app.logic.eval(fn, ...args) takes a function; a script string is for the raw driver (rawAutomation().lxapp().eval({ script }) from @lingxia/test/runner)");
1062
+ }
1063
+ const script = logicScript(fn, args, "t.app.logic.eval");
1064
+ const timeoutMs = this.evalTimeout(options, "t.app.logic.eval");
1065
+ const detail = `${summarise(functionDetail(fn))}${args.length ? ` args=${summarise(args)}` : ""}`;
1066
+ return this.act("logic.eval", detail, () => remote("t.app.logic.eval", "logic", () => this.evalLogic(driver(), { script, timeoutMs })));
1067
+ },
401
1068
  };
402
1069
  }
1070
+ /**
1071
+ * Logic eval that asks the runtime which `lx.*` the script reached and
1072
+ * hands the caller only the value — the observation is the report's
1073
+ * business, not the spec author's, and specs must not have to opt in for
1074
+ * their coverage to be measured.
1075
+ */
1076
+ async evalLogic(driver, options) {
1077
+ const result = (await driver.eval({
1078
+ ...this.withEvalBudget(options, "Logic eval"),
1079
+ captureCalls: true,
1080
+ }));
1081
+ // The marker, not the shape, identifies the envelope: a script that
1082
+ // returns undefined loses its `value` key on the wire, and sniffing
1083
+ // for that key handed the envelope itself back as the result.
1084
+ if (result && typeof result === "object" && result.__lxEval === 1) {
1085
+ const { calls, value } = result;
1086
+ if (Array.isArray(calls)) {
1087
+ for (const call of calls) {
1088
+ if (typeof call === "string")
1089
+ this.observed.add(call);
1090
+ }
1091
+ }
1092
+ return value;
1093
+ }
1094
+ throw new Error("t.app.logic.eval: the host answered without the call-capture envelope; " +
1095
+ "it is older than this @lingxia/test — run `lingxia upgrade` and restart the session (`lingxia dev`)");
1096
+ }
403
1097
  /**
404
1098
  * The driver's own eval default is a flat few seconds, so a call that runs
405
1099
  * long under load fails a spec that still had most of its budget left. An
406
- * eval gets a share of the spec's budget instead — a third, capped — never
407
- * all of it: a call allowed to run the full budget leaves the spec no room
408
- * to retry, so one stalled call takes the whole spec down with it.
1100
+ * eval gets `MAX_EVAL_BUDGET_MS` instead, clamped to what the spec has left.
409
1101
  */
410
- withEvalBudget(options) {
411
- if (options && typeof options === "object" && options.timeoutMs === undefined) {
412
- const share = Math.floor(this.specBudgetMs / 3);
413
- return { ...options, timeoutMs: Math.max(1, Math.min(share, MAX_EVAL_BUDGET_MS)) };
1102
+ withEvalBudget(options, name) {
1103
+ const budget = Math.floor(Math.min(options.timeoutMs ?? MAX_EVAL_BUDGET_MS, this.budgetRoom()));
1104
+ if (budget < 1)
1105
+ throw new TimeoutError(`${name} was not dispatched: its action budget expired`);
1106
+ return { ...options, timeoutMs: budget };
1107
+ }
1108
+ /**
1109
+ * An eval's own `timeout`, clamped to the spec's remaining budget; without
1110
+ * one, `withEvalBudget` picks the default.
1111
+ */
1112
+ evalTimeout(options, api) {
1113
+ const timeout = options?.timeout;
1114
+ if (timeout === undefined)
1115
+ return undefined;
1116
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
1117
+ throw new TypeError(`${api}({ timeout }, fn, ...args) takes a positive number of ms`);
1118
+ }
1119
+ return Math.min(timeout, this.budgetRoom());
1120
+ }
1121
+ viewEval(page, input, api, bound) {
1122
+ const { options, fn, args } = evalInput(input);
1123
+ if (typeof fn !== "function") {
1124
+ throw new TypeError(`${api}(fn, ...args) takes a function; a script string is for the raw driver (rawAutomation().lxapp().page.eval({ script }) from @lingxia/test/runner)`);
1125
+ }
1126
+ if (options && "page" in options) {
1127
+ throw new TypeError(`${api}: page is not an eval option; bind the page with t.app.page({ name }) and use its view`);
414
1128
  }
415
- return options;
1129
+ const script = pageScript(fn, args, api);
1130
+ const timeoutMs = this.evalTimeout(options, api);
1131
+ const detail = `${summarise(functionDetail(fn))}${args.length ? ` args=${summarise(args)}` : ""}`;
1132
+ return this.act("view.eval", bound ? `#${bound} ${detail}` : detail, () => remote(api, "page", () => page().eval(this.withEvalBudget({
1133
+ script,
1134
+ ...(bound ? { page: bound } : {}),
1135
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1136
+ }, "View eval"))));
416
1137
  }
417
- wrapPage(page) {
1138
+ /** `bound` is the immutable instance id captured by app.page(). */
1139
+ wrapView(page, bound) {
418
1140
  const location = () => {
419
1141
  const frame = callerLocation();
420
1142
  return { source: frame.file, line: frame.line, column: frame.column };
421
1143
  };
422
- // Every override lives in the proxy's `get` trap. Assigning onto the proxy
423
- // would write straight through to the real driver — `page.eval` would then
424
- // call itself forever.
425
- const overrides = {
426
- testId: (id, options) => this.locator(page, testIdSelector(id), location(), options),
427
- css: (selector, options) => this.locator(page, selector, location(), options),
428
- eval: (options) => this.act("page.eval", summarise(options), () => page.eval(this.withEvalBudget(options))),
1144
+ // Resolved on each call, like the rest of a fixture app.
1145
+ const lazyPage = {
1146
+ query: (options) => page().query(options),
1147
+ click: (options) => page().click(options),
1148
+ fill: (options) => page().fill(options),
1149
+ press: (options) => page().press(options),
1150
+ type: (options) => page().type(options),
1151
+ eval: (options) => page().eval(options),
429
1152
  };
430
- const guarded = guardObject(page, this, "page.", Object.keys(overrides));
431
- return new Proxy(guarded, {
432
- get(target, prop, receiver) {
433
- if (typeof prop === "string" && prop in overrides)
434
- return overrides[prop];
435
- return Reflect.get(target, prop, receiver);
1153
+ const input = lazyDriver(() => ({ owner: page(), value: page() }), this, "page.");
1154
+ const target = bound === undefined ? {} : { page: bound };
1155
+ const noOptions = (api, options) => {
1156
+ if (options !== undefined) {
1157
+ throw new TypeError(`${api}() takes no options: bind another page with t.app.page({ name }) and use its view`);
1158
+ }
1159
+ };
1160
+ return {
1161
+ testId: (id, options) => {
1162
+ noOptions("view.testId", options);
1163
+ return this.locator(lazyPage, testIdSelector(id), location(), target);
436
1164
  },
437
- });
1165
+ css: (selector, options) => {
1166
+ noOptions("view.css", options);
1167
+ return this.locator(lazyPage, selector, location(), target);
1168
+ },
1169
+ eval: ((...args) => this.viewEval(page, args, bound ? "page.view.eval" : "t.app.view.eval", bound)),
1170
+ screenshot: (options) => {
1171
+ noOptions("view.screenshot", options);
1172
+ return this.act("page.screenshot", bound ? `#${bound}` : "", () => this.readRetrying(() => page().screenshot(bound ? target : undefined)));
1173
+ },
1174
+ scroll: (options) => {
1175
+ if (options && "page" in options) {
1176
+ throw new TypeError("view.scroll() takes no page option: bind another page with t.app.page({ name }) and use its view");
1177
+ }
1178
+ return input.scroll({ ...options, ...target });
1179
+ },
1180
+ };
438
1181
  }
439
1182
  locator(page, selector, location, options) {
440
- return new PageLocator(page, (fn) => this.guard(fn), (verb, detail, op) => this.act(verb, detail, op), selector, location, options);
1183
+ this.usedPage = true;
1184
+ return new PageLocator(page, (fn) => this.guard(fn), (verb, detail, op) => this.act(verb, detail, op), selector, location, options, () => this.budgetRoom());
441
1185
  }
442
- locatorMatchers(locator, inverted) {
1186
+ locatorMatchers(locator, inverted, message) {
1187
+ const withMessage = (options) => message === undefined || options?.message !== undefined ? options : { ...options, message };
443
1188
  const self = {
444
- toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, options, inverted ? "not visible" : "visible"),
445
- toBeHidden: (options) => this.retryLocator(locator, "toBeHidden", inverted, options, true),
446
- toBeAttached: (options) => this.retryLocator(locator, "toBeAttached", inverted, options, true),
447
- toBeEnabled: (options) => this.retryLocator(locator, "toBeEnabled", inverted, options, true),
448
- toBeDisabled: (options) => this.retryLocator(locator, "toBeDisabled", inverted, options, true),
449
- toBeEditable: (options) => this.retryLocator(locator, "toBeEditable", inverted, options, true),
450
- toHaveText: (expected, options) => this.retryLocator(locator, "toHaveText", inverted, options, expected),
451
- toHaveCount: (expected, options) => this.retryLocator(locator, "toHaveCount", inverted, options, expected),
452
- toHaveValue: (expected, options) => this.retryLocator(locator, "toHaveValue", inverted, options, expected),
1189
+ toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, withMessage(options), inverted ? "not visible" : "visible"),
1190
+ toBeInViewport: (options) => this.retryLocator(locator, "toBeInViewport", inverted, withMessage(options), inverted ? "not in viewport" : "in viewport"),
1191
+ toBeHidden: (options) => this.retryLocator(locator, "toBeHidden", inverted, withMessage(options), true),
1192
+ toBeAttached: (options) => this.retryLocator(locator, "toBeAttached", inverted, withMessage(options), true),
1193
+ toBeEnabled: (options) => this.retryLocator(locator, "toBeEnabled", inverted, withMessage(options), true),
1194
+ toBeDisabled: (options) => this.retryLocator(locator, "toBeDisabled", inverted, withMessage(options), true),
1195
+ toBeEditable: (options) => this.retryLocator(locator, "toBeEditable", inverted, withMessage(options), true),
1196
+ toHaveText: (expected, options) => this.retryLocator(locator, "toHaveText", inverted, withMessage(options), expected),
1197
+ toContainText: (expected, options) => this.retryLocator(locator, "toContainText", inverted, withMessage(options), expected),
1198
+ toHaveAttribute: (name, value, options) => {
1199
+ if (typeof name !== "string" || !name)
1200
+ throw new TypeError("toHaveAttribute needs an attribute name");
1201
+ return this.retryLocator(locator, "toHaveAttribute", inverted, withMessage(options), new AttributeExpectation(name, value));
1202
+ },
1203
+ toHaveCount: (expected, options) => this.retryLocator(locator, "toHaveCount", inverted, withMessage(options), expected),
1204
+ toHaveValue: (expected, options) => this.retryLocator(locator, "toHaveValue", inverted, withMessage(options), expected),
453
1205
  };
454
1206
  Object.defineProperty(self, "not", {
455
- get: () => this.locatorMatchers(locator, !inverted),
1207
+ get: () => this.locatorMatchers(locator, !inverted, message),
1208
+ configurable: true,
456
1209
  });
457
1210
  return self;
458
1211
  }
459
- pollMatchers(read, options, inverted) {
460
- const run = (matcher, expected) => this.retryPoll(read, matcher, inverted, options, expected);
1212
+ pollMatchers(read, options, inverted, api) {
1213
+ const run = (matcher, expected) => this.retryPoll(read, matcher, inverted, options, expected, api);
461
1214
  const fixture = this;
462
1215
  const self = {
463
1216
  toBe: (expected) => run("toBe", expected),
464
1217
  toEqual: (expected) => run("toEqual", expected),
465
1218
  toContain: (expected) => run("toContain", expected),
1219
+ toContainEqual: (expected) => run("toContainEqual", expected),
466
1220
  toMatch: (expected) => run("toMatch", expected),
467
1221
  toBeTruthy: () => run("toBeTruthy"),
468
1222
  toBeFalsy: () => run("toBeFalsy"),
469
1223
  toBeDefined: () => run("toBeDefined"),
470
1224
  toBeUndefined: () => run("toBeUndefined"),
471
1225
  toBeInstanceOf: (expected) => run("toBeInstanceOf", expected),
1226
+ toHaveLength: (expected) => run("toHaveLength", expected),
472
1227
  toBeGreaterThan: (expected) => run("toBeGreaterThan", expected),
473
1228
  toBeGreaterThanOrEqual: (expected) => run("toBeGreaterThanOrEqual", expected),
474
1229
  toBeLessThan: (expected) => run("toBeLessThan", expected),
475
1230
  toBeLessThanOrEqual: (expected) => run("toBeLessThanOrEqual", expected),
476
1231
  };
477
1232
  Object.defineProperty(self, "not", {
478
- get: () => fixture.pollMatchers(read, options, !inverted),
1233
+ get: () => fixture.pollMatchers(read, options, !inverted, api),
1234
+ configurable: true,
479
1235
  });
480
1236
  return self;
481
1237
  }
482
- async retryLocator(locator, matcher, inverted, options, expected) {
483
- await this.guard(async () => {
1238
+ retryLocator(locator, matcher, inverted, options, expected) {
1239
+ const target = locator instanceof PageLocator ? locator.describe() : locator.selector;
1240
+ const assertion = `${inverted ? "not." : ""}${matcher}`;
1241
+ return this.track("expect", `${target} ${assertion}`, () => this.guard(async () => {
484
1242
  const frame = callerLocation();
485
1243
  const location = { source: frame.file, line: frame.line, column: frame.column };
486
- const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1244
+ const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
487
1245
  const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
488
- const started = Date.now();
1246
+ const context = () => this.deadlineContext(inverted ? `not.${matcher}` : matcher, location);
489
1247
  let lastResolved;
490
1248
  let lastError;
491
1249
  pushAssertionSilence();
492
1250
  this.silenceActions();
493
1251
  try {
494
- while (Date.now() - started < timeout) {
1252
+ while (!deadline.expired()) {
495
1253
  try {
496
- lastResolved = await resolveLocator(locator);
1254
+ lastResolved = await resolveLocator(locator, deadline, context, expected instanceof AttributeExpectation ? [expected.name] : []);
497
1255
  matchLocator(locator, lastResolved, matcher, expected, inverted);
498
1256
  this.noteAssertion({
499
1257
  matcher: inverted ? `not.${matcher}` : matcher,
500
1258
  expected: formatValue(expected),
501
- actual: formatValue(locatorActual(matcher, lastResolved)),
1259
+ actual: formatValue(locatorActual(matcher, lastResolved, expected)),
502
1260
  passed: true,
1261
+ target,
1262
+ message: options?.message,
1263
+ location: displayLocation(location.source, location.line, location.column),
1264
+ duration_ms: deadline.elapsed(),
503
1265
  });
504
1266
  return;
505
1267
  }
506
1268
  catch (error) {
507
- if (error instanceof TimeoutError || this.aborted)
1269
+ if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
508
1270
  throw error;
509
1271
  lastError = error;
510
1272
  }
511
- if (Date.now() - started >= timeout)
1273
+ if (deadline.expired())
512
1274
  break;
513
- await sleep(interval);
1275
+ await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
514
1276
  if (this.aborted && this.abortError)
515
1277
  throw this.abortError;
516
1278
  }
@@ -519,50 +1281,61 @@ export class LiveFixture {
519
1281
  popAssertionSilence();
520
1282
  this.resumeActions();
521
1283
  }
522
- const duration = Date.now() - started;
1284
+ const duration = deadline.elapsed();
1285
+ const miss = lastResolved && locator instanceof PageLocator ? locator.missText(lastResolved) : undefined;
523
1286
  throw this.retryFailure({
1287
+ clampNote: deadline.clampNote(),
524
1288
  matcher: inverted ? `not.${matcher}` : matcher,
525
1289
  expected,
526
- actual: locatorActual(matcher, lastResolved),
1290
+ actual: locatorActual(matcher, lastResolved, expected),
527
1291
  duration,
528
1292
  location,
529
1293
  lastError,
530
- extra: lastResolved && locator instanceof PageLocator ? locator.missText(lastResolved) : undefined,
1294
+ extra: miss,
1295
+ locator: locator instanceof PageLocator ? locator.boundPage : null,
1296
+ target,
1297
+ message: options?.message,
531
1298
  });
532
- });
1299
+ }), `expect(${target}).${assertion}`);
533
1300
  }
534
- async retryPoll(read, matcher, inverted, options, expected) {
535
- await this.guard(async () => {
1301
+ retryPoll(read, matcher, inverted, options, expected, api) {
1302
+ const source = truncate(read.name || functionDetail(read), 60);
1303
+ const assertion = `${inverted ? "not." : ""}${matcher}`;
1304
+ return this.track(api, `${source} ${assertion}`, () => this.guard(async () => {
536
1305
  const frame = callerLocation();
537
1306
  const location = { source: frame.file, line: frame.line, column: frame.column };
538
- const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1307
+ const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
539
1308
  const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
540
- const started = Date.now();
1309
+ const context = () => this.deadlineContext(`${api} ${inverted ? "not." : ""}${matcher}`, location);
541
1310
  let lastActual;
542
1311
  let lastError;
543
1312
  pushAssertionSilence();
544
1313
  this.silenceActions();
545
1314
  try {
546
- while (Date.now() - started < timeout) {
1315
+ while (!deadline.expired()) {
547
1316
  try {
548
- lastActual = await read();
1317
+ lastActual = await deadline.call(`${api} read`, read, context);
549
1318
  applyMatcher(matcher, lastActual, expected, inverted);
550
1319
  this.noteAssertion({
551
1320
  matcher: inverted ? `not.${matcher}` : matcher,
552
1321
  expected: formatValue(expected),
553
1322
  actual: formatValue(lastActual),
554
1323
  passed: true,
1324
+ target: source,
1325
+ message: options?.message,
1326
+ location: displayLocation(location.source, location.line, location.column),
1327
+ duration_ms: deadline.elapsed(),
555
1328
  });
556
1329
  return;
557
1330
  }
558
1331
  catch (error) {
559
- if (error instanceof TimeoutError || this.aborted)
1332
+ if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
560
1333
  throw error;
561
1334
  lastError = error;
562
1335
  }
563
- if (Date.now() - started >= timeout)
1336
+ if (deadline.expired())
564
1337
  break;
565
- await sleep(interval);
1338
+ await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
566
1339
  if (this.aborted && this.abortError)
567
1340
  throw this.abortError;
568
1341
  }
@@ -572,14 +1345,24 @@ export class LiveFixture {
572
1345
  this.resumeActions();
573
1346
  }
574
1347
  throw this.retryFailure({
1348
+ clampNote: deadline.clampNote(),
575
1349
  matcher: inverted ? `not.${matcher}` : matcher,
576
1350
  expected,
577
1351
  actual: lastActual,
578
- duration: Date.now() - started,
1352
+ duration: deadline.elapsed(),
579
1353
  location,
580
1354
  lastError,
1355
+ target: source,
1356
+ message: options?.message,
581
1357
  });
582
- });
1358
+ }), `${api}(${source}).${assertion}`);
1359
+ }
1360
+ deadlineContext(matcher, location) {
1361
+ return [
1362
+ `while retrying ${matcher}`,
1363
+ `at ${displayLocation(location.source, location.line, location.column)}`,
1364
+ this.stepPathLine(),
1365
+ ].filter(Boolean).join("\n");
583
1366
  }
584
1367
  retryFailure(input) {
585
1368
  const where = displayLocation(input.location.source, input.location.line, input.location.column);
@@ -589,11 +1372,13 @@ export class LiveFixture {
589
1372
  ? input.lastError.message
590
1373
  : undefined;
591
1374
  const lines = [
1375
+ input.message,
592
1376
  `Timed out after ${input.duration}ms retrying ${input.matcher}.`,
593
1377
  input.extra,
594
1378
  `Expected: ${formatValue(input.expected)}`,
595
1379
  `Received: ${formatValue(input.actual)}`,
596
1380
  `Retried for ${input.duration}ms`,
1381
+ input.clampNote,
597
1382
  `at ${where}`,
598
1383
  this.stepPathLine(),
599
1384
  last && last !== `Expected: ${formatValue(input.expected)}` ? last : undefined,
@@ -603,18 +1388,119 @@ export class LiveFixture {
603
1388
  expected: formatValue(input.expected),
604
1389
  actual: formatValue(input.actual),
605
1390
  passed: false,
1391
+ difference: ["toBe", "toEqual"].includes(input.matcher)
1392
+ ? firstDifference(input.expected, input.actual) : undefined,
1393
+ target: input.target,
1394
+ message: input.message,
1395
+ location: where,
1396
+ duration_ms: input.duration,
606
1397
  });
607
- return new AssertionError(input.matcher, input.actual, input.expected, lines.join("\n"));
1398
+ const error = new AssertionError(input.matcher, input.actual, input.expected, lines.join("\n"), "E_TIMEOUT");
1399
+ if (input.locator !== undefined)
1400
+ this.locatorFailures.set(error, input.locator);
1401
+ return error;
1402
+ }
1403
+ }
1404
+ /** An inline scenario (or its selected variant) with `function` rules. */
1405
+ function hasFunctionRules(definition, variant) {
1406
+ if (!definition || typeof definition !== "object")
1407
+ return false;
1408
+ const record = definition;
1409
+ const rules = [
1410
+ ...(Array.isArray(record.rules) ? record.rules : []),
1411
+ ...(variant !== undefined && Array.isArray(record.variants?.[variant]?.rules) ? record.variants[variant].rules : []),
1412
+ ];
1413
+ return rules.some((rule) => !!rule && typeof rule === "object" && "function" in rule);
1414
+ }
1415
+ /** Extra attempts of an idempotent read the transport dropped, and the backoff step. */
1416
+ const TRANSPORT_RETRIES = 2;
1417
+ const TRANSPORT_RETRY_DELAY_MS = 100;
1418
+ /**
1419
+ * Host tiers a host may lack. The fixture resolves them per call, so reading
1420
+ * `t.automation.desktop` never throws.
1421
+ */
1422
+ const HOST_TIERS = ["browser", "desktop", "terminal"];
1423
+ /** Driver reads with no side effect, retried when the transport drops them. */
1424
+ const IDEMPOTENT_READS = new Set(["nav.current", "nav.info", "nav.stack", "page.query", "page.screenshot", "lxapps.list", "lxapps.current"]);
1425
+ function checkpointId(checkpoint, api) {
1426
+ const id = typeof checkpoint === "string" ? checkpoint : checkpoint?.id;
1427
+ if (typeof id !== "string" || id.length === 0) {
1428
+ throw new TypeError(`${api} needs the checkpoint t.app.profile.checkpoint() resolved (or its id)`);
608
1429
  }
1430
+ return id;
609
1431
  }
610
- function resolveLocator(locator) {
611
- if (locator instanceof PageLocator)
612
- return locator.resolve();
613
- throw new Error("t.expect() requires a locator from page.testId() or page.css()");
1432
+ /**
1433
+ * `t.waitFor` retries a read that says "not yet" by throwing, but not one
1434
+ * that is invalid locally or violates the eval JSON boundary. Remote DOM
1435
+ * TypeErrors can resolve as rendering catches up.
1436
+ */
1437
+ function isRetryableReadError(error) {
1438
+ if (error instanceof AssertionError || isRetryableRemoteError(error))
1439
+ return true;
1440
+ const name = error instanceof Error ? error.name : undefined;
1441
+ return name !== "TypeError" && name !== "ReferenceError" && name !== "SyntaxError";
614
1442
  }
615
- function locatorActual(matcher, resolved) {
1443
+ function errorLine(error) {
1444
+ return error instanceof Error ? `${error.name}: ${error.message}` : formatValue(error);
1445
+ }
1446
+ /**
1447
+ * `eval(fn, ...args)` or `eval(options, fn, ...args)`. An object with a
1448
+ * `script` key is a script string's options, not eval options: it stays the
1449
+ * "function" so the caller refuses it as such.
1450
+ */
1451
+ function evalInput(input) {
1452
+ const [first, ...rest] = input;
1453
+ if (first !== null && typeof first === "object" && !("script" in first)) {
1454
+ const [fn, ...args] = rest;
1455
+ return { options: first, fn, args };
1456
+ }
1457
+ return { options: undefined, fn: first, args: rest };
1458
+ }
1459
+ async function remote(api, target, op) {
1460
+ try {
1461
+ return await op();
1462
+ }
1463
+ catch (error) {
1464
+ throw explainRemoteError(error, api, target);
1465
+ }
1466
+ }
1467
+ // Sent to app Logic as source text: it must stay self-contained.
1468
+ function readPageData(scope, instanceId) {
1469
+ const page = scope.getPage(instanceId);
1470
+ if (!page)
1471
+ throw new Error(`page.data: page instance #${instanceId} is gone`);
1472
+ return page.data;
1473
+ }
1474
+ function resolveLocator(locator, deadline, context, attributes = []) {
1475
+ if (locator instanceof PageLocator) {
1476
+ return deadline.call("locator read", () => locator.resolve(undefined, "resolve", attributes), context);
1477
+ }
1478
+ throw new Error("expect(locator) takes a locator from view.testId() or view.css()");
1479
+ }
1480
+ /** `toHaveAttribute`'s expectation; formats as the report shows it. */
1481
+ class AttributeExpectation {
1482
+ constructor(name, value) {
1483
+ this.name = name;
1484
+ this.value = value;
1485
+ }
1486
+ toString() {
1487
+ return this.value === undefined ? `attribute ${this.name}` : `${this.name}=${formatValue(this.value)}`;
1488
+ }
1489
+ toJSON() {
1490
+ return this.toString();
1491
+ }
1492
+ }
1493
+ function locatorActual(matcher, resolved, expected) {
616
1494
  if (!resolved)
617
1495
  return undefined;
1496
+ if (matcher === "toBeInViewport") {
1497
+ return resolved.kind === "unique" ? (resolved.inViewport ? "in viewport" : "outside viewport") : resolved.kind;
1498
+ }
1499
+ if (matcher === "toContainText")
1500
+ return resolved.text;
1501
+ if (matcher === "toHaveAttribute") {
1502
+ return expected instanceof AttributeExpectation ? resolved.attributes?.[expected.name] ?? null : undefined;
1503
+ }
618
1504
  if (matcher === "toBeHidden")
619
1505
  return !resolved.visible;
620
1506
  if (matcher === "toBeAttached")
@@ -651,6 +1537,35 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
651
1537
  }
652
1538
  return;
653
1539
  }
1540
+ if (matcher === "toBeInViewport") {
1541
+ const pass = resolved.kind === "unique" && resolved.inViewport;
1542
+ if (pass === inverted) {
1543
+ const detail = locator instanceof PageLocator ? locator.missText(resolved) : `resolved to ${resolved.kind}`;
1544
+ const actual = String(locatorActual(matcher, resolved));
1545
+ throw new AssertionError(inverted ? "not.toBeInViewport" : "toBeInViewport", actual, inverted ? "not in viewport" : "in viewport", `${detail}\nExpected: ${inverted ? "not " : ""}in viewport\nReceived: ${formatValue(actual)}`);
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (matcher === "toContainText") {
1550
+ if (expected instanceof RegExp)
1551
+ applyMatcher("toMatch", resolved.text, expected, inverted);
1552
+ else
1553
+ applyMatcher("toContain", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
1554
+ return;
1555
+ }
1556
+ if (matcher === "toHaveAttribute" && expected instanceof AttributeExpectation) {
1557
+ if (resolved.count !== 1)
1558
+ throw new Error(`Expected one attached element, received ${resolved.count}`);
1559
+ const actual = resolved.attributes?.[expected.name] ?? null;
1560
+ const pass = actual !== null && (expected.value === undefined ? true
1561
+ : expected.value instanceof RegExp ? (expected.value.lastIndex = 0, expected.value.test(actual))
1562
+ : actual === expected.value);
1563
+ if (pass === inverted) {
1564
+ const name = inverted ? "not.toHaveAttribute" : "toHaveAttribute";
1565
+ throw new AssertionError(name, formatValue(actual), `${inverted ? "not " : ""}${expected}`, `Expected: ${inverted ? "not " : ""}${expected}\nReceived: ${actual === null ? `no ${expected.name} attribute` : `${expected.name}=${formatValue(actual)}`}`);
1566
+ }
1567
+ return;
1568
+ }
654
1569
  if (matcher === "toHaveCount") {
655
1570
  applyMatcher("toBe", resolved.count, expected, inverted);
656
1571
  return;
@@ -659,7 +1574,7 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
659
1574
  if (expected instanceof RegExp)
660
1575
  applyMatcher("toMatch", resolved.text, expected, inverted);
661
1576
  else
662
- applyMatcher("toBe", resolved.text, expected, inverted);
1577
+ applyMatcher("toBe", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
663
1578
  return;
664
1579
  }
665
1580
  if (matcher === "toHaveValue") {
@@ -669,6 +1584,10 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
669
1584
  applyMatcher("toBe", resolved.value, expected, inverted);
670
1585
  }
671
1586
  }
1587
+ /** The driver's own bound for `waitUntil: 'ready'`, the fixture's default too. */
1588
+ const NAV_READY_TIMEOUT_MS = 15000;
1589
+ /** Actions that reach a page of the app. */
1590
+ const PAGE_ACTION = /^(?:page|view|nav)\./;
672
1591
  function guardObject(target, fixture, path, skip = []) {
673
1592
  const cache = new Map();
674
1593
  return new Proxy(target, {
@@ -692,7 +1611,9 @@ function guardObject(target, fixture, path, skip = []) {
692
1611
  }
693
1612
  if (typeof value === "function" && !accessor) {
694
1613
  const name = `${path}${String(prop)}`;
695
- const bound = (...args) => fixture.act(name, summarise(args[0]), () => value.apply(obj, args));
1614
+ const bound = IDEMPOTENT_READS.has(name)
1615
+ ? (...args) => fixture.act(name, summarise(args[0]), () => fixture.readRetrying(() => value.apply(obj, args)))
1616
+ : (...args) => fixture.act(name, summarise(args[0]), () => value.apply(obj, args));
696
1617
  cache.set(prop, bound);
697
1618
  return bound;
698
1619
  }
@@ -705,6 +1626,43 @@ function guardObject(target, fixture, path, skip = []) {
705
1626
  },
706
1627
  });
707
1628
  }
1629
+ /**
1630
+ * A driver namespace resolved on each call instead of on read. `resolve`
1631
+ * yields the namespace's owner and value; a member read returns another lazy
1632
+ * namespace, and a call resolves the chain, then records the call as an
1633
+ * action. A host that lacks the tier rejects the call, never the read.
1634
+ */
1635
+ function lazyDriver(resolve, fixture, path) {
1636
+ const cache = new Map();
1637
+ const target = function lazy() { };
1638
+ return new Proxy(target, {
1639
+ get(_, prop) {
1640
+ // Not a thenable: `await t.automation.desktop` must not call `then`.
1641
+ if (prop === "then" || typeof prop === "symbol")
1642
+ return undefined;
1643
+ if (cache.has(prop))
1644
+ return cache.get(prop);
1645
+ const member = lazyDriver(() => {
1646
+ const { value } = resolve();
1647
+ if (!value || (typeof value !== "object" && typeof value !== "function")) {
1648
+ throw new Error(`${path.slice(0, -1)} is not available on this host`);
1649
+ }
1650
+ return { owner: value, value: Reflect.get(value, prop, value) };
1651
+ }, fixture, `${path}${String(prop)}.`);
1652
+ cache.set(prop, member);
1653
+ return member;
1654
+ },
1655
+ apply(_, __, args) {
1656
+ const name = path.slice(0, -1);
1657
+ return fixture.act(name, summarise(args[0]), () => {
1658
+ const { owner, value } = resolve();
1659
+ if (typeof value !== "function")
1660
+ throw new TypeError(`${name} is not a function on this host`);
1661
+ return value.apply(owner, args);
1662
+ });
1663
+ },
1664
+ });
1665
+ }
708
1666
  /** The one detail worth showing beside an action: what it acted on. */
709
1667
  function summarise(input) {
710
1668
  if (input === undefined || input === null)
@@ -748,40 +1706,52 @@ function safeJson(data) {
748
1706
  export function toReportError(error, step) {
749
1707
  if (error instanceof AssertionError) {
750
1708
  const stack = remapStack(error.stack);
1709
+ const cause = error;
751
1710
  return {
1711
+ code: typeof cause.code === "string" ? cause.code : undefined,
1712
+ data: jsonData(cause.data),
752
1713
  name: error.name,
753
1714
  message: error.message,
754
1715
  stack,
755
1716
  matcher: error.matcher,
756
1717
  expected: formatValue(error.expected),
757
1718
  actual: formatValue(error.actual),
758
- location: firstLocation(stack),
1719
+ difference: ["toBe", "toEqual"].includes(error.matcher)
1720
+ ? firstDifference(error.expected, error.actual) : undefined,
1721
+ location: firstLocation(stack, error),
759
1722
  step,
760
1723
  };
761
1724
  }
762
1725
  if (error instanceof Error) {
763
1726
  const stack = remapStack(error.stack);
764
1727
  const details = error;
765
- let data;
766
- try {
767
- if (details.data !== undefined)
768
- data = JSON.parse(JSON.stringify(details.data));
769
- }
770
- catch { /* Non-JSON diagnostic data must not break reporting. */ }
771
1728
  return {
772
1729
  code: typeof details.code === "string" ? details.code : undefined,
773
- data,
1730
+ data: jsonData(details.data),
774
1731
  name: error.name,
775
1732
  message: error.message,
776
1733
  stack,
777
- location: firstLocation(stack),
1734
+ location: firstLocation(stack, error),
778
1735
  step,
779
1736
  };
780
1737
  }
781
1738
  return { name: "Error", message: String(error), step };
782
1739
  }
783
- function firstLocation(stack) {
784
- const frames = parseFrames(stack);
1740
+ function jsonData(data) {
1741
+ try {
1742
+ return data === undefined ? undefined : JSON.parse(JSON.stringify(data));
1743
+ }
1744
+ catch {
1745
+ // Non-JSON diagnostic data must not break reporting.
1746
+ return undefined;
1747
+ }
1748
+ }
1749
+ function firstLocation(stack, error) {
1750
+ // V8 starts the stack with the message; a message line (a schema issue,
1751
+ // a received value) must not be read as a frame.
1752
+ const header = `${error.name}: ${error.message}`;
1753
+ const frameText = stack?.startsWith(header) ? stack.slice(header.length) : stack;
1754
+ const frames = parseFrames(frameText);
785
1755
  const frame = frames.length ? resolveOrigin(frames) : undefined;
786
1756
  return frame && !isFrameworkFrame(frame.file) ? `${frame.file}:${frame.line}:${frame.column}` : undefined;
787
1757
  }