@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/fixture.js CHANGED
@@ -1,22 +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 { callerLocation, displayLocation, parseFrames } from "./ids.js";
6
- import { PageLocator, sleep, testIdSelector, } from "./locator.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";
13
+ import { callerLocation, displayLocation, isFrameworkFrame, parseFrames, resolveOrigin } from "./ids.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
- this.automation = automation;
19
37
  this.specBudgetMs = specBudgetMs;
38
+ this.redactor = redactor;
20
39
  this.steps = [];
21
40
  /**
22
41
  * `lx.*` members this spec's evals actually reached. Collected so the report
@@ -26,54 +45,241 @@ export class LiveFixture {
26
45
  this.assertions = [];
27
46
  this.attachments = [];
28
47
  this.defers = [];
48
+ this.closed = false;
29
49
  this.aborted = false;
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
- this.apps = {
44
- lxapp: (appId) => this.wrapApp(this.automation.lxapp(appId)),
45
- };
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]);
99
+ this.automation = new Proxy(root, {
100
+ get: (target, prop) => prop === "lxapp"
101
+ ? (appId) => {
102
+ this.assertRunnable();
103
+ return this.appOf(appId === undefined ? this.pinned : this.refFor(appId));
104
+ }
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),
110
+ });
46
111
  }
47
112
  get app() {
48
- 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
+ } };
49
136
  }
50
137
  get raw() {
51
- 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
+ }
52
245
  }
53
246
  step(name, body) {
54
- 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
+ }
55
257
  const record = {
56
258
  name,
57
- path: [...this.stepStack.map((step) => step.name), name].join(" > "),
259
+ ...this.traceMeta(),
260
+ path: parent ? `${parent.path} > ${name}` : name,
58
261
  status: "passed",
59
262
  duration_ms: 0,
60
263
  steps: [],
61
264
  attachments: [],
62
265
  assertions: [],
63
266
  };
64
- const parent = this.stepStack[this.stepStack.length - 1];
65
267
  (parent ? parent.steps : this.steps).push(record);
66
268
  this.stepStack.push(record);
67
- await this.host.emit({
269
+ const started = Date.now();
270
+ this.stepStarted.set(record, started);
271
+ await this.emitTrace({
68
272
  type: "step_started",
69
273
  name,
70
274
  path: record.path,
71
275
  });
72
- const started = Date.now();
73
276
  try {
74
- const result = await body();
277
+ const scope = {
278
+ step: (childName, childBody) => this.runStep(childName, childBody, record),
279
+ };
280
+ const result = await body(scope);
75
281
  record.duration_ms = Date.now() - started;
76
- await this.host.emit({
282
+ await this.emitTrace({
77
283
  type: "step_finished",
78
284
  name,
79
285
  path: record.path,
@@ -84,10 +290,21 @@ export class LiveFixture {
84
290
  }
85
291
  catch (error) {
86
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
+ }
87
304
  record.status = error instanceof TimeoutError ? "timeout" : "failed";
88
305
  record.error = toReportError(error, record.path);
89
306
  this.lastStepPath = record.path;
90
- await this.host.emit({
307
+ await this.emitTrace({
91
308
  type: "step_finished",
92
309
  name,
93
310
  path: record.path,
@@ -98,14 +315,12 @@ export class LiveFixture {
98
315
  throw error;
99
316
  }
100
317
  finally {
101
- 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);
102
322
  }
103
- });
104
- }
105
- get expect() {
106
- const fn = ((locator) => this.locatorMatchers(locator, false));
107
- fn.poll = (read, options) => this.pollMatchers(read, options, false);
108
- return fn;
323
+ }));
109
324
  }
110
325
  async reject(operation, expected = {}) {
111
326
  return this.guard(async () => {
@@ -135,7 +350,7 @@ export class LiveFixture {
135
350
  ].filter(Boolean).join("\n"));
136
351
  }
137
352
  const record = received;
138
- if (expected.code !== undefined && record.code !== expected.code) {
353
+ if (expected.code !== undefined && !matchesErrorCode(received, expected.code)) {
139
354
  this.noteAssertion({
140
355
  matcher: "reject",
141
356
  expected: formatValue(expected.code),
@@ -151,10 +366,10 @@ export class LiveFixture {
151
366
  ].filter(Boolean).join("\n"));
152
367
  }
153
368
  if (typeof expected.message === "string") {
154
- immediateExpect(String(record.message)).toContain(expected.message);
369
+ check(String(record.message)).toContain(expected.message);
155
370
  }
156
371
  if (expected.message instanceof RegExp) {
157
- immediateExpect(String(record.message)).toMatch(expected.message);
372
+ check(String(record.message)).toMatch(expected.message);
158
373
  }
159
374
  this.noteAssertion({
160
375
  matcher: "reject",
@@ -168,12 +383,114 @@ export class LiveFixture {
168
383
  defer(cleanup) {
169
384
  this.defers.push(cleanup);
170
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
+ }
171
485
  async attach(name, data) {
172
486
  await this.guard(async () => {
173
487
  await this.attachRaw(name, data);
174
488
  });
175
489
  }
176
- 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;
177
494
  const payload = encodeAttachPayload(data);
178
495
  if (typeof data === "object" && data && "base64" in data && !("mimeType" in data)) {
179
496
  if (name.endsWith(".png"))
@@ -189,18 +506,26 @@ export class LiveFixture {
189
506
  else if (isPreviewable(payload.mimeType)) {
190
507
  rememberInline(this.specId, name, { text: previewText(data, payload) });
191
508
  }
192
- const ref = { name, path, mimeType: payload.mimeType };
193
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 } : {}) };
194
512
  (current ? current.attachments : this.attachments).push(ref);
195
513
  return ref;
196
514
  }
197
- 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") {
198
521
  this.aborted = true;
199
522
  this.abortError = reason;
200
- this.failurePhase = "timeout";
523
+ this.failurePhase = as === "timeout" ? "timeout" : "body";
524
+ this.finishOpenSteps(reason, as);
201
525
  for (const record of this.openActions) {
202
- record.status = "timeout";
526
+ record.status = as;
203
527
  record.error = toReportError(reason, record.path);
528
+ this.noteFailedAction(record.name, record.detail, reason);
204
529
  }
205
530
  this.openActions.clear();
206
531
  }
@@ -216,17 +541,108 @@ export class LiveFixture {
216
541
  * trace of what it did. Retry loops silence themselves: a five-second poll
217
542
  * would otherwise bury the report in a hundred identical rows.
218
543
  */
