e2e 0.16.0 → 0.17.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 (174) hide show
  1. package/dist/.build.json +1 -1
  2. package/dist/agent/act.js +7 -4
  3. package/dist/agent/action-dispatcher.js +4 -5
  4. package/dist/agent/actions.d.ts +32 -23
  5. package/dist/agent/actions.js +38 -24
  6. package/dist/agent/model/sdk.js +2 -0
  7. package/dist/agent/observation-feed.d.ts +5 -6
  8. package/dist/agent/observation-feed.js +5 -7
  9. package/dist/agent/observation.d.ts +40 -11
  10. package/dist/agent/observation.js +109 -59
  11. package/dist/agent/point-tap.d.ts +8 -8
  12. package/dist/agent/point-tap.js +1 -1
  13. package/dist/agent/replay.d.ts +19 -8
  14. package/dist/agent/replay.js +11 -14
  15. package/dist/agent/scroll-target.d.ts +2 -3
  16. package/dist/agent/step-cache.d.ts +41 -14
  17. package/dist/agent/step-cache.js +147 -62
  18. package/dist/agent/tool-loop.js +2 -0
  19. package/dist/cache/anchors.d.ts +72 -23
  20. package/dist/cache/anchors.js +233 -60
  21. package/dist/cache/context.d.ts +25 -14
  22. package/dist/cache/context.js +50 -8
  23. package/dist/cache/decide.d.ts +2 -2
  24. package/dist/cache/decide.js +4 -6
  25. package/dist/cache/identity.d.ts +18 -4
  26. package/dist/cache/identity.js +9 -4
  27. package/dist/cache/recorder.d.ts +15 -1
  28. package/dist/cache/recorder.js +36 -23
  29. package/dist/cache/rekeyed.d.ts +46 -0
  30. package/dist/cache/rekeyed.js +89 -0
  31. package/dist/cache/relocate.d.ts +14 -14
  32. package/dist/cache/relocate.js +33 -25
  33. package/dist/cache/route.d.ts +32 -33
  34. package/dist/cache/route.js +94 -50
  35. package/dist/cache/store.d.ts +6 -0
  36. package/dist/cache/store.js +21 -1
  37. package/dist/cache/trace.d.ts +53 -14
  38. package/dist/cache/trace.js +70 -28
  39. package/dist/cli/index.js +8 -8
  40. package/dist/cli/init/engines.d.ts +1 -3
  41. package/dist/cli/init/engines.js +2 -2
  42. package/dist/cli/init/gateways.d.ts +1 -1
  43. package/dist/cli/init/gateways.js +11 -1
  44. package/dist/cli/init/sibling-versions.json +1 -1
  45. package/dist/cli/init/versions.d.ts +0 -8
  46. package/dist/cli/init/versions.js +1 -10
  47. package/dist/config/resolve.d.ts +1 -0
  48. package/dist/config/resolve.js +33 -1
  49. package/dist/engine/index.d.ts +2 -2
  50. package/dist/expect/async.js +2 -1
  51. package/dist/expect/poll-scope.d.ts +52 -0
  52. package/dist/expect/poll-scope.js +113 -0
  53. package/dist/expect/poll.js +31 -4
  54. package/dist/expect/values.js +11 -4
  55. package/dist/internal/abandoned.d.ts +11 -0
  56. package/dist/internal/abandoned.js +23 -0
  57. package/dist/internal/brands.d.ts +2 -0
  58. package/dist/internal/brands.js +2 -0
  59. package/dist/internal/client-identity.d.ts +9 -0
  60. package/dist/internal/client-identity.js +14 -0
  61. package/dist/internal/redact.d.ts +7 -4
  62. package/dist/internal/redact.js +116 -23
  63. package/dist/internal/time.d.ts +6 -0
  64. package/dist/internal/time.js +25 -13
  65. package/dist/internal/urls.d.ts +9 -5
  66. package/dist/internal/urls.js +19 -10
  67. package/dist/locator/engine.d.ts +12 -2
  68. package/dist/locator/engine.js +8 -3
  69. package/dist/locator/screen.js +28 -4
  70. package/dist/mcp/catalog.d.ts +3 -2
  71. package/dist/mcp/catalog.js +6 -6
  72. package/dist/mcp/session.js +1 -1
  73. package/dist/oauth/chatgpt.js +3 -23
  74. package/dist/oauth/cli.js +1 -0
  75. package/dist/oauth/copilot.d.ts +5 -4
  76. package/dist/oauth/copilot.js +80 -7
  77. package/dist/oauth/grok.js +2 -1
  78. package/dist/oauth/models.js +2 -1
  79. package/dist/oauth/opencode-console.d.ts +10 -0
  80. package/dist/oauth/opencode-console.js +133 -0
  81. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  82. package/dist/oauth/providers/github-copilot.js +77 -23
  83. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  84. package/dist/oauth/providers/opencode-console.js +148 -0
  85. package/dist/oauth/providers.d.ts +2 -6
  86. package/dist/oauth/providers.js +3 -7
  87. package/dist/oauth/responses.d.ts +9 -0
  88. package/dist/oauth/responses.js +23 -0
  89. package/dist/report/build.d.ts +26 -0
  90. package/dist/report/build.js +16 -8
  91. package/dist/report/failure-text.d.ts +6 -1
  92. package/dist/report/failure-text.js +14 -4
  93. package/dist/report/format.d.ts +10 -5
  94. package/dist/report/format.js +12 -5
  95. package/dist/report/junit.js +11 -12
  96. package/dist/report/list.d.ts +1 -0
  97. package/dist/report/list.js +38 -18
  98. package/dist/report/markdown.d.ts +1 -1
  99. package/dist/report/markdown.js +37 -14
  100. package/dist/report/repeats.d.ts +6 -1
  101. package/dist/report/repeats.js +12 -3
  102. package/dist/run/artifacts.d.ts +18 -0
  103. package/dist/run/artifacts.js +68 -1
  104. package/dist/run/execute.d.ts +9 -0
  105. package/dist/run/execute.js +49 -24
  106. package/dist/run/extended-fixtures.js +6 -2
  107. package/dist/run/failure-evidence.js +4 -2
  108. package/dist/run/fixtures.js +6 -8
  109. package/dist/run/in-process.d.ts +2 -0
  110. package/dist/run/in-process.js +1 -0
  111. package/dist/run/last-run.d.ts +42 -8
  112. package/dist/run/last-run.js +121 -14
  113. package/dist/run/realm.d.ts +4 -1
  114. package/dist/run/realm.js +20 -6
  115. package/dist/run/records.d.ts +16 -0
  116. package/dist/run/records.js +26 -0
  117. package/dist/run/retry.d.ts +3 -1
  118. package/dist/run/retry.js +4 -2
  119. package/dist/run/runner.d.ts +1 -1
  120. package/dist/run/runner.js +69 -30
  121. package/dist/run/serial.d.ts +2 -0
  122. package/dist/run/serial.js +8 -5
  123. package/dist/run/steps.d.ts +0 -6
  124. package/dist/run/steps.js +3 -20
  125. package/dist/run/worker/entry.js +5 -4
  126. package/dist/run/worker/protocol.d.ts +2 -0
  127. package/dist/run/worker/session.d.ts +2 -0
  128. package/dist/run/worker/session.js +1 -0
  129. package/dist/telemetry/events.js +1 -0
  130. package/dist/types.d.ts +9 -3
  131. package/docs/agents.mdx +6 -3
  132. package/docs/assertions.mdx +94 -0
  133. package/docs/authentication.mdx +3 -1
  134. package/docs/browser.mdx +42 -6
  135. package/docs/cache.mdx +64 -18
  136. package/docs/ci.mdx +11 -7
  137. package/docs/debugging.mdx +6 -3
  138. package/docs/executors.mdx +4 -2
  139. package/docs/github.mdx +12 -4
  140. package/docs/{writing-tests.mdx → goals.mdx} +21 -101
  141. package/docs/index.mdx +4 -4
  142. package/docs/integrations/kernel.mdx +1 -1
  143. package/docs/locators.mdx +64 -0
  144. package/docs/migrate/cypress.mdx +2 -2
  145. package/docs/migrate/maestro.mdx +2 -2
  146. package/docs/migrate/playwright.mdx +19 -16
  147. package/docs/migrate/selenium.mdx +2 -2
  148. package/docs/mobile.mdx +21 -4
  149. package/docs/models.mdx +53 -7
  150. package/docs/physical-devices.mdx +76 -0
  151. package/docs/quickstart.mdx +4 -3
  152. package/docs/reference/app.mdx +1 -1
  153. package/docs/reference/cli.mdx +30 -13
  154. package/docs/reference/config.mdx +25 -7
  155. package/docs/reference/engine.mdx +3 -3
  156. package/docs/reference/environment.mdx +8 -1
  157. package/docs/reference/errors.mdx +5 -5
  158. package/docs/reference/expect.mdx +34 -12
  159. package/docs/reference/mobile.mdx +23 -13
  160. package/docs/reference/reporters.mdx +8 -2
  161. package/docs/reference/screen.mdx +30 -7
  162. package/docs/reference/test.mdx +11 -4
  163. package/docs/reference/web.mdx +125 -21
  164. package/docs/security.mdx +26 -16
  165. package/docs/subscriptions.mdx +91 -12
  166. package/docs/telemetry.mdx +1 -1
  167. package/package.json +16 -3
  168. package/schema/report-v1.schema.json +14 -1
  169. package/skills/e2e/SKILL.md +2 -2
  170. package/skills/e2e/references/agent.md +22 -11
  171. package/skills/e2e/references/debugging.md +4 -4
  172. package/skills/e2e/references/running.md +13 -8
  173. package/skills/e2e/references/setup.md +36 -15
  174. package/skills/e2e/references/writing-tests.md +34 -17
