@lingxia/test 0.18.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 +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/locator.js CHANGED
@@ -1,19 +1,28 @@
1
- import { AssertionError } from "./expect.js";
1
+ var _a;
2
2
  import { cssEscape, formatValue } from "./format.js";
3
+ import { ActionDeadline, TimeoutError, errorCode, isElementRefusal, isPreDispatchPageError, isTransientPageError, isTransientTransportError, } from "./deadline.js";
4
+ import { resolveHost } from "./host.js";
3
5
  import { displayLocation } from "./ids.js";
4
6
  import { DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, } from "./version.js";
7
+ import { runnerSetTimeout } from "./pending.js";
8
+ /** Marks a locator across module copies, so `expect(locator)` retries it. */
9
+ export const LOCATOR_BRAND = Symbol.for("lingxia.test.locator");
5
10
  export function testIdSelector(id) {
6
11
  return `[data-testid="${cssEscape(id)}"]`;
7
12
  }
8
13
  export class PageLocator {
9
- constructor(page, guard, record, selector, location, options = {}) {
14
+ constructor(page, guard, record, selector, location, options = {}, room = () => Number.POSITIVE_INFINITY, refine = {}) {
10
15
  this.page = page;
11
16
  this.guard = guard;
12
17
  this.record = record;
13
18
  this.location = location;
14
19
  this.options = options;
20
+ this.room = room;
21
+ this.refine = refine;
22
+ this[_a] = true;
15
23
  this.selector = selector;
16
24
  this.options = { ...options };
25
+ this.refine = { ...refine };
17
26
  if (!selector.trim())
18
27
  throw new TypeError("Locator selector must not be empty");
19
28
  if (options.index !== undefined && (!Number.isInteger(options.index) || options.index < 0)) {
@@ -21,55 +30,172 @@ export class PageLocator {
21
30
  }
22
31
  }
23
32
  nth(index) {
24
- return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, { ...this.options, index });
33
+ return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, { ...this.options, index }, this.room, { ...this.refine, last: false });
25
34
  }
26
- async press(key, options) {
35
+ first() {
36
+ return this.nth(0);
37
+ }
38
+ last() {
39
+ const { index: _index, ...options } = this.options;
40
+ return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, options, this.room, { ...this.refine, last: true });
41
+ }
42
+ filter(options) {
43
+ const hasText = options?.hasText;
44
+ if (typeof hasText !== "string" && !(hasText instanceof RegExp)) {
45
+ throw new TypeError("filter() needs { hasText: string | RegExp }");
46
+ }
47
+ return new PageLocator(this.page, this.guard, this.record, this.selector, this.location, this.options, this.room, { ...this.refine, hasText });
48
+ }
49
+ // Actions return the fixture's own call, not a wrapper around it: a call
50
+ // the spec never awaited is then the very promise the runtime stops.
51
+ press(key, options) {
27
52
  if (!this.page.press)
28
- throw new Error("This page driver does not support press");
29
- await this.act("press", options, (css, index) => this.page.press({ ...this.options, css, index, key }));
53
+ return Promise.reject(new Error("This page driver does not support press"));
54
+ return this.act("press", options, (css, index) => this.page.press({ page: this.options.page, css, index, key }), `key=${formatValue(key)}`);
55
+ }
56
+ click(options) {
57
+ const force = options?.force === true;
58
+ return this.act("click", options, (css, index) => this.page.click({ page: this.options.page, css, index, ...(force ? { force } : {}) }));
59
+ }
60
+ fill(text, options) {
61
+ const force = options?.force === true;
62
+ return this.act("fill", options, (css, index) => this.page.fill({ page: this.options.page, css, text, index, ...(force ? { force } : {}) }), this.inputDetail(text));
63
+ }
64
+ type(text, options) {
65
+ return this.act("type", options, (css, index) => this.page.type({ page: this.options.page, css, text, index }), this.inputDetail(text));
66
+ }
67
+ waitFor(options) {
68
+ const state = options?.state ?? "visible";
69
+ if (!["attached", "detached", "visible", "hidden", "inViewport"].includes(state)) {
70
+ return Promise.reject(new TypeError(`Unknown locator state: ${String(state)}`));
71
+ }
72
+ const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
73
+ const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
74
+ if (!Number.isFinite(timeout) || timeout <= 0 || !Number.isFinite(interval) || interval <= 0) {
75
+ return Promise.reject(new TypeError("Wait timeout and interval must be positive finite numbers"));
76
+ }
77
+ const deadline = new ActionDeadline(timeout, this.room());
78
+ return this.record("page.waitFor", `${this.target()} ${state}`, async () => {
79
+ let reason = "not observed";
80
+ let cause;
81
+ while (!deadline.expired()) {
82
+ try {
83
+ const resolved = await this.resolve(deadline, "waitFor");
84
+ if (reachedState(resolved, state))
85
+ return;
86
+ reason = this.missText(resolved);
87
+ cause = undefined;
88
+ }
89
+ catch (error) {
90
+ // A page mid-transition, or a read the transport dropped, is not an
91
+ // answer yet; anything else is.
92
+ if (!isTransientPageError(error) && !isTransientTransportError(error))
93
+ throw error;
94
+ reason = transientReason(error);
95
+ cause = error;
96
+ }
97
+ if (deadline.expired())
98
+ break;
99
+ await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
100
+ }
101
+ throw withCause(new TimeoutError([
102
+ `Timed out after ${deadline.elapsed()}ms waiting for ${formatValue(this.selector)} to be ${state}.`,
103
+ reason,
104
+ deadline.clampNote(),
105
+ `at ${this.where()}`,
106
+ ].filter(Boolean).join("\n")), cause);
107
+ });
108
+ }
109
+ count() {
110
+ return this.read("count", async () => (await this.resolve(undefined, "count")).count);
111
+ }
112
+ isVisible() {
113
+ return this.read("isVisible", async () => {
114
+ const resolved = await this.resolve(undefined, "isVisible");
115
+ if (resolved.kind === "many")
116
+ throw new Error(`isVisible(): ${this.missText(resolved)}; narrow it with nth(), first(), last() or filter()`);
117
+ return resolved.kind === "unique";
118
+ });
30
119
  }
31
- async click(options) {
32
- await this.act("click", options, (css, index) => this.page.click({ ...this.options, css, index }));
120
+ textContent() {
121
+ return this.read("textContent", async () => (await this.single("textContent")).text);
33
122
  }
34
- async fill(text, options) {
35
- await this.act("fill", options, (css, index) => this.page.fill({ ...this.options, css, text, index }));
123
+ inputValue() {
124
+ return this.read("inputValue", async () => {
125
+ const resolved = await this.single("inputValue");
126
+ if (resolved.value === null)
127
+ throw new Error(`inputValue(): ${this.target()} has no value (not an input, textarea or select)`);
128
+ return resolved.value;
129
+ });
36
130
  }
37
- async type(text, options) {
38
- await this.act("type", options, (css, index) => this.page.type({ ...this.options, css, text, index }));
131
+ getAttribute(name) {
132
+ if (typeof name !== "string" || name.length === 0) {
133
+ return Promise.reject(new TypeError("getAttribute(name) takes an attribute name"));
134
+ }
135
+ return this.read("getAttribute", async () => (await this.single("getAttribute", [name])).attributes?.[name] ?? null);
39
136
  }
40
- async query() {
41
- return this.guard(() => this.page.query({ ...this.options, css: this.selector }));
137
+ /** A traced one-shot read. */
138
+ read(verb, op) {
139
+ return this.record(`page.${verb}`, this.target(), op);
42
140
  }
43
- async resolve() {
44
- const all = await this.guard(() => this.page.query({ page: this.options.page, css: this.selector, all: true }));
45
- const matches = Array.isArray(all.items) ? all.items : all.exists ? [all] : [];
46
- const selected = this.options.index === undefined ? undefined : matches[this.options.index];
47
- const items = this.options.index === undefined ? matches : selected ? [{ ...selected, index: this.options.index }] : [];
48
- const count = this.options.index === undefined ? (all.count ?? items.length) : items.length;
141
+ async single(verb, attributes = []) {
142
+ const resolved = await this.resolve(undefined, verb, attributes);
143
+ if (resolved.count !== 1) {
144
+ throw new Error(`${verb}(): ${this.missText(resolved)}` +
145
+ (resolved.kind === "many" ? "; narrow it with nth(), first(), last() or filter()" : ""));
146
+ }
147
+ return resolved;
148
+ }
149
+ /**
150
+ * One read of the locator's matches. With a deadline the query is raced
151
+ * against what is left of it, so a query that never returns fails `verb`.
152
+ */
153
+ async resolve(deadline, verb = "resolve", attributes = []) {
154
+ const query = () => this.page.query({ page: this.options.page, css: this.selector, all: true });
155
+ const all = await this.guard(() => deadline ? deadline.call("page.query", query, () => this.callContext(verb)) : query());
156
+ const raw = Array.isArray(all.items) ? all.items : all.exists ? [all] : [];
157
+ // Every match keeps its DOM index: the driver addresses `css` + index.
158
+ // Text is read as the user sees it: `innerText` line breaks depend on
159
+ // layout (WebKit ends a flex row's text in "\n"), so whitespace is
160
+ // collapsed and trimmed before any matcher sees it.
161
+ const indexed = raw.map((item, position) => ({
162
+ ...item,
163
+ index: item.index ?? position,
164
+ ...(typeof item.text === "string" ? { text: normalizeText(item.text) } : {}),
165
+ }));
166
+ const hasText = this.refine.hasText;
167
+ const matches = hasText === undefined ? indexed : indexed.filter((item) => textMatches(item.text ?? "", hasText));
168
+ const pick = this.refine.last ? matches.length - 1 : this.options.index;
169
+ const picked = pick === undefined ? undefined : matches[pick];
170
+ const items = pick === undefined ? matches : picked ? [picked] : [];
171
+ const count = pick !== undefined ? items.length : hasText === undefined ? (all.count ?? items.length) : items.length;
49
172
  const visibleItems = items.filter((item) => item.visible);
50
173
  const visibleCount = visibleItems.length;
174
+ let resolved;
51
175
  if (count === 0) {
52
- return {
176
+ resolved = {
53
177
  count: 0,
54
178
  visibleCount: 0,
55
179
  attached: false,
56
180
  visible: false,
181
+ inViewport: false,
57
182
  text: "",
58
183
  value: null,
59
184
  index: 0,
60
185
  kind: "nothing",
61
186
  };
62
187
  }
63
- if (count > 1) {
64
- return { count, visibleCount, attached: true, visible: visibleCount > 0,
188
+ else if (count > 1) {
189
+ resolved = { count, visibleCount, attached: true, visible: visibleCount > 0, inViewport: false,
65
190
  text: items.map(item => item.text ?? "").join("\n"), value: null, index: 0, kind: "many" };
66
191
  }
67
- if (visibleCount === 0) {
68
- return {
192
+ else if (visibleCount === 0) {
193
+ resolved = {
69
194
  count,
70
195
  visibleCount: 0,
71
196
  attached: true,
72
197
  visible: false,
198
+ inViewport: false,
73
199
  text: items[0]?.text ?? "",
74
200
  value: items[0]?.value ?? null,
75
201
  index: items[0]?.index ?? 0,
@@ -77,20 +203,48 @@ export class PageLocator {
77
203
  kind: "hidden",
78
204
  };
79
205
  }
80
- const unique = visibleItems[0];
81
- return {
82
- count,
83
- visibleCount: 1,
84
- attached: true,
85
- visible: true,
86
- text: unique.text ?? "",
87
- value: unique.value ?? null,
88
- index: unique.index ?? items.indexOf(unique),
89
- enabled: unique.enabled, editable: unique.editable, rect: unique.rect,
90
- kind: "unique",
91
- };
206
+ else {
207
+ const unique = visibleItems[0];
208
+ resolved = {
209
+ count,
210
+ visibleCount: 1,
211
+ attached: true,
212
+ visible: true,
213
+ inViewport: unique.inViewport === true,
214
+ text: unique.text ?? "",
215
+ value: unique.value ?? null,
216
+ index: unique.index ?? items.indexOf(unique),
217
+ enabled: unique.enabled, editable: unique.editable, rect: unique.rect,
218
+ kind: "unique",
219
+ };
220
+ }
221
+ if (attributes.length > 0 && count === 1) {
222
+ resolved.attributes = await this.readAttributes(resolved.index, attributes, deadline, verb);
223
+ }
224
+ return resolved;
225
+ }
226
+ /** A read-only look at the single match's attributes. */
227
+ async readAttributes(index, names, deadline, verb) {
228
+ if (!this.page.eval)
229
+ throw new Error("This page driver cannot read attributes");
230
+ const script = `(() => {
231
+ const el = document.querySelectorAll(${JSON.stringify(this.selector)})[${index}];
232
+ const out = {};
233
+ for (const name of ${JSON.stringify(names)}) out[name] = el ? el.getAttribute(name) : null;
234
+ return out;
235
+ })()`;
236
+ const read = () => this.page.eval({ page: this.options.page, script });
237
+ const value = await this.guard(() => deadline ? deadline.call("page.eval (attributes)", read, () => this.callContext(verb)) : read());
238
+ const out = {};
239
+ const record = value && typeof value === "object" ? value : {};
240
+ for (const name of names)
241
+ out[name] = typeof record[name] === "string" ? record[name] : null;
242
+ return out;
92
243
  }
93
244
  missText(resolved) {
245
+ if (resolved.kind === "unique" && !resolved.inViewport) {
246
+ return `locator ${formatValue(this.selector)} resolved to a visible element outside the viewport`;
247
+ }
94
248
  if (resolved.kind === "nothing") {
95
249
  return `locator ${formatValue(this.selector)} resolved to nothing`;
96
250
  }
@@ -102,10 +256,37 @@ export class PageLocator {
102
256
  }
103
257
  return `locator ${formatValue(this.selector)} resolved to a visible element`;
104
258
  }
105
- async actionability(index, verb) {
259
+ /** The page instance this locator is bound to; `null` for the current page. */
260
+ get boundPage() {
261
+ return this.options.page ?? null;
262
+ }
263
+ /** The page, selector and narrowing, as the trace and failures show it. */
264
+ describe() {
265
+ return this.target();
266
+ }
267
+ inputDetail(text) {
268
+ // Locator names commonly reveal password fields. Declared --secret-arg
269
+ // values are also scrubbed from the final report by the run redactor.
270
+ if (/(?:password|passwd|passphrase|secret|token|credential|api[-_]?key)/i.test(this.selector)) {
271
+ return `value=[redacted, ${text.length} chars]`;
272
+ }
273
+ return `value=${formatValue(text)}`;
274
+ }
275
+ target() {
276
+ const filter = this.refine.hasText === undefined ? "" : ` filter(hasText=${formatValue(this.refine.hasText)})`;
277
+ const pick = this.refine.last ? " [last]" : this.options.index === undefined ? "" : ` [${this.options.index}]`;
278
+ return `${this.options.page ? `#${this.options.page} ` : ""}${this.selector}${filter}${pick}`;
279
+ }
280
+ where() {
281
+ return displayLocation(this.location.source, this.location.line, this.location.column);
282
+ }
283
+ callContext(verb) {
284
+ return `while trying to ${verb} ${formatValue(this.selector)}\nat ${this.where()}`;
285
+ }
286
+ async actionability(index, verb, deadline) {
106
287
  if (!this.page.eval)
107
288
  return true;
108
- const result = await this.guard(() => this.page.eval({ page: this.options.page, script: `(() => {
289
+ const script = `(() => {
109
290
  const el = document.querySelectorAll(${JSON.stringify(this.selector)})[${index}];
110
291
  if (!el || !el.isConnected) return "element detached";
111
292
  el.scrollIntoView({block:"center", inline:"center", behavior:"instant"});
@@ -114,70 +295,216 @@ export class PageLocator {
114
295
  const r = el.getBoundingClientRect();
115
296
  const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
116
297
  return hit && (hit === el || el.contains(hit)) ? true : "element is obscured";
117
- })()` }));
298
+ })()`;
299
+ const result = await this.guard(() => deadline.call("page.eval (actionability)", () => this.page.eval({ page: this.options.page, script }), () => this.callContext(verb)));
118
300
  return result === true ? true : String(result ?? "invalid actionability response");
119
301
  }
120
- async act(verb, options, run) {
302
+ act(verb, options, run, input) {
303
+ const force = options?.force === true;
121
304
  const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
122
- const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
123
- if (!Number.isFinite(timeout) || timeout <= 0 || !Number.isFinite(interval) || interval <= 0) {
124
- throw new TypeError("Action timeout and interval must be positive finite numbers");
305
+ const interval = DEFAULT_POLL_INTERVAL_MS;
306
+ if (!Number.isFinite(timeout) || timeout <= 0) {
307
+ return Promise.reject(new TypeError("Action timeout must be a positive finite number"));
125
308
  }
126
- await this.record(`page.${verb}`, `${this.options.page ? this.options.page + " " : ""}${this.selector}${this.options.index === undefined ? "" : ` [${this.options.index}]`}`, async () => {
127
- const started = Date.now();
309
+ const deadline = new ActionDeadline(timeout, this.room());
310
+ const context = () => this.callContext(verb);
311
+ return this.record(`page.${verb}`, `${this.target()}${input ? ` ${input}` : ""}`, async () => {
128
312
  let last;
129
313
  let previousRect;
130
314
  let reason = "not attached";
131
- while (Date.now() - started < timeout) {
132
- last = await this.resolve();
133
- reason = this.missText(last);
134
- if (last.kind === "hidden" && last.count === 1 && this.page.eval) {
135
- await this.guard(() => this.page.eval({ page: this.options.page, script: `document.querySelectorAll(${JSON.stringify(this.selector)})[${last.index}]?.scrollIntoView({block:"center", inline:"center", behavior:"instant"})` }));
136
- }
137
- if (last.kind === "unique") {
138
- const rect = JSON.stringify(last.rect);
139
- const stable = last.rect === undefined || previousRect === rect;
140
- previousRect = rect;
141
- if (last.enabled === false)
142
- reason = "element is disabled";
143
- else if ((verb === "fill" || verb === "type") && last.editable === false)
144
- reason = "element is not editable";
145
- else if (!stable)
146
- reason = "element is moving";
147
- else {
148
- const check = await this.actionability(last.index, verb);
149
- if (check === true) {
150
- try {
151
- await this.guard(() => run(this.selector, last.index));
152
- return;
315
+ // Preserve the last driver failure as the timeout cause.
316
+ let cause;
317
+ const until = async (budget) => {
318
+ while (!budget.expired()) {
319
+ try {
320
+ last = await this.resolve(budget, verb);
321
+ reason = this.missText(last);
322
+ cause = undefined;
323
+ if (force && last.count === 1) {
324
+ // Forced: attached and enabled are enough; no viewport, stability
325
+ // or hit-test wait.
326
+ if (last.enabled === false)
327
+ reason = "element is disabled";
328
+ else if ((verb === "fill" || verb === "type") && last.editable === false)
329
+ reason = "element is not editable";
330
+ else {
331
+ try {
332
+ const index = last.index;
333
+ await this.guard(() => budget.call(`page.${verb}`, () => run(this.selector, index), context));
334
+ return true;
335
+ }
336
+ catch (error) {
337
+ if (!isElementRefusal(error) && !isPreDispatchPageError(error)) {
338
+ throw new DispatchFailure(error);
339
+ }
340
+ reason = error instanceof Error ? error.message : String(error);
341
+ cause = error;
342
+ }
153
343
  }
154
- catch (error) {
155
- // These native errors are raised before input dispatch. A transport
156
- // failure is ambiguous and must never resubmit an action.
157
- const message = error instanceof Error ? error.message : "";
158
- if (!/^Element (?:not found|not interactable):/.test(message))
159
- throw error;
160
- reason = message;
161
- previousRect = undefined;
344
+ }
345
+ else if (last.kind === "unique") {
346
+ const rect = JSON.stringify(last.rect);
347
+ const stable = last.rect === undefined || previousRect === rect;
348
+ previousRect = rect;
349
+ if (last.enabled === false)
350
+ reason = "element is disabled";
351
+ else if ((verb === "fill" || verb === "type") && last.editable === false)
352
+ reason = "element is not editable";
353
+ else if (!stable)
354
+ reason = "element is moving";
355
+ else {
356
+ const check = await this.actionability(last.index, verb, budget);
357
+ if (check === true) {
358
+ try {
359
+ const index = last.index;
360
+ await this.guard(() => budget.call(`page.${verb}`, () => run(this.selector, index), context));
361
+ return true;
362
+ }
363
+ catch (error) {
364
+ // These native errors are raised before input dispatch. A transport
365
+ // failure is ambiguous and must never resubmit an action.
366
+ if (!isElementRefusal(error) && !isPreDispatchPageError(error)) {
367
+ throw new DispatchFailure(error);
368
+ }
369
+ reason = error instanceof Error ? error.message : String(error);
370
+ cause = error;
371
+ previousRect = undefined;
372
+ }
373
+ }
374
+ if (check !== true)
375
+ reason = check;
162
376
  }
163
377
  }
164
- if (check !== true)
165
- reason = check;
378
+ else {
379
+ previousRect = undefined;
380
+ }
166
381
  }
382
+ catch (error) {
383
+ // The page is being replaced (navigation in flight): no read or
384
+ // dispatch reached it, so wait for the new page within the budget.
385
+ if (error instanceof DispatchFailure)
386
+ throw error.error;
387
+ // Reads only: a dispatch error is a DispatchFailure, never retried.
388
+ if (!isTransientPageError(error) && !isTransientTransportError(error))
389
+ throw error;
390
+ reason = transientReason(error);
391
+ cause = error;
392
+ previousRect = undefined;
393
+ }
394
+ await sleep(Math.min(interval, Math.max(1, budget.remaining())));
167
395
  }
168
- else {
169
- previousRect = undefined;
170
- }
171
- await sleep(Math.min(interval, Math.max(1, timeout - (Date.now() - started))));
172
- }
173
- const where = displayLocation(this.location.source, this.location.line, this.location.column);
174
- throw new AssertionError(verb, reason, "stable, enabled, unobscured element", `Timed out after ${Date.now() - started}ms waiting to ${verb} ${formatValue(this.selector)}.\n${reason}\nat ${where}`);
396
+ return false;
397
+ };
398
+ if (await until(deadline))
399
+ return;
400
+ throw withCause(new TimeoutError([
401
+ `Timed out after ${deadline.elapsed()}ms waiting to ${verb} ${formatValue(this.selector)}.`,
402
+ reason,
403
+ deadline.clampNote(),
404
+ `at ${this.where()}`,
405
+ ].filter(Boolean).join("\n")), cause);
175
406
  });
176
407
  }
177
408
  }
409
+ _a = LOCATOR_BRAND;
410
+ /** Preserve the last driver failure without changing the public timeout code. */
411
+ function withCause(error, cause) {
412
+ if (cause === undefined)
413
+ return error;
414
+ Object.assign(error, { cause });
415
+ const code = errorCode(cause);
416
+ const data = cause && typeof cause === "object" ? cause.data : undefined;
417
+ error.data = {
418
+ ...(data && typeof data === "object" ? data : {}),
419
+ ...(code === undefined ? {} : { driverCode: code }),
420
+ };
421
+ return error;
422
+ }
423
+ /** A dispatch error that must propagate as is, never be retried. */
424
+ class DispatchFailure {
425
+ constructor(error) {
426
+ this.error = error;
427
+ }
428
+ }
429
+ function transientReason(error) {
430
+ const text = error instanceof Error ? error.message : String(error);
431
+ return isTransientTransportError(error) ? `read dropped by the transport: ${text}` : `page not ready: ${text}`;
432
+ }
433
+ /** `many` never satisfies `attached`/`visible`: the match is ambiguous. */
434
+ function reachedState(resolved, state) {
435
+ switch (state) {
436
+ case "attached":
437
+ return resolved.kind === "unique" || resolved.kind === "hidden";
438
+ case "visible":
439
+ return resolved.kind === "unique";
440
+ case "hidden":
441
+ return resolved.visibleCount === 0;
442
+ case "detached":
443
+ return resolved.kind === "nothing";
444
+ case "inViewport":
445
+ return resolved.kind === "unique" && resolved.inViewport;
446
+ }
447
+ }
448
+ /** How long the visibility probe waits for an animation frame. */
449
+ const VISIBILITY_FRAME_WAIT_MS = 300;
450
+ /** The probe's whole budget: a hidden page also throttles its timers. */
451
+ export const VISIBILITY_PROBE_BUDGET_MS = 1500;
452
+ /**
453
+ * Reads `document.visibilityState` and whether an animation frame arrives.
454
+ * WebKit stops animation frames for a page that is hidden or in a fully
455
+ * covered window, so rAF- and transition-driven UI (a sheet sliding in)
456
+ * never moves and actionability reads it as obscured.
457
+ */
458
+ export const VISIBILITY_PROBE_SCRIPT = `new Promise((resolve) => {
459
+ let done = false;
460
+ const finish = (frame) => {
461
+ if (done) return;
462
+ done = true;
463
+ resolve({ state: String(document.visibilityState || "unknown"), animationFrames: frame });
464
+ };
465
+ try { requestAnimationFrame(() => finish(true)); } catch (_) { finish(false); }
466
+ setTimeout(() => finish(false), ${VISIBILITY_FRAME_WAIT_MS});
467
+ })`;
468
+ /**
469
+ * What the probe can say: what it observed, and what usually causes that. It
470
+ * cannot see the window, so the cause stays a likely one.
471
+ */
472
+ export const HIDDEN_PAGE_NOTE = "page looked hidden or paused";
473
+ const HIDDEN_PAGE_CAUSE = "a covered window or a sleeping display pauses a page's animations";
474
+ /** Why every page is hidden when the host can tell the screen is locked. */
475
+ export const SCREEN_LOCKED_NOTE = "the screen is locked; unlock it — animations and sheets are paused";
476
+ /** A hidden-page note, naming the lock when that is the cause. */
477
+ export function hiddenCause(note) {
478
+ return note !== undefined && resolveHost().screenLocked() ? SCREEN_LOCKED_NOTE : note;
479
+ }
480
+ /** What the probe saw, or `undefined` for an unreadable answer. */
481
+ export function pageVisibility(value) {
482
+ if (!value || typeof value !== "object")
483
+ return undefined;
484
+ const record = value;
485
+ if (typeof record.state !== "string" || typeof record.animationFrames !== "boolean")
486
+ return undefined;
487
+ const hidden = record.state === "hidden" || !record.animationFrames;
488
+ return {
489
+ state: record.state,
490
+ animationFrames: record.animationFrames,
491
+ ...(hidden ? { note: `${HIDDEN_PAGE_NOTE}: visibilityState "${record.state}", ${record.animationFrames ? "animation frames running" : `no animation frame observed within ${VISIBILITY_FRAME_WAIT_MS}ms`} (${HIDDEN_PAGE_CAUSE})` } : {}),
492
+ };
493
+ }
494
+ /** Collapse every run of whitespace (newlines included) to one space and trim. */
495
+ export function normalizeText(value) {
496
+ return value.replace(/\s+/g, " ").trim();
497
+ }
498
+ export function textMatches(text, expected) {
499
+ if (expected instanceof RegExp) {
500
+ expected.lastIndex = 0;
501
+ return expected.test(text);
502
+ }
503
+ return normalizeText(text).toLowerCase().includes(normalizeText(expected).toLowerCase());
504
+ }
178
505
  export function sleep(ms) {
179
506
  return new Promise((resolve) => {
180
- setTimeout(resolve, ms);
507
+ runnerSetTimeout(resolve, ms);
181
508
  });
182
509
  }
183
510
  //# sourceMappingURL=locator.js.map