219
- 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) {
220
634
  if (this.actionSilence > 0)
221
635
  return this.guard(op);
222
636
  // Past the cap, keep recording failures: the action that finally breaks is
223
637
  // the one row worth having, and dropping it leaves nothing pointing at it.
224
638
  if (this.actionCount >= MAX_ACTIONS) {
639
+ this.traceTruncated = true;
225
640
  try {
226
641
  return await this.guard(op);
227
642
  }
228
643
  catch (error) {
229
644
  this.recordFailedAction(name, detail, error, 0);
645
+ this.noteFailedAction(name, detail, error);
230
646
  throw error;
231
647
  }
232
648
  }
@@ -238,7 +654,8 @@ export class LiveFixture {
238
654
  if (previous?.kind === "action" &&
239
655
  previous.status === "passed" &&
240
656
  previous.name === name &&
241
- previous.detail === detail) {
657
+ previous.detail === detail &&
658
+ previous.sequence === this.eventSequence) {
242
659
  const started = Date.now();
243
660
  try {
244
661
  const result = await this.guard(op);
@@ -250,6 +667,7 @@ export class LiveFixture {
250
667
  // A failure is its own row: it is the one attempt worth reading.
251
668
  const failed = {
252
669
  name,
670
+ ...this.traceMeta(),
253
671
  detail,
254
672
  kind: "action",
255
673
  path: previous.path,
@@ -261,12 +679,14 @@ export class LiveFixture {
261
679
  error: toReportError(error, previous.path),
262
680
  };
263
681
  siblings.push(failed);
682
+ this.noteFailedAction(name, detail, error);
264
683
  throw error;
265
684
  }
266
685
  }
267
686
  this.actionCount += 1;
268
687
  const record = {
269
688
  name,
689
+ ...this.traceMeta(),
270
690
  detail,
271
691
  kind: "action",
272
692
  path: [...this.stepStack.map((step) => step.name), name].join(" > "),
@@ -282,6 +702,7 @@ export class LiveFixture {
282
702
  // body, so a record left at its optimistic default would serialise as an
283
703
  // instant success — the hung call rendered as the fastest one in the trace.
284
704
  this.openActions.add(record);
705
+ await this.emitTrace({ type: "step_started", name, path: record.path });
285
706
  try {
286
707
  const result = await this.guard(op);
287
708
  record.duration_ms = Date.now() - started;
@@ -293,14 +714,64 @@ export class LiveFixture {
293
714
  record.status = error instanceof TimeoutError ? "timeout" : "failed";
294
715
  record.error = toReportError(error, record.path);
295
716
  this.openActions.delete(record);
717
+ this.noteFailedAction(name, detail, error);
296
718
  throw error;
297
719
  }
720
+ finally {
721
+ await this.emitTrace({ type: "step_finished", name, path: record.path,
722
+ status: record.status, duration_ms: record.duration_ms, error: record.error });
723
+ }
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 };
298
768
  }
299
769
  recordFailedAction(name, detail, error, duration) {
300
770
  const parent = this.stepStack[this.stepStack.length - 1];
301
771
  const path = [...this.stepStack.map((step) => step.name), name].join(" > ");
302
772
  (parent ? parent.steps : this.steps).push({
303
773
  name,
774
+ ...this.traceMeta(),
304
775
  detail,
305
776
  kind: "action",
306
777
  path,
@@ -324,6 +795,18 @@ export class LiveFixture {
324
795
  this.assertRunnable();
325
796
  return result;
326
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
+ }
327
810
  currentStepPath() {
328
811
  const current = this.stepStack[this.stepStack.length - 1];
329
812
  return current?.path ?? this.lastStepPath;
@@ -331,12 +814,51 @@ export class LiveFixture {
331
814
  noteAssertion(entry) {
332
815
  const record = {
333
816
  ...entry,
817
+ ...this.traceMeta(),
334
818
  step: this.currentStepPath(),
335
819
  };
336
820
  const current = this.stepStack[this.stepStack.length - 1];
337
821
  (current ? current.assertions : this.assertions).push(record);
338
822
  }
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
+ }
339
859
  assertRunnable() {
860
+ if (this.closed)
861
+ throw new Error("Test fixture is closed");
340
862
  if (this.cleanupActive) {
341
863
  if (this.cleanupUntil > 0 && Date.now() > this.cleanupUntil) {
342
864
  throw new TimeoutError("fixture cleanup budget exceeded");
@@ -350,147 +872,407 @@ export class LiveFixture {
350
872
  const path = this.currentStepPath();
351
873
  return path ? `in step ${JSON.stringify(path)}` : "";
352
874
  }
353
- wrapApp(driver) {
354
- 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.");
355
879
  return {
356
- page,
357
- nav: guardObject(driver.nav, this, "nav."),
358
- info: () => this.act("app.info", "", () => driver.info()),
359
- pages: () => this.act("app.pages", "", () => driver.pages()),
360
- surfaceLayout: () => this.act("app.surfaceLayout", "", () => driver.surfaceLayout()),
361
- eval: (options) => this.act("app.eval", summarise(options), async () => {
362
- // Ask the runtime which `lx.*` the script reached, and hand the
363
- // caller only the value — the observation is the report's business,
364
- // not the spec author's, and specs must not have to opt in for their
365
- // coverage to be measured.
366
- const result = (await driver.eval({
367
- ...this.withEvalBudget(options),
368
- captureCalls: true,
369
- }));
370
- // The marker, not the shape, identifies the envelope: a script that
371
- // returns undefined loses its `value` key on the wire, and sniffing
372
- // for that key handed the envelope itself back as the result.
373
- if (result && typeof result === "object" && result.__lxEval === 1) {
374
- const { calls, value } = result;
375
- if (Array.isArray(calls)) {
376
- for (const call of calls) {
377
- if (typeof call === "string")
378
- this.observed.add(call);
379
- }
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;
380
958
  }
381
- return value;
959
+ last = "no live instance";
382
960
  }
383
- // An older runtime ignores `captureCalls` and returns the bare value.
384
- return result;
385
- }),
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
+ },
386
1068
  };
387
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
+ }
388
1097
  /**
389
1098
  * The driver's own eval default is a flat few seconds, so a call that runs
390
1099
  * long under load fails a spec that still had most of its budget left. An
391
- * eval gets a share of the spec's budget instead — a third, capped — never
392
- * all of it: a call allowed to run the full budget leaves the spec no room
393
- * 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.
394
1101
  */
395
- withEvalBudget(options) {
396
- if (options && typeof options === "object" && options.timeoutMs === undefined) {
397
- const share = Math.floor(this.specBudgetMs / 3);
398
- 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`);
399
1128
  }
400
- 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"))));
401
1137
  }
402
- wrapPage(page) {
1138
+ /** `bound` is the immutable instance id captured by app.page(). */
1139
+ wrapView(page, bound) {
403
1140
  const location = () => {
404
1141
  const frame = callerLocation();
405
1142
  return { source: frame.file, line: frame.line, column: frame.column };
406
1143
  };
407
- // Every override lives in the proxy's `get` trap. Assigning onto the proxy
408
- // would write straight through to the real driver — `page.eval` would then
409
- // call itself forever.
410
- const overrides = {
411
- testId: (id) => this.locator(page, testIdSelector(id), location()),
412
- css: (selector) => this.locator(page, selector, location()),
413
- 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),
414
1152
  };
415
- const guarded = guardObject(page, this, "page.", Object.keys(overrides));
416
- return new Proxy(guarded, {
417
- get(target, prop, receiver) {
418
- if (typeof prop === "string" && prop in overrides)
419
- return overrides[prop];
420
- 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);
421
1164
  },
422
- });
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
+ };
423
1181
  }
424
- locator(page, selector, location) {
425
- return new PageLocator(page, (fn) => this.guard(fn), (verb, detail, op) => this.act(verb, detail, op), selector, location);
1182
+ locator(page, 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());
426
1185
  }
427
- locatorMatchers(locator, inverted) {
1186
+ locatorMatchers(locator, inverted, message) {
1187
+ const withMessage = (options) => message === undefined || options?.message !== undefined ? options : { ...options, message };
428
1188
  const self = {
429
- toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, options, inverted ? "not visible" : "visible"),
430
- toHaveText: (expected, options) => this.retryLocator(locator, "toHaveText", inverted, options, expected),
431
- toHaveCount: (expected, options) => this.retryLocator(locator, "toHaveCount", inverted, options, expected),
432
- 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),
433
1205
  };
434
1206
  Object.defineProperty(self, "not", {
435
- get: () => this.locatorMatchers(locator, !inverted),
1207
+ get: () => this.locatorMatchers(locator, !inverted, message),
1208
+ configurable: true,
436
1209
  });
437
1210
  return self;
438
1211
  }
439
- pollMatchers(read, options, inverted) {
440
- 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);
441
1214
  const fixture = this;
442
1215
  const self = {
443
1216
  toBe: (expected) => run("toBe", expected),
444
1217
  toEqual: (expected) => run("toEqual", expected),
445
1218
  toContain: (expected) => run("toContain", expected),
1219
+ toContainEqual: (expected) => run("toContainEqual", expected),
446
1220
  toMatch: (expected) => run("toMatch", expected),
447
1221
  toBeTruthy: () => run("toBeTruthy"),
448
1222
  toBeFalsy: () => run("toBeFalsy"),
449
1223
  toBeDefined: () => run("toBeDefined"),
450
1224
  toBeUndefined: () => run("toBeUndefined"),
451
1225
  toBeInstanceOf: (expected) => run("toBeInstanceOf", expected),
1226
+ toHaveLength: (expected) => run("toHaveLength", expected),
452
1227
  toBeGreaterThan: (expected) => run("toBeGreaterThan", expected),
453
1228
  toBeGreaterThanOrEqual: (expected) => run("toBeGreaterThanOrEqual", expected),
454
1229
  toBeLessThan: (expected) => run("toBeLessThan", expected),
455
1230
  toBeLessThanOrEqual: (expected) => run("toBeLessThanOrEqual", expected),
456
1231
  };
457
1232
  Object.defineProperty(self, "not", {
458
- get: () => fixture.pollMatchers(read, options, !inverted),
1233
+ get: () => fixture.pollMatchers(read, options, !inverted, api),
1234
+ configurable: true,
459
1235
  });
460
1236
  return self;
461
1237
  }
462
- async retryLocator(locator, matcher, inverted, options, expected) {
463
- 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 () => {
464
1242
  const frame = callerLocation();
465
1243
  const location = { source: frame.file, line: frame.line, column: frame.column };
466
- const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1244
+ const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
467
1245
  const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
468
- const started = Date.now();
1246
+ const context = () => this.deadlineContext(inverted ? `not.${matcher}` : matcher, location);
469
1247
  let lastResolved;
470
1248
  let lastError;
471
1249
  pushAssertionSilence();
472
1250
  this.silenceActions();
473
1251
  try {
474
- while (Date.now() - started < timeout) {
1252
+ while (!deadline.expired()) {
475
1253
  try {
476
- lastResolved = await resolveLocator(locator);
1254
+ lastResolved = await resolveLocator(locator, deadline, context, expected instanceof AttributeExpectation ? [expected.name] : []);
477
1255
  matchLocator(locator, lastResolved, matcher, expected, inverted);
478
1256
  this.noteAssertion({
479
1257
  matcher: inverted ? `not.${matcher}` : matcher,
480
1258
  expected: formatValue(expected),
481
- actual: formatValue(locatorActual(matcher, lastResolved)),
1259
+ actual: formatValue(locatorActual(matcher, lastResolved, expected)),
482
1260
  passed: true,
1261
+ target,
1262
+ message: options?.message,
1263
+ location: displayLocation(location.source, location.line, location.column),
1264
+ duration_ms: deadline.elapsed(),
483
1265
  });
484
1266
  return;
485
1267
  }
486
1268
  catch (error) {
487
- if (error instanceof TimeoutError || this.aborted)
1269
+ if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
488
1270
  throw error;
489
1271
  lastError = error;
490
1272
  }
491
- if (Date.now() - started >= timeout)
1273
+ if (deadline.expired())
492
1274
  break;
493
- await sleep(interval);
1275
+ await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
494
1276
  if (this.aborted && this.abortError)
495
1277
  throw this.abortError;
496
1278
  }
@@ -499,50 +1281,61 @@ export class LiveFixture {
499
1281
  popAssertionSilence();
500
1282
  this.resumeActions();
501
1283
  }
502
- const duration = Date.now() - started;
1284
+ const duration = deadline.elapsed();
1285
+ const miss = lastResolved && locator instanceof PageLocator ? locator.missText(lastResolved) : undefined;
503
1286
  throw this.retryFailure({
1287
+ clampNote: deadline.clampNote(),
504
1288
  matcher: inverted ? `not.${matcher}` : matcher,
505
1289
  expected,
506
- actual: locatorActual(matcher, lastResolved),
1290
+ actual: locatorActual(matcher, lastResolved, expected),
507
1291
  duration,
508
1292
  location,
509
1293
  lastError,
510
- 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,
511
1298
  });
512
- });
1299
+ }), `expect(${target}).${assertion}`);
513
1300
  }
514
- async retryPoll(read, matcher, inverted, options, expected) {
515
- 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 () => {
516
1305
  const frame = callerLocation();
517
1306
  const location = { source: frame.file, line: frame.line, column: frame.column };
518
- const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1307
+ const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
519
1308
  const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
520
- const started = Date.now();
1309
+ const context = () => this.deadlineContext(`${api} ${inverted ? "not." : ""}${matcher}`, location);
521
1310
  let lastActual;
522
1311
  let lastError;
523
1312
  pushAssertionSilence();
524
1313
  this.silenceActions();
525
1314
  try {
526
- while (Date.now() - started < timeout) {
1315
+ while (!deadline.expired()) {
527
1316
  try {
528
- lastActual = await read();
1317
+ lastActual = await deadline.call(`${api} read`, read, context);
529
1318
  applyMatcher(matcher, lastActual, expected, inverted);
530
1319
  this.noteAssertion({
531
1320
  matcher: inverted ? `not.${matcher}` : matcher,
532
1321
  expected: formatValue(expected),
533
1322
  actual: formatValue(lastActual),
534
1323
  passed: true,
1324
+ target: source,
1325
+ message: options?.message,
1326
+ location: displayLocation(location.source, location.line, location.column),
1327
+ duration_ms: deadline.elapsed(),
535
1328
  });
536
1329
  return;
537
1330
  }
538
1331
  catch (error) {
539
- if (error instanceof TimeoutError || this.aborted)
1332
+ if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
540
1333
  throw error;
541
1334
  lastError = error;
542
1335
  }
543
- if (Date.now() - started >= timeout)
1336
+ if (deadline.expired())
544
1337
  break;
545
- await sleep(interval);
1338
+ await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
546
1339
  if (this.aborted && this.abortError)
547
1340
  throw this.abortError;
548
1341
  }
@@ -552,14 +1345,24 @@ export class LiveFixture {
552
1345
  this.resumeActions();
553
1346
  }
554
1347
  throw this.retryFailure({
1348
+ clampNote: deadline.clampNote(),
555
1349
  matcher: inverted ? `not.${matcher}` : matcher,
556
1350
  expected,
557
1351
  actual: lastActual,
558
- duration: Date.now() - started,
1352
+ duration: deadline.elapsed(),
559
1353
  location,
560
1354
  lastError,
1355
+ target: source,
1356
+ message: options?.message,
561
1357
  });
562
- });
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");
563
1366
  }
564
1367
  retryFailure(input) {
565
1368
  const where = displayLocation(input.location.source, input.location.line, input.location.column);
@@ -569,11 +1372,13 @@ export class LiveFixture {
569
1372
  ? input.lastError.message
570
1373
  : undefined;
571
1374
  const lines = [
1375
+ input.message,
572
1376
  `Timed out after ${input.duration}ms retrying ${input.matcher}.`,
573
1377
  input.extra,
574
1378
  `Expected: ${formatValue(input.expected)}`,
575
1379
  `Received: ${formatValue(input.actual)}`,
576
1380
  `Retried for ${input.duration}ms`,
1381
+ input.clampNote,
577
1382
  `at ${where}`,
578
1383
  this.stepPathLine(),
579
1384
  last && last !== `Expected: ${formatValue(input.expected)}` ? last : undefined,
@@ -583,18 +1388,129 @@ export class LiveFixture {
583
1388
  expected: formatValue(input.expected),
584
1389
  actual: formatValue(input.actual),
585
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,
586
1397
  });
587
- 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)`);
588
1429
  }
1430
+ return id;
589
1431
  }
590
- function resolveLocator(locator) {
591
- if (locator instanceof PageLocator)
592
- return locator.resolve();
593
- 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";
594
1442
  }
595
- 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) {
596
1494
  if (!resolved)
597
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
+ }
1504
+ if (matcher === "toBeHidden")
1505
+ return !resolved.visible;
1506
+ if (matcher === "toBeAttached")
1507
+ return resolved.attached;
1508
+ if (matcher === "toBeEnabled")
1509
+ return resolved.enabled;
1510
+ if (matcher === "toBeDisabled")
1511
+ return resolved.enabled === false;
1512
+ if (matcher === "toBeEditable")
1513
+ return resolved.editable;
598
1514
  if (matcher === "toHaveCount")
599
1515
  return resolved.count;
600
1516
  if (matcher === "toHaveText")
@@ -606,6 +1522,13 @@ function locatorActual(matcher, resolved) {
606
1522
  return resolved.kind;
607
1523
  }
608
1524
  function matchLocator(locator, resolved, matcher, expected, inverted) {
1525
+ if (["toBeHidden", "toBeAttached", "toBeEnabled", "toBeDisabled", "toBeEditable"].includes(matcher)) {
1526
+ if (["toBeEnabled", "toBeDisabled", "toBeEditable"].includes(matcher) && resolved.count !== 1) {
1527
+ throw new Error(`Expected one attached element, received ${resolved.count}`);
1528
+ }
1529
+ applyMatcher("toBe", locatorActual(matcher, resolved), true, inverted);
1530
+ return;
1531
+ }
609
1532
  if (matcher === "toBeVisible") {
610
1533
  const pass = resolved.visible && resolved.kind === "unique";
611
1534
  if (pass === inverted) {
@@ -614,6 +1537,35 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
614
1537
  }
615
1538
  return;
616
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
+ }
617
1569
  if (matcher === "toHaveCount") {
618
1570
  applyMatcher("toBe", resolved.count, expected, inverted);
619
1571
  return;
@@ -622,7 +1574,7 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
622
1574
  if (expected instanceof RegExp)
623
1575
  applyMatcher("toMatch", resolved.text, expected, inverted);
624
1576
  else
625
- applyMatcher("toBe", resolved.text, expected, inverted);
1577
+ applyMatcher("toBe", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
626
1578
  return;
627
1579
  }
628
1580
  if (matcher === "toHaveValue") {
@@ -632,22 +1584,40 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
632
1584
  applyMatcher("toBe", resolved.value, expected, inverted);
633
1585
  }
634
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)\./;
635
1591
  function guardObject(target, fixture, path, skip = []) {
636
1592
  const cache = new Map();
637
1593
  return new Proxy(target, {
638
- get(obj, prop, receiver) {
1594
+ get(obj, prop) {
639
1595
  if (skip.includes(prop))
640
- return Reflect.get(obj, prop, receiver);
1596
+ return Reflect.get(obj, prop, obj);
641
1597
  if (cache.has(prop))
642
1598
  return cache.get(prop);
643
- const value = Reflect.get(obj, prop, receiver);
644
- if (typeof value === "function") {
1599
+ const value = Reflect.get(obj, prop, obj);
1600
+ // Rong native class instances are callable objects too. Getter results
1601
+ // are driver namespaces; rebinding them as methods loses their members.
1602
+ let owner = obj;
1603
+ let accessor = false;
1604
+ while (owner) {
1605
+ const descriptor = Object.getOwnPropertyDescriptor(owner, prop);
1606
+ if (descriptor) {
1607
+ accessor = typeof descriptor.get === "function";
1608
+ break;
1609
+ }
1610
+ owner = Object.getPrototypeOf(owner);
1611
+ }
1612
+ if (typeof value === "function" && !accessor) {
645
1613
  const name = `${path}${String(prop)}`;
646
- 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));
647
1617
  cache.set(prop, bound);
648
1618
  return bound;
649
1619
  }
650
- if (value && typeof value === "object") {
1620
+ if (value && (typeof value === "object" || typeof value === "function")) {
651
1621
  const nested = guardObject(value, fixture, `${path}${String(prop)}.`);
652
1622
  cache.set(prop, nested);
653
1623
  return nested;
@@ -656,6 +1626,43 @@ function guardObject(target, fixture, path, skip = []) {
656
1626
  },
657
1627
  });
658
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
+ }
659
1666
  /** The one detail worth showing beside an action: what it acted on. */
660
1667
  function summarise(input) {
661
1668
  if (input === undefined || input === null)
@@ -699,38 +1706,53 @@ function safeJson(data) {
699
1706
  export function toReportError(error, step) {
700
1707
  if (error instanceof AssertionError) {
701
1708
  const stack = remapStack(error.stack);
1709
+ const cause = error;
702
1710
  return {
1711
+ code: typeof cause.code === "string" ? cause.code : undefined,
1712
+ data: jsonData(cause.data),
703
1713
  name: error.name,
704
1714
  message: error.message,
705
1715
  stack,
706
1716
  matcher: error.matcher,
707
1717
  expected: formatValue(error.expected),
708
1718
  actual: formatValue(error.actual),
709
- location: firstLocation(stack),
1719
+ difference: ["toBe", "toEqual"].includes(error.matcher)
1720
+ ? firstDifference(error.expected, error.actual) : undefined,
1721
+ location: firstLocation(stack, error),
710
1722
  step,
711
1723
  };
712
1724
  }
713
1725
  if (error instanceof Error) {
714
1726
  const stack = remapStack(error.stack);
1727
+ const details = error;
715
1728
  return {
1729
+ code: typeof details.code === "string" ? details.code : undefined,
1730
+ data: jsonData(details.data),
716
1731
  name: error.name,
717
1732
  message: error.message,
718
1733
  stack,
719
- location: firstLocation(stack),
1734
+ location: firstLocation(stack, error),
720
1735
  step,
721
1736
  };
722
1737
  }
723
1738
  return { name: "Error", message: String(error), step };
724
1739
  }
725
- function firstLocation(stack) {
726
- const frame = parseFrames(stack)[0];
727
- return frame ? `${frame.file}:${frame.line}:${frame.column}` : undefined;
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
+ }
728
1748
  }
729
- export function protocolStatus(status) {
730
- if (status === "skipped" || status === "xfail")
731
- return status === "skipped" ? "skipped" : "passed";
732
- if (status === "passed")
733
- return "passed";
734
- return "failed";
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);
1755
+ const frame = frames.length ? resolveOrigin(frames) : undefined;
1756
+ return frame && !isFrameworkFrame(frame.file) ? `${frame.file}:${frame.line}:${frame.column}` : undefined;
735
1757
  }
736
1758
  //# sourceMappingURL=fixture.js.map