@@ -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);
@@ -449,9 +539,12 @@ export class SecretLedger {
449
539
  * `FRAGMENT_LENGTH` characters, or half of a value shorter than twice that.
450
540
  * A cut is one position, so a boundary this short seldom matches plain text
451
541
  * 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
542
+ * a CSS `text-transform` shows it, and with every whitespace run on both
543
+ * sides read as one space (`readingKey`) and the value's leading whitespace
544
+ * dropped where whole-value matching trims it (`valuePattern`), as an
545
+ * engine that collapses text before cutting it shows it: the cut falls on
546
+ * text as the engine read it, before any serializer spells it. A case
547
+ * mapping that changes length (`ß` to `SS`) is not followed. The part is found before whole values are
455
548
  * rewritten, so a value that starts with another registered value is not
456
549
  * half rewritten as the shorter one, and an occurrence running into the
457
550
  * part joins its marker. Bound like `redact`.
@@ -463,7 +556,7 @@ export class SecretLedger {
463
556
  * becomes its marker. For a recording that keeps what an engine read raw
464
557
  * (a Playwright trace holds the page's cut text and selections), where a
465
558
  * value cut or selected partway through survives whole-value matching.
466
- * Matched as the value is written, in any case, like `redactCut`. A
559
+ * Matched in any case and with whitespace runs collapsed, like `redactCut`. A
467
560
  * base64 or base64url run that decodes to text holding a value or a
468
561
  * fragment is rewritten whole (a basic-auth header the engine sent). Bound
469
562
  * like `redact`.
@@ -30,6 +30,12 @@ export interface PollConditionOptions {
30
30
  * Polls a condition until it holds (or, when negated, until its negation has
31
31
  * held continuously for the negation grace window), throwing the caller's
32
32
  * error at the deadline.
33
+ *
34
+ * A negation holds from the moment the read that first saw it was issued, so
35
+ * a slow read counts toward the window. A budget shorter than the window
36
+ * still has to be satisfiable: the negation then only needs to hold for the
37
+ * whole budget, and passes at the deadline on what it has seen, since a read
38
+ * past the deadline has no budget left.
33
39
  */
34
40
  export declare function pollCondition(options: PollConditionOptions): Promise<void>;
35
41
  /** Races a promise against a timeout; on timeout invokes onTimeout to build the error. */
@@ -47,29 +47,41 @@ export const NEGATION_GRACE_MS = 1000;
47
47
  * Polls a condition until it holds (or, when negated, until its negation has
48
48
  * held continuously for the negation grace window), throwing the caller's
49
49
  * error at the deadline.
50
+ *
51
+ * A negation holds from the moment the read that first saw it was issued, so
52
+ * a slow read counts toward the window. A budget shorter than the window
53
+ * still has to be satisfiable: the negation then only needs to hold for the
54
+ * whole budget, and passes at the deadline on what it has seen, since a read
55
+ * past the deadline has no budget left.
50
56
  */
51
57
  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;
58
+ const { deadline, negated } = options;
59
+ const startedAt = Date.now();
60
+ const grace = Math.min(NEGATION_GRACE_MS, deadline.remaining(startedAt));
61
+ let readAt = startedAt;
62
+ let holdingSince;
63
+ const holds = (now) => holdingSince !== undefined && now - holdingSince >= grace;
56
64
  for (;;) {
57
65
  const value = await options.evaluate();
58
- if (!options.negated) {
66
+ if (!negated) {
59
67
  if (value === true)
60
68
  return;
61
69
  }
62
- else if (value === false) {
63
- negatedTrueSince ??= Date.now();
64
- if (Date.now() - negatedTrueSince >= grace)
65
- return;
66
- }
67
70
  else {
68
- negatedTrueSince = undefined;
71
+ holdingSince = value === false ? (holdingSince ?? readAt) : undefined;
69
72
  }
70
- if (options.deadline.expired())
73
+ const now = Date.now();
74
+ if (holds(now))
75
+ return;
76
+ if (deadline.expired(now))
71
77
  throw await options.onTimeout();
72
- await sleep(POLL_INTERVAL_MS, options.signal);
78
+ await sleep(negated ? Math.min(POLL_INTERVAL_MS, deadline.remaining(now)) : POLL_INTERVAL_MS, options.signal);
79
+ readAt = Date.now();
80
+ if (negated && deadline.expired(readAt)) {
81
+ if (holds(readAt))
82
+ return;
83
+ throw await options.onTimeout();
84
+ }
73
85
  }
74
86
  }
75
87
  /** 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
  }>;
@@ -213,9 +213,14 @@ export class LocatorEngine {
213
213
  }
214
214
  }
215
215
  }
216
- /** Reads one node for polling surfaces; returns null while zero matches. */
217
- async tryRead(expression, deadline) {
218
- return this.readOne(expression, deadline, 'wait');
216
+ /**
217
+ * Reads one node for polling surfaces; returns null while zero matches.
218
+ * A poll that waits for absence passes `missingFrame: 'empty'`: nothing
219
+ * under a frame that is not in the document is attached, so the sample is
220
+ * zero matches rather than a wait for the frame.
221
+ */
222
+ async tryRead(expression, deadline, missingFrame = 'wait') {
223
+ return this.readOne(expression, deadline, missingFrame);
219
224
  }
220
225
  /**
221
226
  * Reads the one current match at once, for `isVisible` and `isHidden`:
@@ -55,6 +55,27 @@ const TEXT_OPTION_KEYS = ['exact', 'visible'];
55
55
  const ROLE_FILTER_KEYS = [...TEXT_OPTION_KEYS, 'checked', 'disabled', 'selected', 'expanded', 'pressed', 'level'];
56
56
  /** The keys of `RoleOptions`: the accessible name and the filters. */
57
57
  const ROLE_OPTION_KEYS = ['name', ...ROLE_FILTER_KEYS];
58
+ /** The states `locator.waitFor` waits for, Playwright's four. */
59
+ const WAIT_FOR_STATES = ['attached', 'detached', 'visible', 'hidden'];
60
+ /** The states absence satisfies: a frame missing from the document reads as zero matches for them. */
61
+ const ABSENCE_STATES = new Set(['detached', 'hidden']);
62
+ /**
63
+ * Whether the one match, or its absence, is in `state`: `attached` is any
64
+ * match, `detached` none, `visible` a match that is not hidden, and `hidden`
65
+ * a hidden match or none.
66
+ */
67
+ function inWaitForState(node, state) {
68
+ switch (state) {
69
+ case 'attached':
70
+ return node !== null;
71
+ case 'detached':
72
+ return node === null;
73
+ case 'visible':
74
+ return isNodeVisible(node);
75
+ case 'hidden':
76
+ return !isNodeVisible(node);
77
+ }
78
+ }
58
79
  class ScreenImpl {
59
80
  context;
60
81
  scope;
@@ -361,7 +382,11 @@ class LocatorImpl extends ScreenImpl {
361
382
  return nodes.map((node) => normalizeText(node.text ?? ''));
362
383
  }
363
384
  async waitFor(options) {
364
- const state = options?.state ?? 'visible';
385
+ rejectUnknownOptions('waitFor', options, ['state', 'timeout']);
386
+ const state = options?.state === undefined ? 'visible' : options.state;
387
+ if (!WAIT_FOR_STATES.includes(state)) {
388
+ throw new TestError('INVALID_ARGUMENT', `waitFor state must be one of ${WAIT_FOR_STATES.join(', ')}, got ${JSON.stringify(state)}`);
389
+ }
365
390
  await this.context.steps.run('locator', 'locator.waitFor', `${this.label} → ${state}`, async () => {
366
391
  const { engine } = this.context;
367
392
  const deadline = engine.deadline(options?.timeout);
@@ -371,9 +396,8 @@ class LocatorImpl extends ScreenImpl {
371
396
  signal: engine.signal,
372
397
  negated: false,
373
398
  evaluate: async () => {
374
- const { node } = await engine.tryRead(this.expression, deadline);
375
- const visible = isNodeVisible(node);
376
- return state === 'visible' ? visible : !visible;
399
+ const { node } = await engine.tryRead(this.expression, deadline, ABSENCE_STATES.has(state) ? 'empty' : 'wait');
400
+ return inWaitForState(node, state);
377
401
  },
378
402
  onTimeout: () => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
379
403
  details: locatorDetails(this.expression, Date.now() - startedMs),
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { ToolSet } from 'ai';
14
14
  import type { StepExecutorContext } from '../agent/executor.ts';
15
+ import { type NodeRedaction } from '../agent/observation.ts';
15
16
  import type { ScreenPresenter } from '../agent/screen-update.ts';
16
17
  import type { LocatorExpression, SemanticNode, TargetSession } from '../engine/surface.ts';
17
18
  import type { LocatorEngine } from '../locator/engine.ts';
@@ -35,7 +36,7 @@ export interface CatalogOptions {
35
36
  /** The agent's project tools (`agents.<name>.tools`), served beside the built-in ones. */
36
37
  readonly tools: Readonly<Record<string, AgentTool>>;
37
38
  /** The attempt's secret ledger: what `locate` shows of a node passes through it, as `observe` does. */
38
- readonly redact: (text: string) => string;
39
+ readonly redaction: NodeRedaction;
39
40
  /** The session's recorder, when the engine records video. */
40
41
  readonly recorder: SessionRecorder | undefined;
41
42
  readonly warn: (message: string) => void;
@@ -60,4 +61,4 @@ export interface LocateQuery {
60
61
  /** Builds the expression and the matching `screen.*` call from the tool arguments. */
61
62
  export declare function locateQuery(args: LocateArgs): LocateQuery;
62
63
  /** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
63
- export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[], redact: (text: string) => string): string;
64
+ export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[], redaction: NodeRedaction): string;
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { projectTools } from '../agent/default-agent.js';
15
- import { projectTree } from '../agent/observation.js';
15
+ import { projectTree, redactNode } from '../agent/observation.js';
16
16
  import { GRAMMAR_TOOL_NAMES } from '../agent/action-names.js';
17
17
  import { createGrammarTools } from '../agent/primitives.js';
18
18
  import { ConfigurationError } from '../internal/errors.js';
@@ -34,7 +34,7 @@ export function createSessionCatalog(options) {
34
34
  const builtIn = {
35
35
  observe: fullObserveTool(context, screen),
36
36
  ...verbs,
37
- locate: locateTool(options.locator, options.session, options.redact),
37
+ locate: locateTool(options.locator, options.session, options.redaction),
38
38
  ...recording,
39
39
  };
40
40
  const defined = options.tools;
@@ -65,7 +65,7 @@ function fullObserveTool(context, screen) {
65
65
  };
66
66
  }
67
67
  /** The session's `locate`: a semantic locator tried against the live screen, with the verdict a test would get. */
68
- function locateTool(locator, session, redact) {
68
+ function locateTool(locator, session, redaction) {
69
69
  return {
70
70
  description: 'Try a semantic locator against the live screen before writing it into a test: screen.getByRole(role, name), getByText, getByLabel, getByPlaceholder, or getByTestId. Returns how many nodes match and which, plus the test code to use. Exactly one of role, text, label, placeholder, or testId; name narrows a role query. Matching is exact unless exact is false.',
71
71
  inputSchema: z.object({
@@ -84,7 +84,7 @@ function locateTool(locator, session, redact) {
84
84
  for (const ref of refs.slice(0, MAX_LOCATE_NODES)) {
85
85
  read.push(await session.read(ref, locator.operation()));
86
86
  }
87
- return describeLocate(query, refs.length, read, redact);
87
+ return describeLocate(query, refs.length, read, redaction);
88
88
  },
89
89
  };
90
90
  }
@@ -115,7 +115,7 @@ export function locateQuery(args) {
115
115
  return { expression: textQuery('placeholder', placeholder, { exact }, undefined), code: `screen.getByPlaceholder(${JSON.stringify(placeholder)}${options})` };
116
116
  }
117
117
  /** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
118
- export function describeLocate(query, count, nodes, redact) {
118
+ export function describeLocate(query, count, nodes, redaction) {
119
119
  const lines = [`${count === 1 ? '1 node matches' : `${count} nodes match`} ${describeExpression(query.expression)}.`];
120
120
  if (count === 1)
121
121
  lines.push(`Use: ${query.code}`);
@@ -124,7 +124,7 @@ export function describeLocate(query, count, nodes, redact) {
124
124
  else
125
125
  lines.push(`A test action on ${query.code} would fail with LOCATOR_AMBIGUOUS. Narrow it with a name, getByRole(role, name), .filter({ hasText }), .first(), or .nth(i), or scope it under a container.`);
126
126
  for (const node of nodes)
127
- lines.push(`- ${describeNode(projectTree(node, redact))}`);
127
+ lines.push(`- ${describeNode(projectTree(redactNode(node, redaction)))}`);
128
128
  if (count > nodes.length)
129
129
  lines.push(`- and ${count - nodes.length} more`);
130
130
  return lines.join('\n');
@@ -207,7 +207,7 @@ export class SessionHost {
207
207
  screen,
208
208
  session: attempt.session,
209
209
  tools: config.agent.tools,
210
- redact: attempt.agentRuntime.redact,
210
+ redaction: { redact: attempt.agentRuntime.redact, redactCut: attempt.agentRuntime.redactCut },
211
211
  recorder,
212
212
  locator: new LocatorEngine({
213
213
  session: attempt.session,
@@ -6,8 +6,10 @@
6
6
  */
7
7
  import { createOpenAI } from '@ai-sdk/openai';
8
8
  import { createOAuthFetch } from './fetch.js';
9
- import { USER_AGENT, loginHint } from './providers.js';
9
+ import { USER_AGENT } from '../internal/client-identity.js';
10
+ import { loginHint } from './providers.js';
10
11
  import { createCodexProvider } from './providers/openai.js';
12
+ import { withoutServerStorage } from './responses.js';
11
13
  import { defaultCredentialStore } from './store.js';
12
14
  export function chatgpt(modelId) {
13
15
  const fetch = createOAuthFetch(createCodexProvider(), {
@@ -18,25 +20,3 @@ export function chatgpt(modelId) {
18
20
  // The key header is removed per request; the value only satisfies the constructor.
19
21
  return withoutServerStorage(createOpenAI({ apiKey: 'oauth', fetch, name: 'chatgpt' }).responses(modelId));
20
22
  }
21
- /**
22
- * Tells the SDK what the Codex backend enforces: nothing is stored server
23
- * side. Believing storage is on, the SDK refers back to an earlier turn's
24
- * reasoning by id (`item_reference`), which the backend then cannot find; told
25
- * it is off, the SDK carries the encrypted reasoning itself.
26
- */
27
- function withoutServerStorage(model) {
28
- const storeOff = (options) => ({
29
- ...options,
30
- providerOptions: { ...options.providerOptions, openai: { ...options.providerOptions?.['openai'], store: false } },
31
- });
32
- return {
33
- specificationVersion: model.specificationVersion,
34
- provider: model.provider,
35
- modelId: model.modelId,
36
- get supportedUrls() {
37
- return model.supportedUrls;
38
- },
39
- doGenerate: (options) => model.doGenerate(storeOff(options)),
40
- doStream: (options) => model.doStream(storeOff(options)),
41
- };
42
- }
package/dist/oauth/cli.js CHANGED
@@ -121,6 +121,7 @@ async function signedIn() {
121
121
  const PROVIDER_HINTS = {
122
122
  openai: 'ChatGPT Plus/Pro, the Codex sign-in',
123
123
  'github-copilot': 'GitHub Copilot: OpenAI, Anthropic, Google, and SpaceXAI models',
124
+ 'opencode-console': 'your OpenCode Console workspace',
124
125
  spacexai: 'SuperGrok or X Premium+',
125
126
  };
126
127
  /** A terminal picker over providers; without a terminal the provider has to be named. */
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `copilot('claude-sonnet-5')`: a GitHub Copilot subscription as an AI SDK
3
- * model. Copilot serves OpenAI, Anthropic, Google, and SpaceXAI models over the
4
- * OpenAI chat protocol; the instance is `@ai-sdk/openai-compatible`'s chat
5
- * model at the Copilot API with a fetch that carries the stored login. An
6
- * enterprise login stored by `e2e login` routes to its own host.
3
+ * model. Copilot serves most of its models over the OpenAI chat protocol and
4
+ * some only over its Responses API; the instance asks the plan's model
5
+ * listing which on the first call and delegates to the matching model, so
6
+ * construction stays synchronous and touches the network only when a call
7
+ * does. An enterprise login stored by `e2e login` routes to its own host.
7
8
  */
8
9
  import type { LanguageModelV4 } from '@ai-sdk/provider';
9
10
  export declare function copilot(modelId: string): LanguageModelV4;