e2e 0.16.0 → 0.18.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 (256) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act.js +7 -4
  4. package/dist/agent/action-dispatcher.js +4 -5
  5. package/dist/agent/actions.d.ts +32 -23
  6. package/dist/agent/actions.js +38 -24
  7. package/dist/agent/ai-sdk.js +5 -4
  8. package/dist/agent/error.js +3 -3
  9. package/dist/agent/invocation.js +3 -4
  10. package/dist/agent/model/sdk.js +2 -0
  11. package/dist/agent/model/tool-choice.d.ts +9 -0
  12. package/dist/agent/model/tool-choice.js +14 -0
  13. package/dist/agent/observation-feed.d.ts +5 -6
  14. package/dist/agent/observation-feed.js +8 -11
  15. package/dist/agent/observation.d.ts +40 -11
  16. package/dist/agent/observation.js +109 -59
  17. package/dist/agent/phases.d.ts +7 -1
  18. package/dist/agent/phases.js +12 -3
  19. package/dist/agent/point-tap.d.ts +8 -8
  20. package/dist/agent/point-tap.js +1 -1
  21. package/dist/agent/prompts.js +3 -0
  22. package/dist/agent/replay.d.ts +19 -8
  23. package/dist/agent/replay.js +29 -29
  24. package/dist/agent/scroll-target.d.ts +2 -3
  25. package/dist/agent/step-cache.d.ts +43 -15
  26. package/dist/agent/step-cache.js +156 -67
  27. package/dist/agent/tool-loop.js +94 -5
  28. package/dist/cache/anchors.d.ts +72 -23
  29. package/dist/cache/anchors.js +233 -60
  30. package/dist/cache/context.d.ts +25 -14
  31. package/dist/cache/context.js +50 -8
  32. package/dist/cache/decide.d.ts +2 -2
  33. package/dist/cache/decide.js +4 -6
  34. package/dist/cache/identity.d.ts +18 -4
  35. package/dist/cache/identity.js +9 -4
  36. package/dist/cache/recorder.d.ts +15 -1
  37. package/dist/cache/recorder.js +36 -23
  38. package/dist/cache/rekeyed.d.ts +46 -0
  39. package/dist/cache/rekeyed.js +89 -0
  40. package/dist/cache/relocate.d.ts +14 -14
  41. package/dist/cache/relocate.js +33 -25
  42. package/dist/cache/route.d.ts +32 -33
  43. package/dist/cache/route.js +94 -50
  44. package/dist/cache/store.d.ts +6 -0
  45. package/dist/cache/store.js +21 -1
  46. package/dist/cache/trace.d.ts +53 -14
  47. package/dist/cache/trace.js +76 -30
  48. package/dist/cli/bin.js +2 -2
  49. package/dist/cli/index.js +43 -32
  50. package/dist/cli/init/engines.d.ts +1 -3
  51. package/dist/cli/init/engines.js +2 -2
  52. package/dist/cli/init/gateways.d.ts +1 -1
  53. package/dist/cli/init/gateways.js +11 -1
  54. package/dist/cli/init/mcp-config.d.ts +2 -1
  55. package/dist/cli/init/mcp-config.js +8 -3
  56. package/dist/cli/init/sibling-versions.json +1 -1
  57. package/dist/cli/init/versions.d.ts +0 -8
  58. package/dist/cli/init/versions.js +1 -10
  59. package/dist/cli/mcp.d.ts +3 -2
  60. package/dist/cli/mcp.js +52 -16
  61. package/dist/cli/run-output.d.ts +17 -0
  62. package/dist/cli/run-output.js +45 -0
  63. package/dist/collect/collect.js +8 -4
  64. package/dist/collect/registry.js +27 -16
  65. package/dist/config/compiled-files.d.ts +36 -0
  66. package/dist/config/compiled-files.js +50 -0
  67. package/dist/config/diagnose.js +21 -19
  68. package/dist/config/esm-hooks.d.ts +39 -44
  69. package/dist/config/esm-hooks.js +238 -63
  70. package/dist/config/load.d.ts +1 -7
  71. package/dist/config/load.js +23 -66
  72. package/dist/config/register.d.ts +8 -0
  73. package/dist/config/register.js +9 -0
  74. package/dist/config/resolve.d.ts +1 -0
  75. package/dist/config/resolve.js +33 -1
  76. package/dist/config/specifiers.d.ts +19 -0
  77. package/dist/config/specifiers.js +33 -0
  78. package/dist/config/tsconfig.d.ts +56 -0
  79. package/dist/config/tsconfig.js +115 -0
  80. package/dist/config/typescript.d.ts +19 -0
  81. package/dist/config/typescript.js +253 -0
  82. package/dist/engine/contract.d.ts +1 -0
  83. package/dist/engine/index.d.ts +4 -2
  84. package/dist/engine/session.js +2 -2
  85. package/dist/expect/async.js +4 -2
  86. package/dist/expect/poll-scope.d.ts +52 -0
  87. package/dist/expect/poll-scope.js +113 -0
  88. package/dist/expect/poll.js +33 -6
  89. package/dist/expect/values.js +11 -4
  90. package/dist/internal/abandoned.d.ts +11 -0
  91. package/dist/internal/abandoned.js +23 -0
  92. package/dist/internal/brands.d.ts +2 -0
  93. package/dist/internal/brands.js +2 -0
  94. package/dist/internal/client-identity.d.ts +9 -0
  95. package/dist/internal/client-identity.js +14 -0
  96. package/dist/internal/errors.d.ts +10 -3
  97. package/dist/internal/errors.js +23 -8
  98. package/dist/internal/node-version.d.ts +16 -7
  99. package/dist/internal/node-version.js +50 -15
  100. package/dist/internal/package-manager.js +1 -1
  101. package/dist/internal/realm-slot.d.ts +5 -4
  102. package/dist/internal/redact.d.ts +9 -4
  103. package/dist/internal/redact.js +120 -23
  104. package/dist/internal/time.d.ts +26 -4
  105. package/dist/internal/time.js +58 -14
  106. package/dist/internal/urls.d.ts +9 -5
  107. package/dist/internal/urls.js +19 -10
  108. package/dist/locator/engine.d.ts +12 -2
  109. package/dist/locator/engine.js +55 -14
  110. package/dist/locator/screen.js +59 -9
  111. package/dist/mcp/catalog.d.ts +3 -2
  112. package/dist/mcp/catalog.js +6 -6
  113. package/dist/mcp/output.d.ts +59 -0
  114. package/dist/mcp/output.js +129 -0
  115. package/dist/mcp/server.d.ts +4 -0
  116. package/dist/mcp/server.js +9 -12
  117. package/dist/mcp/session.d.ts +8 -1
  118. package/dist/mcp/session.js +56 -15
  119. package/dist/mcp/tools.js +7 -5
  120. package/dist/oauth/chatgpt.js +3 -23
  121. package/dist/oauth/cli.js +1 -0
  122. package/dist/oauth/copilot.d.ts +5 -4
  123. package/dist/oauth/copilot.js +80 -7
  124. package/dist/oauth/grok.js +2 -1
  125. package/dist/oauth/models.js +2 -1
  126. package/dist/oauth/opencode-console.d.ts +10 -0
  127. package/dist/oauth/opencode-console.js +133 -0
  128. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  129. package/dist/oauth/providers/github-copilot.js +77 -23
  130. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  131. package/dist/oauth/providers/opencode-console.js +148 -0
  132. package/dist/oauth/providers.d.ts +2 -6
  133. package/dist/oauth/providers.js +3 -7
  134. package/dist/oauth/responses.d.ts +9 -0
  135. package/dist/oauth/responses.js +23 -0
  136. package/dist/report/build.d.ts +26 -0
  137. package/dist/report/build.js +16 -8
  138. package/dist/report/failure-text.d.ts +6 -1
  139. package/dist/report/failure-text.js +14 -4
  140. package/dist/report/format.d.ts +10 -5
  141. package/dist/report/format.js +12 -5
  142. package/dist/report/junit.js +11 -12
  143. package/dist/report/list.d.ts +12 -0
  144. package/dist/report/list.js +61 -24
  145. package/dist/report/markdown.d.ts +1 -1
  146. package/dist/report/markdown.js +37 -14
  147. package/dist/report/repeats.d.ts +6 -1
  148. package/dist/report/repeats.js +12 -3
  149. package/dist/run/artifacts.d.ts +22 -4
  150. package/dist/run/artifacts.js +71 -3
  151. package/dist/run/execute.d.ts +34 -5
  152. package/dist/run/execute.js +93 -44
  153. package/dist/run/extended-fixtures.js +6 -2
  154. package/dist/run/failure-evidence.js +10 -4
  155. package/dist/run/fixtures.js +6 -8
  156. package/dist/run/in-process.d.ts +2 -0
  157. package/dist/run/in-process.js +52 -10
  158. package/dist/run/last-run.d.ts +42 -8
  159. package/dist/run/last-run.js +121 -14
  160. package/dist/run/process-output.d.ts +73 -0
  161. package/dist/run/process-output.js +130 -0
  162. package/dist/run/provision.d.ts +2 -0
  163. package/dist/run/provision.js +1 -0
  164. package/dist/run/realm.d.ts +4 -1
  165. package/dist/run/realm.js +20 -6
  166. package/dist/run/records.d.ts +20 -4
  167. package/dist/run/records.js +26 -0
  168. package/dist/run/retry.d.ts +10 -1
  169. package/dist/run/retry.js +30 -12
  170. package/dist/run/runner.d.ts +9 -1
  171. package/dist/run/runner.js +101 -39
  172. package/dist/run/scheduler.js +255 -19
  173. package/dist/run/secrecy.d.ts +22 -19
  174. package/dist/run/secrecy.js +22 -18
  175. package/dist/run/serial.d.ts +75 -2
  176. package/dist/run/serial.js +243 -59
  177. package/dist/run/standalone.js +1 -1
  178. package/dist/run/steps.d.ts +0 -6
  179. package/dist/run/steps.js +3 -20
  180. package/dist/run/trace-redaction.d.ts +5 -6
  181. package/dist/run/trace-redaction.js +5 -6
  182. package/dist/run/worker/entry.js +40 -18
  183. package/dist/run/worker/handle.js +10 -5
  184. package/dist/run/worker/protocol.d.ts +85 -3
  185. package/dist/run/worker/session.d.ts +2 -0
  186. package/dist/run/worker/session.js +29 -0
  187. package/dist/telemetry/environment.d.ts +2 -1
  188. package/dist/telemetry/environment.js +12 -2
  189. package/dist/telemetry/events.js +1 -0
  190. package/dist/telemetry/project.d.ts +5 -6
  191. package/dist/telemetry/project.js +6 -11
  192. package/dist/telemetry/store.d.ts +5 -5
  193. package/dist/telemetry/store.js +5 -7
  194. package/dist/telemetry/telemetry.d.ts +2 -3
  195. package/dist/telemetry/telemetry.js +2 -4
  196. package/dist/types.d.ts +15 -6
  197. package/docs/agent-steps.mdx +1 -0
  198. package/docs/agents.mdx +7 -3
  199. package/docs/api-testing.mdx +2 -1
  200. package/docs/authentication.mdx +6 -3
  201. package/docs/browser.mdx +49 -12
  202. package/docs/bug-bash.mdx +2 -1
  203. package/docs/cache.mdx +66 -19
  204. package/docs/ci.mdx +14 -9
  205. package/docs/coding-agents.mdx +2 -1
  206. package/docs/core-concepts.mdx +208 -0
  207. package/docs/debugging.mdx +25 -12
  208. package/docs/decision-models.mdx +142 -0
  209. package/docs/example-projects.mdx +32 -0
  210. package/docs/executors.mdx +7 -2
  211. package/docs/explore.mdx +1 -0
  212. package/docs/github.mdx +14 -5
  213. package/docs/index.mdx +9 -5
  214. package/docs/integrations/eas.mdx +1 -0
  215. package/docs/integrations/index.mdx +1 -0
  216. package/docs/integrations/kernel.mdx +3 -2
  217. package/docs/migrate/cypress.mdx +26 -3
  218. package/docs/migrate/detox.mdx +24 -1
  219. package/docs/migrate/maestro.mdx +28 -4
  220. package/docs/migrate/playwright.mdx +44 -18
  221. package/docs/migrate/selenium.mdx +27 -3
  222. package/docs/mobile-ci.mdx +6 -5
  223. package/docs/mobile.mdx +96 -8
  224. package/docs/models.mdx +60 -9
  225. package/docs/quickstart.mdx +28 -16
  226. package/docs/reference/agent.mdx +13 -3
  227. package/docs/reference/app.mdx +2 -1
  228. package/docs/reference/cli.mdx +41 -18
  229. package/docs/reference/config.mdx +67 -13
  230. package/docs/reference/engine.mdx +25 -13
  231. package/docs/reference/environment.mdx +10 -2
  232. package/docs/reference/errors.mdx +13 -10
  233. package/docs/reference/expect.mdx +36 -13
  234. package/docs/reference/mcp.mdx +21 -8
  235. package/docs/reference/mobile.mdx +24 -13
  236. package/docs/reference/reporters.mdx +9 -2
  237. package/docs/reference/screen.mdx +49 -16
  238. package/docs/reference/test.mdx +32 -5
  239. package/docs/reference/web.mdx +133 -23
  240. package/docs/security.mdx +58 -42
  241. package/docs/subscriptions.mdx +92 -12
  242. package/docs/telemetry.mdx +9 -6
  243. package/docs/tools.mdx +1 -0
  244. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  245. package/docs/writing-an-engine.mdx +2 -1
  246. package/package.json +25 -8
  247. package/schema/report-v1.schema.json +14 -1
  248. package/skills/e2e/SKILL.md +5 -3
  249. package/skills/e2e/references/agent.md +33 -11
  250. package/skills/e2e/references/debugging.md +4 -4
  251. package/skills/e2e/references/mcp.md +8 -3
  252. package/skills/e2e/references/running.md +15 -9
  253. package/skills/e2e/references/setup.md +45 -16
  254. package/skills/e2e/references/writing-tests.md +35 -18
  255. package/docs/viewports.mdx +0 -146
  256. package/docs/writing-tests.mdx +0 -256
@@ -63,24 +63,31 @@ function compile(values) {
63
63
  });
64
64
  // `split` on a capturing pattern returns the markers at the odd indexes; those pass through untouched.
65
65
  const redact = (text) => text.split(known).map((piece, index) => (index % 2 === 1 ? piece : rewrite(piece))).join('');
66
- const valueKeys = entries.map(([, value]) => caseKey(value));
66
+ const valueKeys = entries.map(([, value]) => readingKey(value).key);
67
+ // A reader trims a value's leading whitespace as it trims any text's, so a cut part may begin past it.
68
+ const leadingForms = entries.map(([, value], index) => {
69
+ const key = valueKeys[index] ?? '';
70
+ return secretLength(value.trim()) >= MIN_SECRET_LENGTH && key.trimStart() !== key ? [key, key.trimStart()] : [key];
71
+ });
67
72
  const redactCut = (text) => {
68
73
  // Only what follows the last marker can end in a value cut short.
69
74
  const tail = text.split(known).at(-1) ?? '';
70
- const tailKey = caseKey(tail);
75
+ const tailKey = readingKey(tail);
71
76
  let cut = 0;
72
77
  let marker = '';
73
- valueKeys.forEach((value, index) => {
74
- const length = leadingPartAtEnd(tailKey, value, Math.min(FRAGMENT_LENGTH, Math.ceil(value.length / 2)));
75
- if (length > cut) {
76
- cut = length;
77
- marker = markers[index] ?? '';
78
+ leadingForms.forEach((forms, index) => {
79
+ for (const value of forms) {
80
+ const length = leadingPartAtEnd(tailKey.key, value, Math.min(FRAGMENT_LENGTH, Math.ceil(value.length / 2)));
81
+ if (length > cut) {
82
+ cut = length;
83
+ marker = markers[index] ?? '';
84
+ }
78
85
  }
79
86
  });
80
87
  if (cut === 0)
81
88
  return redact(text);
82
89
  // An occurrence the whole-value pass would rewrite across the fragment's start joins the fragment.
83
- let start = tail.length - cut;
90
+ let start = tailKey.at(tailKey.key.length - cut);
84
91
  pattern.lastIndex = 0;
85
92
  for (let match = pattern.exec(tail); match !== null && match.index < start; match = pattern.exec(tail)) {
86
93
  if (match.index + match[0].length > start) {
@@ -221,6 +228,87 @@ function caseKey(text) {
221
228
  out += ch.charCodeAt(0) < 128 ? ch.toLowerCase() : caseKeyOf(ch);
222
229
  return out;
223
230
  }
231
+ /** Whitespace a reader rewrites: a run of two or more, or one character other than a plain space. */
232
+ const COLLAPSIBLE = /\s\s|[^\S ]/u;
233
+ /** UTF-16 units joined into one string at a time when the key is rebuilt. */
234
+ const KEY_CHUNK = 8192;
235
+ /**
236
+ * `text` as a cut or a fragment of a value is compared: `caseKey`, with each
237
+ * whitespace run read as one space, as an engine that collapses whitespace
238
+ * (`normalizeText`) shows it before cutting. A value holding a line break, a
239
+ * tab, a CRLF, a no-break space, or a run of spaces then agrees with what a
240
+ * collapsed text keeps of it, and a stretch of the key maps back to the text
241
+ * through `at`. `caseKey` keeps every index, so text with nothing to collapse
242
+ * is its own key. Otherwise the key is rebuilt unit by unit into a typed
243
+ * array, and `at` keeps one pair of offsets per collapsed run: a trace text
244
+ * of many megabytes costs a few times its size, never an object per unit.
245
+ */
246
+ function readingKey(text) {
247
+ const cased = caseKey(text);
248
+ if (!COLLAPSIBLE.test(cased))
249
+ return { key: cased, at: (index) => index };
250
+ const units = new Uint16Array(cased.length);
251
+ // Pairs of where a stretch after a collapsed run starts, in the key and in the text.
252
+ let breaks = new Int32Array(64);
253
+ let pairs = 0;
254
+ let length = 0;
255
+ for (let index = 0; index < cased.length; index += 1) {
256
+ const unit = cased.charCodeAt(index);
257
+ if (!isWhitespace(unit)) {
258
+ units[length++] = unit;
259
+ continue;
260
+ }
261
+ let end = index + 1;
262
+ while (end < cased.length && isWhitespace(cased.charCodeAt(end)))
263
+ end += 1;
264
+ units[length++] = 0x20;
265
+ if (end - index > 1 || unit !== 0x20) {
266
+ if (2 * pairs + 2 > breaks.length) {
267
+ const grown = new Int32Array(breaks.length * 2);
268
+ grown.set(breaks);
269
+ breaks = grown;
270
+ }
271
+ breaks[2 * pairs] = length;
272
+ breaks[2 * pairs + 1] = end;
273
+ pairs += 1;
274
+ }
275
+ index = end - 1;
276
+ }
277
+ const chunks = [];
278
+ for (let start = 0; start < length; start += KEY_CHUNK) {
279
+ chunks.push(String.fromCharCode(...units.subarray(start, Math.min(length, start + KEY_CHUNK))));
280
+ }
281
+ return {
282
+ key: chunks.join(''),
283
+ at: (index) => {
284
+ // The last stretch starting at or before `index`; before the first run the key and the text agree.
285
+ let low = -1;
286
+ let high = pairs - 1;
287
+ while (low < high) {
288
+ const middle = (low + high + 1) >> 1;
289
+ if ((breaks[2 * middle] ?? 0) <= index)
290
+ low = middle;
291
+ else
292
+ high = middle - 1;
293
+ }
294
+ return low === -1 ? index : (breaks[2 * low + 1] ?? 0) + index - (breaks[2 * low] ?? 0);
295
+ },
296
+ };
297
+ }
298
+ /** Whether a UTF-16 unit is one `\s` matches: what `normalizeText` collapses. */
299
+ function isWhitespace(unit) {
300
+ return (unit === 0x20 ||
301
+ (unit >= 0x09 && unit <= 0x0d) ||
302
+ unit === 0xa0 ||
303
+ unit === 0x1680 ||
304
+ (unit >= 0x2000 && unit <= 0x200a) ||
305
+ unit === 0x2028 ||
306
+ unit === 0x2029 ||
307
+ unit === 0x202f ||
308
+ unit === 0x205f ||
309
+ unit === 0x3000 ||
310
+ unit === 0xfeff);
311
+ }
224
312
  /** One non-ASCII character's `caseKey`, the same length as `ch`. */
225
313
  function caseKeyOf(ch) {
226
314
  const upper = sameLength(ch.toUpperCase(), ch);
@@ -306,8 +394,9 @@ function leadingPartAtEnd(text, value, minimum) {
306
394
  /**
307
395
  * Every run of `FRAGMENT_LENGTH` characters of every value, mapped to the
308
396
  * index of the first value holding it; values come longest first, so a run
309
- * two values share names the longer one. The values come as `caseKey`
310
- * reads them, so a run in any case finds its owner.
397
+ * two values share names the longer one. The values come as `readingKey`
398
+ * reads them, so a run in any case or with its whitespace collapsed finds
399
+ * its owner.
311
400
  */
312
401
  function fragmentOwners(values) {
313
402
  const owners = new Map();
@@ -323,29 +412,30 @@ function fragmentOwners(values) {
323
412
  /**
324
413
  * `text` with every stretch whose windows of `FRAGMENT_LENGTH` characters
325
414
  * each occur in a value replaced by the marker of the value owning its first
326
- * window. Windows are looked up by `caseKey`, as `owners` is keyed, so a
327
- * fragment in another case is one too. The stretch grows one window at a time, so the
328
- * scan is linear in the text and a run spanning two values becomes one
329
- * marker.
415
+ * window. Windows are looked up by `readingKey`, as `owners` is keyed, so a
416
+ * fragment in another case or with another whitespace run is one too, and
417
+ * the stretch is cut back out of `text` whole, its whitespace runs included.
418
+ * The stretch grows one window at a time, so the scan is linear in the text
419
+ * and a run spanning two values becomes one marker.
330
420
  */
331
421
  function rewriteFragments(text, owners, markers) {
332
422
  if (owners.size === 0)
333
423
  return text;
334
- const key = caseKey(text);
424
+ const { key, at } = readingKey(text);
335
425
  let out = '';
336
426
  let kept = 0;
337
427
  let start = 0;
338
- while (start + FRAGMENT_LENGTH <= text.length) {
428
+ while (start + FRAGMENT_LENGTH <= key.length) {
339
429
  const owner = owners.get(key.slice(start, start + FRAGMENT_LENGTH));
340
430
  if (owner === undefined) {
341
431
  start += 1;
342
432
  continue;
343
433
  }
344
434
  let end = start + FRAGMENT_LENGTH;
345
- while (end < text.length && owners.has(key.slice(end + 1 - FRAGMENT_LENGTH, end + 1)))
435
+ while (end < key.length && owners.has(key.slice(end + 1 - FRAGMENT_LENGTH, end + 1)))
346
436
  end += 1;
347
- out += `${text.slice(kept, start)}${markers[owner] ?? ''}`;
348
- kept = end;
437
+ out += `${text.slice(kept, at(start))}${markers[owner] ?? ''}`;
438
+ kept = at(end);
349
439
  start = end;
350
440
  }
351
441
  return out + text.slice(kept);
@@ -437,6 +527,10 @@ export class SecretLedger {
437
527
  }
438
528
  /** Bound so it can be handed out as a plain function. */
439
529
  redact = (text) => this.compile().redact(text);
530
+ /** Whether no value is registered, so there is nothing to redact. */
531
+ get isEmpty() {
532
+ return this.values.length === 0;
533
+ }
440
534
  /** Every registered name and value, in registration order. */
441
535
  entries() {
442
536
  return this.values.map(([name, value]) => [name, value]);
@@ -449,9 +543,12 @@ export class SecretLedger {
449
543
  * `FRAGMENT_LENGTH` characters, or half of a value shorter than twice that.
450
544
  * A cut is one position, so a boundary this short seldom matches plain text
451
545
  * by chance. Matched as the value is written, in any case (`caseKey`), as
452
- * a CSS `text-transform` shows it: the cut falls on text as the engine read
453
- * it, before any serializer spells it. A case mapping that changes length
454
- * (`ß` to `SS`) is not followed, nor is collapsed whitespace. The part is found before whole values are
546
+ * a CSS `text-transform` shows it, and with every whitespace run on both
547
+ * sides read as one space (`readingKey`) and the value's leading whitespace
548
+ * dropped where whole-value matching trims it (`valuePattern`), as an
549
+ * engine that collapses text before cutting it shows it: the cut falls on
550
+ * text as the engine read it, before any serializer spells it. A case
551
+ * mapping that changes length (`ß` to `SS`) is not followed. The part is found before whole values are
455
552
  * rewritten, so a value that starts with another registered value is not
456
553
  * half rewritten as the shorter one, and an occurrence running into the
457
554
  * part joins its marker. Bound like `redact`.
@@ -463,7 +560,7 @@ export class SecretLedger {
463
560
  * becomes its marker. For a recording that keeps what an engine read raw
464
561
  * (a Playwright trace holds the page's cut text and selections), where a
465
562
  * value cut or selected partway through survives whole-value matching.
466
- * Matched as the value is written, in any case, like `redactCut`. A
563
+ * Matched in any case and with whitespace runs collapsed, like `redactCut`. A
467
564
  * base64 or base64url run that decodes to text holding a value or a
468
565
  * fragment is rewritten whole (a basic-auth header the engine sent). Bound
469
566
  * like `redact`.
@@ -12,6 +12,15 @@ export declare class Deadline {
12
12
  export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
13
13
  /** Canonical polling cadence for locator and assertion loops. */
14
14
  export declare const POLL_INTERVAL_MS = 100;
15
+ /**
16
+ * Whether `cause` is a read a wait's deadline cut off: an operation timeout
17
+ * of a read that started with less than one poll tick of the wait left, so
18
+ * it timed out because the wait did, not because the app stopped answering.
19
+ * A read that started with more and still timed out hung, and its timeout is
20
+ * the failure. Reads the engine error a locator failure wraps as well as a
21
+ * bare one.
22
+ */
23
+ export declare function cutOffAtDeadline(cause: unknown, startedWithMs: number): boolean;
15
24
  /** How long a negated assertion must hold before it passes. */
16
25
  export declare const NEGATION_GRACE_MS = 1000;
17
26
  export interface PollConditionOptions {
@@ -19,17 +28,30 @@ export interface PollConditionOptions {
19
28
  readonly signal: AbortSignal;
20
29
  readonly negated: boolean;
21
30
  /**
22
- * Evaluates the positive condition once. Returns undefined when the
23
- * condition cannot be evaluated yet: the positive poll keeps waiting and
24
- * the negation grace window resets.
31
+ * Evaluates the positive condition once, every read bounded by
32
+ * `deadline`: a read on a longer budget outlasts the poll. Returns
33
+ * undefined when the condition cannot be evaluated yet: the positive poll
34
+ * keeps waiting and the negation grace window resets.
25
35
  */
26
36
  evaluate(): Promise<boolean | undefined>;
27
- onTimeout(): Error | Promise<Error>;
37
+ /** Builds the poll's failure; `cause` is the read the deadline cut off, when one did. */
38
+ onTimeout(cause?: unknown): Error | Promise<Error>;
28
39
  }
29
40
  /**
30
41
  * Polls a condition until it holds (or, when negated, until its negation has
31
42
  * held continuously for the negation grace window), throwing the caller's
32
43
  * error at the deadline.
44
+ *
45
+ * A negation holds from the moment the read that first saw it was issued, so
46
+ * a slow read counts toward the window. A budget shorter than the window
47
+ * still has to be satisfiable: the negation then only needs to hold for the
48
+ * whole budget, and passes at the deadline on what it has seen, since a read
49
+ * past the deadline has no budget left. A read the deadline cut off (see
50
+ * `cutOffAtDeadline`) after an earlier one completed saw nothing: the poll
51
+ * ends there. A negation then passes if it has held long enough by the
52
+ * time the read is cut off: the read left less than one poll tick unseen, no
53
+ * more than the gap between any two reads, so a short budget still passes at
54
+ * its deadline on what it saw.
33
55
  */
34
56
  export declare function pollCondition(options: PollConditionOptions): Promise<void>;
35
57
  /** Races a promise against a timeout; on timeout invokes onTimeout to build the error. */
@@ -1,5 +1,5 @@
1
1
  /** Deadline and cancellation helpers. */
2
- import { E2EError } from './errors.js';
2
+ import { asEngineError, E2EError } from './errors.js';
3
3
  export class Deadline {
4
4
  endsAt;
5
5
  constructor(timeoutMs, now = Date.now()) {
@@ -41,35 +41,79 @@ export function sleep(ms, signal) {
41
41
  }
42
42
  /** Canonical polling cadence for locator and assertion loops. */
43
43
  export const POLL_INTERVAL_MS = 100;
44
+ /**
45
+ * Whether `cause` is a read a wait's deadline cut off: an operation timeout
46
+ * of a read that started with less than one poll tick of the wait left, so
47
+ * it timed out because the wait did, not because the app stopped answering.
48
+ * A read that started with more and still timed out hung, and its timeout is
49
+ * the failure. Reads the engine error a locator failure wraps as well as a
50
+ * bare one.
51
+ */
52
+ export function cutOffAtDeadline(cause, startedWithMs) {
53
+ if (startedWithMs >= POLL_INTERVAL_MS)
54
+ return false;
55
+ const engineError = asEngineError(cause) ?? asEngineError(cause instanceof Error ? cause.cause : undefined);
56
+ return engineError?.code === 'OPERATION_TIMEOUT';
57
+ }
44
58
  /** How long a negated assertion must hold before it passes. */
45
59
  export const NEGATION_GRACE_MS = 1000;
46
60
  /**
47
61
  * Polls a condition until it holds (or, when negated, until its negation has
48
62
  * held continuously for the negation grace window), throwing the caller's
49
63
  * error at the deadline.
64
+ *
65
+ * A negation holds from the moment the read that first saw it was issued, so
66
+ * a slow read counts toward the window. A budget shorter than the window
67
+ * still has to be satisfiable: the negation then only needs to hold for the
68
+ * whole budget, and passes at the deadline on what it has seen, since a read
69
+ * past the deadline has no budget left. A read the deadline cut off (see
70
+ * `cutOffAtDeadline`) after an earlier one completed saw nothing: the poll
71
+ * ends there. A negation then passes if it has held long enough by the
72
+ * time the read is cut off: the read left less than one poll tick unseen, no
73
+ * more than the gap between any two reads, so a short budget still passes at
74
+ * its deadline on what it saw.
50
75
  */
51
76
  export async function pollCondition(options) {
52
- // A budget shorter than the grace window still has to be satisfiable: the
53
- // negation then only needs to hold for the budget itself.
54
- const grace = Math.min(NEGATION_GRACE_MS, Math.max(0, options.deadline.remaining()));
55
- let negatedTrueSince;
77
+ const { deadline, negated } = options;
78
+ const startedAt = Date.now();
79
+ const grace = Math.min(NEGATION_GRACE_MS, deadline.remaining(startedAt));
80
+ let readAt = startedAt;
81
+ let holdingSince;
82
+ const holds = (now) => holdingSince !== undefined && now - holdingSince >= grace;
83
+ let sampled = false;
56
84
  for (;;) {
57
- const value = await options.evaluate();
58
- if (!options.negated) {
59
- if (value === true)
85
+ const startedWithMs = deadline.remaining(readAt);
86
+ let value;
87
+ try {
88
+ value = await options.evaluate();
89
+ sampled = true;
90
+ }
91
+ catch (cause) {
92
+ if (!sampled || !cutOffAtDeadline(cause, startedWithMs))
93
+ throw cause;
94
+ if (negated && holds(Math.min(Date.now(), deadline.endsAt)))
60
95
  return;
96
+ throw await options.onTimeout(cause);
61
97
  }
62
- else if (value === false) {
63
- negatedTrueSince ??= Date.now();
64
- if (Date.now() - negatedTrueSince >= grace)
98
+ if (!negated) {
99
+ if (value === true)
65
100
  return;
66
101
  }
67
102
  else {
68
- negatedTrueSince = undefined;
103
+ holdingSince = value === false ? (holdingSince ?? readAt) : undefined;
69
104
  }
70
- if (options.deadline.expired())
105
+ const now = Date.now();
106
+ if (holds(now))
107
+ return;
108
+ if (deadline.expired(now))
71
109
  throw await options.onTimeout();
72
- await sleep(POLL_INTERVAL_MS, options.signal);
110
+ await sleep(negated ? Math.min(POLL_INTERVAL_MS, deadline.remaining(now)) : POLL_INTERVAL_MS, options.signal);
111
+ readAt = Date.now();
112
+ if (negated && deadline.expired(readAt)) {
113
+ if (holds(readAt))
114
+ return;
115
+ throw await options.onTimeout();
116
+ }
73
117
  }
74
118
  }
75
119
  /** Races a promise against a timeout; on timeout invokes onTimeout to build the error. */
@@ -23,10 +23,12 @@ export declare function isLoopbackHost(hostname: string): boolean;
23
23
  /** True when environment may default to `test` (loopback, .localhost, .test hosts). */
24
24
  export declare function isImplicitTestHost(hostname: string): boolean;
25
25
  /**
26
- * Resolves a navigation URL against the base and refuses the schemes no test
27
- * may open. Returns the absolute URL string. Any http(s) origin is admitted:
28
- * a click can reach one just as well, so a gate on typed navigation alone
29
- * would guard nothing.
26
+ * Resolves a navigation URL against the base and refuses every scheme but
27
+ * http(s), except the exact `about:blank`. Returns the absolute URL string.
28
+ * Any http(s) origin is admitted: a click can reach one just as well, so a
29
+ * gate on typed navigation alone would guard nothing. The rule is an
30
+ * allowlist because a browser wraps and nests schemes (`view-source:file:`,
31
+ * `blob:`, `filesystem:`), and a list of forbidden ones misses the wrapper.
30
32
  */
31
33
  export declare function resolveNavigationUrl(input: string, base: NormalizedBaseUrl | undefined): {
32
34
  url: string;
@@ -53,5 +55,7 @@ export declare function sameSite(url: string | URL, site: string): boolean;
53
55
  * Relative expected strings resolve against the base URL; string comparison is
54
56
  * exact after WHATWG serialization; regexps test the complete serialized URL.
55
57
  * Anything else is `INVALID_ARGUMENT`, the `toTextPattern` rule.
58
+ * `ignoreCase` is Playwright's: `true` compares a string case-insensitively
59
+ * and adds the `i` flag to a regexp, `false` removes it.
56
60
  */
57
- export declare function urlMatches(current: string, expected: string | RegExp, baseHref: string): boolean;
61
+ export declare function urlMatches(current: string, expected: string | RegExp, baseHref: string, ignoreCase?: boolean): boolean;
@@ -1,7 +1,7 @@
1
1
  /** URL normalization and origin policy helpers. */
2
2
  import { ConfigurationError } from './errors.js';
3
3
  import { testPattern } from './regexp.js';
4
- import { toTextPattern } from './text.js';
4
+ import { toTextPattern, withIgnoreCase } from './text.js';
5
5
  /**
6
6
  * Parses and normalizes the app base URL. Rejects userinfo, query, and
7
7
  * fragment. WHATWG parsing handles IDNA ASCII hosts, dot segments, and
@@ -94,12 +94,16 @@ export function isLoopbackHost(hostname) {
94
94
  export function isImplicitTestHost(hostname) {
95
95
  return isLoopbackHost(hostname) || hostname.endsWith('.test');
96
96
  }
97
- const FORBIDDEN_PROTOCOLS = new Set(['file:', 'data:', 'javascript:']);
97
+ const NAVIGABLE_PROTOCOLS = new Set(['http:', 'https:']);
98
+ /** The one non-http(s) page navigation opens: empty, with no local or inline content. */
99
+ const BLANK_PAGE = 'about:blank';
98
100
  /**
99
- * Resolves a navigation URL against the base and refuses the schemes no test
100
- * may open. Returns the absolute URL string. Any http(s) origin is admitted:
101
- * a click can reach one just as well, so a gate on typed navigation alone
102
- * would guard nothing.
101
+ * Resolves a navigation URL against the base and refuses every scheme but
102
+ * http(s), except the exact `about:blank`. Returns the absolute URL string.
103
+ * Any http(s) origin is admitted: a click can reach one just as well, so a
104
+ * gate on typed navigation alone would guard nothing. The rule is an
105
+ * allowlist because a browser wraps and nests schemes (`view-source:file:`,
106
+ * `blob:`, `filesystem:`), and a list of forbidden ones misses the wrapper.
103
107
  */
104
108
  export function resolveNavigationUrl(input, base) {
105
109
  let url;
@@ -114,7 +118,7 @@ export function resolveNavigationUrl(input, base) {
114
118
  }
115
119
  throw new ConfigurationError('POLICY_DENIED', `malformed URL: ${input}`);
116
120
  }
117
- if (FORBIDDEN_PROTOCOLS.has(url.protocol)) {
121
+ if (!NAVIGABLE_PROTOCOLS.has(url.protocol) && url.href !== BLANK_PAGE) {
118
122
  throw new ConfigurationError('POLICY_DENIED', `forbidden URL scheme: ${url.protocol}`);
119
123
  }
120
124
  return { url: url.href };
@@ -160,9 +164,11 @@ export function sameSite(url, site) {
160
164
  * Relative expected strings resolve against the base URL; string comparison is
161
165
  * exact after WHATWG serialization; regexps test the complete serialized URL.
162
166
  * Anything else is `INVALID_ARGUMENT`, the `toTextPattern` rule.
167
+ * `ignoreCase` is Playwright's: `true` compares a string case-insensitively
168
+ * and adds the `i` flag to a regexp, `false` removes it.
163
169
  */
164
- export function urlMatches(current, expected, baseHref) {
165
- const pattern = toTextPattern(expected);
170
+ export function urlMatches(current, expected, baseHref, ignoreCase) {
171
+ const pattern = withIgnoreCase(toTextPattern(expected), ignoreCase);
166
172
  if (pattern.kind === 'regexp') {
167
173
  return testPattern(pattern.source, pattern.flags, serializeForComparison(current));
168
174
  }
@@ -173,7 +179,10 @@ export function urlMatches(current, expected, baseHref) {
173
179
  catch {
174
180
  return false;
175
181
  }
176
- return serializeForComparison(current) === expectedUrl.href;
182
+ const actual = serializeForComparison(current);
183
+ return ignoreCase === true
184
+ ? actual.toLowerCase() === expectedUrl.href.toLowerCase()
185
+ : actual === expectedUrl.href;
177
186
  }
178
187
  function serializeForComparison(url) {
179
188
  try {
@@ -18,6 +18,11 @@ export declare function attributeOf(node: SemanticNode, name: string): string |
18
18
  * empty or absent.
19
19
  */
20
20
  export declare function denySecureRead(nodes: readonly SemanticNode[], label: string): void;
21
+ /**
22
+ * What a frame the engine reports missing means to a resolve: `wait` treats
23
+ * it like a node that is not there yet, `empty` answers zero matches.
24
+ */
25
+ type MissingFrame = 'wait' | 'empty';
21
26
  interface LocatorEngineOptions {
22
27
  readonly session: TargetSession;
23
28
  /** The running phase's signal and deadline, read per operation. */
@@ -95,8 +100,13 @@ export declare class LocatorEngine {
95
100
  /** Reads every current match at once, for `allTextContents`: a missing frame reads as no matches. */
96
101
  readAllNow(expression: LocatorExpression): Promise<readonly SemanticNode[]>;
97
102
  private readEvery;
98
- /** Reads one node for polling surfaces; returns null while zero matches. */
99
- tryRead(expression: LocatorExpression, deadline: Deadline): Promise<{
103
+ /**
104
+ * Reads one node for polling surfaces; returns null while zero matches.
105
+ * A poll that waits for absence passes `missingFrame: 'empty'`: nothing
106
+ * under a frame that is not in the document is attached, so the sample is
107
+ * zero matches rather than a wait for the frame.
108
+ */
109
+ tryRead(expression: LocatorExpression, deadline: Deadline, missingFrame?: MissingFrame): Promise<{
100
110
  node: SemanticNode | null;
101
111
  count: number;
102
112
  }>;
@@ -3,7 +3,7 @@ import {} from '../engine/surface.js';
3
3
  import { asEngineError, ConfigurationError, E2EError, TestError, translateEngineError, } from '../internal/errors.js';
4
4
  import { requireKey } from '../internal/keys.js';
5
5
  import { describeExpression, expressionHints } from './expression.js';
6
- import { Deadline, POLL_INTERVAL_MS, sleep } from '../internal/time.js';
6
+ import { cutOffAtDeadline, Deadline, POLL_INTERVAL_MS, sleep } from '../internal/time.js';
7
7
  /** Canonical visibility predicate over a semantic node snapshot. */
8
8
  export function isNodeVisible(node) {
9
9
  return node !== null && node.states?.hidden !== true;
@@ -96,16 +96,22 @@ export class LocatorEngine {
96
96
  * matches, and a read that promised not to wait must not wait for it.
97
97
  */
98
98
  async resolveOnce(expression, deadline, missingFrame = 'wait') {
99
+ let retried;
99
100
  for (;;) {
101
+ const startedWithMs = deadline.remaining();
100
102
  try {
101
103
  return await this.session.locate(expression, this.operationWithin(deadline));
102
104
  }
103
105
  catch (cause) {
106
+ // A retry the deadline cut off saw nothing new: the wait ends on the failure it was retrying.
107
+ if (retried !== undefined && cutOffAtDeadline(cause, startedWithMs))
108
+ throw translateLocatorError(retried, expression);
104
109
  const engineError = asEngineError(cause);
105
110
  if (engineError?.retryable === true) {
106
111
  if (missingFrame === 'empty' && engineError.code === 'FRAME_NOT_FOUND')
107
112
  return [];
108
113
  if (!deadline.expired()) {
114
+ retried = cause;
109
115
  await sleep(POLL_INTERVAL_MS, this.signal);
110
116
  continue;
111
117
  }
@@ -120,15 +126,31 @@ export class LocatorEngine {
120
126
  */
121
127
  async resolveExactlyOne(expression, deadline) {
122
128
  const startedMs = Date.now();
129
+ // The wait as it happened: a deadline capped by the test's remaining
130
+ // budget waited less than the action timeout, and the report says so.
131
+ const notFound = (cause) => new TestError('LOCATOR_NOT_FOUND', `locator matched no nodes within ${describeExpression(expression)}`, {
132
+ details: locatorDetails(expression, Date.now() - startedMs),
133
+ ...(cause === undefined ? {} : { cause }),
134
+ });
135
+ let sampled = false;
123
136
  for (;;) {
124
- const ref = assertSingle(await this.resolveOnce(expression, deadline), expression);
137
+ const startedWithMs = deadline.remaining();
138
+ let refs;
139
+ try {
140
+ refs = await this.resolveOnce(expression, deadline);
141
+ }
142
+ catch (cause) {
143
+ // A resolve the deadline cut off, after one that matched nothing, is the wait running out.
144
+ if (sampled && cutOffAtDeadline(cause, startedWithMs))
145
+ throw notFound(cause);
146
+ throw cause;
147
+ }
148
+ sampled = true;
149
+ const ref = assertSingle(refs, expression);
125
150
  if (ref !== null)
126
151
  return ref;
127
- if (deadline.expired()) {
128
- // The wait as it happened: a deadline capped by the test's remaining
129
- // budget waited less than the action timeout, and the report says so.
130
- throw new TestError('LOCATOR_NOT_FOUND', `locator matched no nodes within ${describeExpression(expression)}`, { details: locatorDetails(expression, Date.now() - startedMs) });
131
- }
152
+ if (deadline.expired())
153
+ throw notFound();
132
154
  await sleep(POLL_INTERVAL_MS, this.signal);
133
155
  }
134
156
  }
@@ -213,9 +235,14 @@ export class LocatorEngine {
213
235
  }
214
236
  }
215
237
  }
216
- /** Reads one node for polling surfaces; returns null while zero matches. */
217
- async tryRead(expression, deadline) {
218
- return this.readOne(expression, deadline, 'wait');
238
+ /**
239
+ * Reads one node for polling surfaces; returns null while zero matches.
240
+ * A poll that waits for absence passes `missingFrame: 'empty'`: nothing
241
+ * under a frame that is not in the document is attached, so the sample is
242
+ * zero matches rather than a wait for the frame.
243
+ */
244
+ async tryRead(expression, deadline, missingFrame = 'wait') {
245
+ return this.readOne(expression, deadline, missingFrame);
219
246
  }
220
247
  /**
221
248
  * Reads the one current match at once, for `isVisible` and `isHidden`:
@@ -270,16 +297,30 @@ export class LocatorEngine {
270
297
  if (this.session.actions.has('scrollIntoView')) {
271
298
  await this.performUntil(expression, { kind: 'scrollIntoView' }, deadline);
272
299
  }
300
+ const notVisible = (cause) => new TestError('LOCATOR_NOT_FOUND', `locator did not become visible with a box to act within: ${describeExpression(expression)}`, {
301
+ details: locatorDetails(expression, Date.now() - startedMs),
302
+ ...(cause === undefined ? {} : { cause }),
303
+ });
304
+ let sampled = false;
273
305
  for (;;) {
274
- const { node } = await this.tryRead(expression, deadline);
306
+ const startedWithMs = deadline.remaining();
307
+ let node;
308
+ try {
309
+ ({ node } = await this.tryRead(expression, deadline));
310
+ }
311
+ catch (cause) {
312
+ if (sampled && cutOffAtDeadline(cause, startedWithMs))
313
+ throw notVisible(cause);
314
+ throw cause;
315
+ }
316
+ sampled = true;
275
317
  const rect = isNodeVisible(node) ? node.rect : undefined;
276
318
  if (rect !== undefined) {
277
319
  await this.dispatchAt({ x: rect.x + offset.x, y: rect.y + offset.y }, action, deadline);
278
320
  return;
279
321
  }
280
- if (deadline.expired()) {
281
- throw new TestError('LOCATOR_NOT_FOUND', `locator did not become visible with a box to act within: ${describeExpression(expression)}`, { details: locatorDetails(expression, Date.now() - startedMs) });
282
- }
322
+ if (deadline.expired())
323
+ throw notVisible();
283
324
  await sleep(POLL_INTERVAL_MS, this.signal);
284
325
  }
285
326
  }