ravensight-playtest 0.3.0 → 0.3.2

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.
@@ -133,6 +133,190 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
133
133
  return { x: cx, y: cy };
134
134
  }
135
135
 
136
+ /** Roles whose current value the snapshot carries, read from the DOM. */
137
+ export const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton', 'menuitemcheckbox', 'menuitemradio']);
138
+
139
+ /** The longest value shown for a control; a longer one is cut with "...". */
140
+ export const MAX_VALUE_CHARS = 80;
141
+
142
+ /** How many controls on one screen have their value read; a typed field past it shows no value at all. */
143
+ export const MAX_VALUE_READS = 60;
144
+
145
+ /** What a password field's value is always shown as, empty or not. */
146
+ export const MASKED_VALUE = '(hidden)';
147
+
148
+ /**
149
+ * Roles whose snapshot text can be what the user typed. Their printed value is
150
+ * removed before anything is compacted, and a value is shown only when the DOM
151
+ * answered and said the field is not a password: Playwright's snapshot prints
152
+ * a password field's contents in clear, and 0.3.0 sent that to the model.
153
+ */
154
+ export const TYPED_VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton']);
155
+
156
+ /**
157
+ * A node body with the value the snapshot printed after its name removed:
158
+ * `textbox "Pass" [ref=e6]: hunter22` becomes `textbox "Pass" [ref=e6]`. A
159
+ * trailing `:` (children follow) is kept.
160
+ *
161
+ * @param {string} body
162
+ * @returns {string}
163
+ */
164
+ export function withoutPrintedValue(body) {
165
+ const match = String(body).match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(:?)(.*)$/s);
166
+ if (!match) return String(body);
167
+ return match[3].trim() ? match[1] : `${match[1]}${match[2]}`;
168
+ }
169
+
170
+ /**
171
+ * A control's current state as the snapshot shows it: `value="..."` for a
172
+ * field or a slider, `checked` or `not checked` for a checkbox, radio or
173
+ * switch, `selected option="..."` for a select. A password is masked, and a
174
+ * value over {@link MAX_VALUE_CHARS} is cut.
175
+ *
176
+ * @param {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}} state
177
+ * @returns {string|null}
178
+ */
179
+ export function formatControlState(state) {
180
+ if (!state || typeof state !== 'object') return null;
181
+ const quote = value => {
182
+ let text = String(value == null ? '' : value);
183
+ if (text.length > MAX_VALUE_CHARS) text = `${text.slice(0, MAX_VALUE_CHARS - 3)}...`;
184
+ return JSON.stringify(text);
185
+ };
186
+ if (state.kind === 'check') return state.checked === 'mixed' ? 'partly checked' : state.checked ? 'checked' : 'not checked';
187
+ if (state.kind === 'select') return `selected option=${quote((state.selected || []).join(', '))}`;
188
+ if (state.kind === 'value') return state.password ? `value=${MASKED_VALUE}` : `value=${quote(state.value)}`;
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Read a control's state in the page. Runs in the browser, serialised by
194
+ * Playwright, so it must not close over anything.
195
+ *
196
+ * @param {Element} el
197
+ * @returns {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}|null}
198
+ */
199
+ function readControlState(el) {
200
+ if (!el) return null;
201
+ const tag = String(el.tagName || '').toLowerCase();
202
+ const type = String(el.getAttribute('type') || '').toLowerCase();
203
+ const role = String(el.getAttribute('role') || '').toLowerCase();
204
+ if (tag === 'input' && (type === 'checkbox' || type === 'radio')) return { kind: 'check', checked: el.indeterminate ? 'mixed' : Boolean(el.checked) };
205
+ if (tag === 'select') return { kind: 'select', selected: Array.from(el.selectedOptions || []).map(option => String(option.label || option.textContent || '').trim()) };
206
+ if (tag === 'input' || tag === 'textarea') return { kind: 'value', value: String(el.value == null ? '' : el.value), password: tag === 'input' && type === 'password' };
207
+ const ariaChecked = el.getAttribute('aria-checked');
208
+ if (ariaChecked !== null || ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'].includes(role)) {
209
+ return { kind: 'check', checked: ariaChecked === 'mixed' ? 'mixed' : ariaChecked === 'true' };
210
+ }
211
+ if (el.isContentEditable) return { kind: 'value', value: String(el.innerText || '').replace(/\s+/g, ' ').trim() };
212
+ const now = el.getAttribute('aria-valuetext') || el.getAttribute('aria-valuenow');
213
+ if (now !== null) return { kind: 'value', value: String(now) };
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * The refs of the controls in an AI mode snapshot whose value is worth
219
+ * reading, in page order and bounded by {@link MAX_VALUE_READS}.
220
+ *
221
+ * @param {string} text
222
+ * @returns {Array<string>}
223
+ */
224
+ export function valueRefs(text) {
225
+ const refs = [];
226
+ for (const line of String(text == null ? '' : text).split('\n')) {
227
+ const raw = unquoteKey(line.trim().replace(/^- /, ''));
228
+ const role = (raw.match(/^([a-z]+)/) || [])[1] || '';
229
+ if (!VALUE_ROLES.has(role)) continue;
230
+ const ref = (raw.match(REF_ATTRIBUTE) || [])[1];
231
+ if (ref && !refs.includes(ref)) refs.push(ref);
232
+ if (refs.length >= MAX_VALUE_READS) break;
233
+ }
234
+ return refs;
235
+ }
236
+
237
+ /** How long reading one control's value may take before it is skipped. */
238
+ export const VALUE_READ_TIMEOUT_MS = 1000;
239
+
240
+ /**
241
+ * Read the current state of every control in a snapshot from the DOM, by
242
+ * ref. A control that cannot answer (gone, detached, a frame) is left out
243
+ * and shows what the snapshot printed.
244
+ *
245
+ * @param {any} page
246
+ * @param {string} text an AI mode snapshot
247
+ * @returns {Promise<Map<string, Object>>}
248
+ */
249
+ export async function readControlValues(page, text) {
250
+ const values = new Map();
251
+ const refs = valueRefs(text);
252
+ if (refs.length === 0 || !page || typeof page.locator !== 'function') return values;
253
+ await Promise.all(refs.map(async ref => {
254
+ try {
255
+ const locator = page.locator(`aria-ref=${ref}`);
256
+ if (typeof locator.evaluate !== 'function') return;
257
+ const state = await locator.evaluate(readControlState, null, { timeout: VALUE_READ_TIMEOUT_MS });
258
+ if (state && typeof state === 'object') values.set(ref, state);
259
+ } catch {
260
+ // Not answerable now; the snapshot's own text stands for it.
261
+ }
262
+ }));
263
+ return values;
264
+ }
265
+
266
+ /**
267
+ * What a field holds, read in the page. Runs in the browser, serialised by
268
+ * Playwright, so it must not close over anything.
269
+ *
270
+ * @param {Element} el
271
+ * @returns {string|null}
272
+ */
273
+ function readFieldValue(el) {
274
+ if (!el) return null;
275
+ if ('value' in el && typeof el.value === 'string') return el.value;
276
+ if (el.isContentEditable) return String(el.innerText || '');
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * The value a typing target holds, for `changed` on a `type` action. It is
282
+ * compared and never shown, so a password field's value is read like any
283
+ * other and goes no further than this comparison. Null when the field cannot
284
+ * answer (gone, nothing focused, an older Playwright).
285
+ *
286
+ * @param {any} locator
287
+ * @returns {Promise<string|null>}
288
+ */
289
+ export async function fieldValue(locator) {
290
+ if (!locator || typeof locator.evaluate !== 'function') return null;
291
+ try {
292
+ const value = await locator.evaluate(readFieldValue, null, { timeout: VALUE_READ_TIMEOUT_MS });
293
+ return typeof value === 'string' ? value : null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ /** How long `act` waits once for a late change (a navigation still starting) before it says the screen did not change. */
300
+ export const SETTLE_MS = 300;
301
+
302
+ /**
303
+ * Whether an action changed anything: the URL, the document (a navigation or
304
+ * a reload), the compacted tree (which carries every field's value), or for
305
+ * `type`, the field's own value before and after. Any one is a change. The
306
+ * Texas round logged a click that navigated, and typing into fields, as
307
+ * `changed: false`, and the action log feeds the downgrade rule.
308
+ *
309
+ * @param {{urlBefore?: string|null, urlAfter?: string|null, tokenBefore?: string|null, tokenAfter?: string|null, hashBefore?: string|null, hashAfter?: string|null, valueBefore?: string|null, valueAfter?: string|null}} facts
310
+ * @returns {boolean}
311
+ */
312
+ export function computeChanged(facts) {
313
+ const differs = (a, b) => a !== null && a !== undefined && b !== null && b !== undefined && a !== b;
314
+ return differs(facts.urlBefore, facts.urlAfter)
315
+ || differs(facts.tokenBefore, facts.tokenAfter)
316
+ || (facts.hashAfter !== undefined && facts.hashAfter !== facts.hashBefore)
317
+ || differs(facts.valueBefore, facts.valueAfter);
318
+ }
319
+
136
320
  /**
137
321
  * The kept nodes of an aria snapshot with the depth each one had, or null for
138
322
  * text that is not an aria snapshot. The depth is not shown to the model; it
@@ -152,7 +336,7 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
152
336
  * @param {{viewport?: {width: number, height: number}}} [options]
153
337
  * @returns {Array<{depth: number, line: string, hashLine: string, body: string, role: string, name: string|null, ref: string|null, centre: Object|null, hint: string|null, key: string|null, duplicate: boolean}>|null}
154
338
  */
155
- export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
339
+ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT, values = null } = {}) {
156
340
  const value = String(text == null ? '' : text);
157
341
  const lines = value.split('\n');
158
342
  if (!lines.some(line => /^\s*- /.test(line))) return null;
@@ -170,7 +354,9 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
170
354
  const ref = (raw.match(REF_ATTRIBUTE) || [])[1] || null;
171
355
  const boxMatch = raw.match(BOX_ATTRIBUTE);
172
356
  const box = boxMatch ? boxMatch.slice(1, 5).map(Number) : null;
173
- const body = raw.replace(NOISE_ATTRIBUTE, '');
357
+ // A field's printed value goes before anything else reads the line: only
358
+ // a value the DOM confirmed (and never a password) is shown, below.
359
+ const body = TYPED_VALUE_ROLES.has(role) ? withoutPrintedValue(raw.replace(NOISE_ATTRIBUTE, '')) : raw.replace(NOISE_ATTRIBUTE, '');
174
360
  ancestors.push({ depth, role, name });
175
361
  const named = /^[a-z]+ "[^"]*"/.test(body);
176
362
  const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
@@ -180,11 +366,13 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
180
366
  const hint = container
181
367
  ? (container.name ? `${container.name}${LANDMARK_LABELS[container.role] && container.role !== 'region' ? ` ${LANDMARK_LABELS[container.role]}` : ''}` : LANDMARK_LABELS[container.role])
182
368
  : null;
369
+ const state = interactive && ref && values && typeof values.get === 'function' && VALUE_ROLES.has(role) ? values.get(ref) : null;
183
370
  kept.push({
184
371
  depth,
185
372
  body,
186
373
  role,
187
374
  name,
375
+ state: state ? formatControlState(state) : null,
188
376
  ref: interactive ? ref : null,
189
377
  centre: interactive ? visibleCentre(box, viewport) : null,
190
378
  hint,
@@ -199,8 +387,15 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
199
387
  for (const node of kept) {
200
388
  node.duplicate = Boolean(node.key) && counts.get(node.key) > 1;
201
389
  const split = node.body.match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(.*)$/s) || [null, node.body, ''];
202
- const head = split[1];
203
- const tail = split[2];
390
+ let head = split[1];
391
+ let tail = split[2];
392
+ if (node.state) {
393
+ // The value read from the DOM replaces whatever the snapshot printed
394
+ // after the name and its checked attribute, so there is one answer, and
395
+ // it is the one the page holds.
396
+ head = head.replace(/ \[(?:checked|selected)(?:=[^\]]*)?\]/g, '');
397
+ tail = ` ${node.state}`;
398
+ }
204
399
  const refPart = node.ref ? ` [${node.ref}]` : '';
205
400
  const hintPart = node.duplicate && node.hint ? ` (in ${node.hint})` : '';
206
401
  const centrePart = node.centre ? ` @(${node.centre.x},${node.centre.y})` : '';
@@ -225,6 +420,13 @@ export const COLLAPSE_MIN_RUN = 4;
225
420
  /** How many of a collapsed run are still shown, so the model sees what the items look like. */
226
421
  export const COLLAPSE_KEEP = 2;
227
422
 
423
+ /**
424
+ * A run of this many look-alike siblings or more is collapsed even when the
425
+ * page is under the cap: twenty school cards cost every turn they stay in
426
+ * context and tell the persona nothing the first two and a count do not.
427
+ */
428
+ export const COLLAPSE_ALWAYS_RUN = 20;
429
+
228
430
  /**
229
431
  * The shape of one node, without the words that make it unique: its role,
230
432
  * its state attributes, whether it has a name, whether the name carries
@@ -249,9 +451,15 @@ function nodeShape(body) {
249
451
  * says how many more there are.
250
452
  *
251
453
  * @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
252
- * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
454
+ * @param {{minRun?: number, keepNamedTwins?: boolean}} [options] the shortest
455
+ * run that collapses; with `keepNamedTwins`, a run in which two units share
456
+ * a control's role and name is never collapsed (the 0.3.1 rule that
457
+ * same-named controls are shown together, each with its ref)
458
+ * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number, longest: number}}
459
+ * `longest` is the most look-alike siblings in one run, collapsed or not, and
460
+ * `longestTwins` whether two units of that run share a control's role and name
253
461
  */
254
- export function collapseRepeatedSiblings(nodes) {
462
+ export function collapseRepeatedSiblings(nodes, { minRun = COLLAPSE_MIN_RUN, keepNamedTwins = false } = {}) {
255
463
  const keyCounts = new Map();
256
464
  for (const node of nodes) if (node.key) keyCounts.set(node.key, (keyCounts.get(node.key) || 0) + 1);
257
465
  const unitEnd = start => {
@@ -262,6 +470,8 @@ export function collapseRepeatedSiblings(nodes) {
262
470
  const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
263
471
  const entries = [];
264
472
  let collapsed = 0;
473
+ let longest = nodes.length > 0 ? 1 : 0;
474
+ let longestTwins = false;
265
475
  let i = 0;
266
476
  while (i < nodes.length) {
267
477
  const units = [[i, unitEnd(i)]];
@@ -273,7 +483,23 @@ export function collapseRepeatedSiblings(nodes) {
273
483
  units.push([next, end]);
274
484
  next = end;
275
485
  }
276
- if (units.length >= COLLAPSE_MIN_RUN) {
486
+ let runHasTwins = false;
487
+ if (units.length > 1) {
488
+ const seen = new Map();
489
+ for (const node of nodes.slice(i, next)) if (node.key) seen.set(node.key, (seen.get(node.key) || 0) + 1);
490
+ runHasTwins = [...seen.values()].some(count => count > 1);
491
+ }
492
+ if (units.length > longest) {
493
+ longest = units.length;
494
+ longestTwins = runHasTwins;
495
+ }
496
+ const twinsInRun = keepNamedTwins && units.length >= minRun && runHasTwins;
497
+ if (twinsInRun) {
498
+ // Under the cap there is room for all of them, and collapsing would
499
+ // hide controls that share a name with one still shown.
500
+ for (const node of nodes.slice(i, next)) entries.push({ line: node.line, hashLine: node.hashLine, role: node.role, key: node.key });
501
+ i = next;
502
+ } else if (units.length >= minRun) {
277
503
  // A node whose role and name also appear outside this run is never
278
504
  // hidden in the count: two buttons called "Run the week", one in the
279
505
  // header and one in the main panel, both stay visible with their refs.
@@ -307,14 +533,17 @@ export function collapseRepeatedSiblings(nodes) {
307
533
  i += 1;
308
534
  }
309
535
  }
310
- return { entries, collapsed };
536
+ return { entries, collapsed, longest, longestTwins };
311
537
  }
312
538
 
313
539
  /**
314
540
  * Fit a compacted aria snapshot under the web ceiling without losing the
315
541
  * part of the page the persona has to act on.
316
542
  *
317
- * Under the cap nothing changes. Over it, in this order: interactive nodes
543
+ * Under the cap only runs of {@link COLLAPSE_ALWAYS_RUN} or more look-alike
544
+ * siblings collapse, and never a run in which two units share a control's
545
+ * name (22 "View" buttons are all shown, each with its ref); everything else
546
+ * is shown as it is. Over it, in this order: interactive nodes
318
547
  * are what is kept (the last tenth of them on the page first, since that is
319
548
  * where a form after a long list sits, then inputs, then buttons and links,
320
549
  * then any role with a name); runs of look-alike siblings collapse to their
@@ -328,20 +557,24 @@ export function collapseRepeatedSiblings(nodes) {
328
557
  * @param {{max?: number, previous?: Set<string>|null}} [options]
329
558
  * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
330
559
  */
331
- export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT } = {}) {
332
- const nodes = compactAriaNodes(text, { viewport });
560
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT, values = null } = {}) {
561
+ const nodes = compactAriaNodes(text, { viewport, values });
333
562
  if (nodes === null) return null;
334
563
  const lines = nodes.map(node => node.hashLine);
335
564
  const full = nodes.map(node => node.line).join('\n');
336
- if (full.length <= max) return { text: full, lines, nodes };
337
-
338
- const { entries, collapsed } = collapseRepeatedSiblings(nodes);
339
565
  const header = (left, out) => {
340
566
  const parts = [];
341
567
  if (left > 0) parts.push(`${left} similar items collapsed`);
342
568
  if (out > 0) parts.push(`${out} lines left out to fit`);
343
569
  return parts.length > 0 ? `[${parts.join('; ')}]` : '';
344
570
  };
571
+ if (full.length <= max) {
572
+ const long = collapseRepeatedSiblings(nodes, { minRun: COLLAPSE_ALWAYS_RUN, keepNamedTwins: true });
573
+ if (long.collapsed === 0) return { text: full, lines, nodes };
574
+ return { text: `${header(long.collapsed, 0)}\n${long.entries.map(entry => entry.line).join('\n')}`, lines, nodes };
575
+ }
576
+
577
+ const { entries, collapsed } = collapseRepeatedSiblings(nodes);
345
578
  const collapsedText = entries.map(entry => entry.line).join('\n');
346
579
  const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
347
580
  if (withHeader.length <= max) return { text: withHeader, lines, nodes };
@@ -397,6 +630,113 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
397
630
  return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
398
631
  }
399
632
 
633
+ /**
634
+ * Read the screen the way a persona's observation does: the page level AI
635
+ * mode snapshot with each control's value from the DOM, the body snapshot on
636
+ * an older Playwright, and the innerText fallback for a thin page. `check`
637
+ * measures a first screen with this too, so what it reports is what a
638
+ * persona would be shown.
639
+ *
640
+ * @param {any} current a Playwright page
641
+ * @param {{innerTextTimeoutMs?: number}} [options] how long the innerText fallback may take
642
+ * @returns {Promise<{text: string, aria: boolean, values: Map<string, Object>|null}>}
643
+ * the screen text, and whether it is the aria snapshot (compacted) or the
644
+ * innerText fallback (never compacted: a bullet list in the page is not
645
+ * snapshot structure)
646
+ */
647
+ export async function readSnapshot(current, { innerTextTimeoutMs = INNER_TEXT_TIMEOUT_MS } = {}) {
648
+ return readSnapshotWith(current, innerTextTimeoutMs);
649
+ }
650
+
651
+ /** How long the innerText fallback may take: a page whose script is stuck cannot hang a turn. */
652
+ export const INNER_TEXT_TIMEOUT_MS = 5000;
653
+
654
+ /**
655
+ * A promise that rejects after `ms` if the given one has not settled.
656
+ *
657
+ * @param {Promise<any>} promise
658
+ * @param {number} ms
659
+ * @returns {Promise<any>}
660
+ */
661
+ export function withTimeout(promise, ms) {
662
+ let timer;
663
+ const limit = new Promise((resolve, reject) => {
664
+ timer = setTimeout(() => reject(new Error(`Timeout ${ms}ms exceeded`)), ms);
665
+ });
666
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer));
667
+ }
668
+
669
+ async function readSnapshotWith(current, innerTextTimeoutMs) {
670
+ let text = '';
671
+ let aria = true;
672
+ let values = null;
673
+ const body = current.locator('body');
674
+ // The page level AI mode snapshot carries a ref per element, which
675
+ // `aria-ref=e12` resolves, and each element's box. Older Playwright
676
+ // builds have neither, and the body snapshot is the fallback.
677
+ if (typeof current.ariaSnapshot === 'function') {
678
+ try {
679
+ text = await current.ariaSnapshot({ mode: 'ai', boxes: true, timeout: ACTION_TIMEOUT_MS });
680
+ } catch {
681
+ text = '';
682
+ }
683
+ // What each field, box and select holds now, from the DOM rather than
684
+ // the snapshot's text: the Texas run typed a coach name twice and
685
+ // filed a bug because the observation kept showing the field empty.
686
+ if (text) values = await readControlValues(current, text);
687
+ }
688
+ if (!text && typeof body.ariaSnapshot === 'function') {
689
+ try {
690
+ text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
691
+ } catch {
692
+ text = '';
693
+ }
694
+ }
695
+ if (text.trim().length < THIN_OBSERVATION_CHARS) {
696
+ try {
697
+ const inner = await withTimeout(current.evaluate(() => {
698
+ const el = globalThis.document && globalThis.document.body;
699
+ return el ? el.innerText : '';
700
+ }), innerTextTimeoutMs);
701
+ if (String(inner).trim().length > text.trim().length) {
702
+ text = String(inner);
703
+ aria = false;
704
+ }
705
+ } catch {
706
+ // A page mid-navigation has no body to read. The thin observation
707
+ // is the honest answer, and the driver's screenshot fallback covers it.
708
+ }
709
+ }
710
+ return { text, aria, values };
711
+ }
712
+
713
+ /**
714
+ * How heavy a screen is for a persona: the whole compacted text (before any
715
+ * fit), what the persona is actually shown after the fit, and the longest run
716
+ * of look-alike siblings on it.
717
+ *
718
+ * @param {{text: string, aria: boolean, values?: Map<string, Object>|null}} snap from {@link readSnapshot}
719
+ * @param {{max?: number, viewport?: {width: number, height: number}}} [options]
720
+ * @returns {{aria: boolean, fullChars: number, shownChars: number, longestRun: number, longestRunCollapsed: boolean}}
721
+ * `longestRunCollapsed` says whether the persona sees that run as two and a
722
+ * count, decided the way the driver decides it
723
+ */
724
+ export function measureSnapshot(snap, { max = WEB_OBSERVATION_MAX_CHARS, viewport = DEFAULT_VIEWPORT } = {}) {
725
+ const text = String((snap && snap.text) || '');
726
+ const fitted = snap && snap.aria ? fitAriaSnapshot(text, { max, viewport, values: snap.values || null }) : null;
727
+ if (!fitted) {
728
+ return { aria: false, fullChars: text.length, shownChars: truncateObservation(text, max).length, longestRun: 0, longestRunCollapsed: false };
729
+ }
730
+ const full = fitted.nodes.map(node => node.line).join('\n');
731
+ const runs = collapseRepeatedSiblings(fitted.nodes);
732
+ // The driver's own decision: over the cap a run of four or more collapses;
733
+ // under it, only a run of COLLAPSE_ALWAYS_RUN or more with no two items
734
+ // sharing a control's name. The rest is shown in full.
735
+ const overCap = full.length > max;
736
+ const longestRunCollapsed = overCap ? runs.longest >= COLLAPSE_MIN_RUN : runs.longest >= COLLAPSE_ALWAYS_RUN && !runs.longestTwins;
737
+ return { aria: true, fullChars: full.length, shownChars: fitted.text.length, longestRun: runs.longest, longestRunCollapsed };
738
+ }
739
+
400
740
  /**
401
741
  * Playwright writes a node key as a single quoted YAML scalar when the name
402
742
  * contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
@@ -628,6 +968,73 @@ export function ambiguityMessage(target, candidates, count = null) {
628
968
  : `${total} elements match ${JSON.stringify(String(target))}; take a snapshot and use the ref of the one you mean`;
629
969
  }
630
970
 
971
+ /**
972
+ * What a target copied from the snapshot means. A persona pastes a whole line
973
+ * as often as a ref: the Texas round sent `button "Run the week" @(1003,304)`
974
+ * as a target, and it was searched for as text for 15 seconds, twice. Any
975
+ * subset of `role "name" [eN] @(x,y)` (with or without the leading `- `, a
976
+ * `(in header)` hint or a `: value` tail) is read into its parts.
977
+ *
978
+ * The `[eN]` and `@(x,y)` are read only after the `role "name"` part, so a
979
+ * name that itself contains one (`link "Footnote [e3]"`, `button "Teleport
980
+ * @(3, 4)"`) is a name, not a ref or a point. A bare `e4` is marked `bare`:
981
+ * the caller treats it as text when a node on screen is named exactly that (a
982
+ * chess square), and as a ref otherwise.
983
+ *
984
+ * Returns null for a target that carries neither a ref nor coordinates, which
985
+ * is the ordinary `button "Start"` or visible text case.
986
+ *
987
+ * @param {string} target
988
+ * @returns {{ref: string|null, x: number|null, y: number|null, role: string|null, name: string|null, bare?: boolean}|null}
989
+ */
990
+ export function parseSnapshotTarget(target) {
991
+ const value = String(target == null ? '' : target).trim().replace(/^- /, '');
992
+ if (REF_PATTERN.test(value)) return { ref: value, x: null, y: null, role: null, name: null, bare: true };
993
+ const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"/);
994
+ const bareRole = roleAndName ? null : value.match(/^([a-z]+)(?=\s*(?:\[|@|$))/);
995
+ const rest = roleAndName ? value.slice(roleAndName[0].length) : value;
996
+ const ref = (rest.match(/\[(e\d{1,6})\]/) || [])[1] || null;
997
+ const at = rest.match(/@\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
998
+ if (!ref && !at) return null;
999
+ return {
1000
+ ref,
1001
+ x: at ? Math.round(Number(at[1])) : null,
1002
+ y: at ? Math.round(Number(at[2])) : null,
1003
+ role: roleAndName ? roleAndName[1] : bareRole ? bareRole[1] : null,
1004
+ name: roleAndName ? roleAndName[2] : null
1005
+ };
1006
+ }
1007
+
1008
+ /**
1009
+ * Whether a target is text the last snapshot shows: a node's name or its
1010
+ * inline text, ignoring case. Used to refuse at once a target that is not a
1011
+ * ref, coordinates or anything on screen, rather than search for it for the
1012
+ * whole action timeout.
1013
+ *
1014
+ * @param {Array<Object>|null} nodes
1015
+ * @param {string} target
1016
+ * @returns {boolean}
1017
+ */
1018
+ export function textOnScreen(nodes, target) {
1019
+ const value = String(target == null ? '' : target).trim().toLowerCase();
1020
+ if (!value) return false;
1021
+ return nodes.some(node => String(node.name || '').toLowerCase().includes(value) || String(node.body || '').toLowerCase().includes(value));
1022
+ }
1023
+
1024
+ /** What a target that is none of a ref, coordinates or visible text is told. */
1025
+ export const NOT_A_TARGET_MESSAGE = 'is not a ref, coordinates or text on the current screen; use the ref the snapshot shows, such as e12';
1026
+
1027
+ /**
1028
+ * A locator narrowed to visible elements when this Playwright can: a mobile
1029
+ * only duplicate hidden by CSS must never be the one clicked.
1030
+ *
1031
+ * @param {any} locator
1032
+ * @returns {any}
1033
+ */
1034
+ export function visibleOnly(locator) {
1035
+ return locator && typeof locator.filter === 'function' ? locator.filter({ visible: true }) : locator;
1036
+ }
1037
+
631
1038
  /**
632
1039
  * Turn what the model wrote into a Playwright locator.
633
1040
  *
@@ -650,11 +1057,19 @@ export function resolveLocator(page, target) {
650
1057
  // escape when it is spliced back into the `role=` engine. A target whose
651
1058
  // name really does contain a quote falls through to the text search below.
652
1059
  const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
653
- if (roleAndName) return page.locator(`role=${roleAndName[1]}[name="${roleAndName[2]}"]`);
1060
+ // No first(): two visible matches are Playwright's strict mode failure,
1061
+ // which is answered with the candidates rather than guessed.
1062
+ if (roleAndName) return visibleOnly(page.locator(`role=${roleAndName[1]}[name="${roleAndName[2]}"]`));
654
1063
 
655
1064
  if (/^[#.[]/.test(value) || /^[a-z][a-z0-9-]*(\[|\.|#|:| >)/i.test(value)) return page.locator(value);
656
1065
 
657
- return page.getByText(value, { exact: false }).first();
1066
+ // Hidden matches are skipped: a page with a mobile only copy of a button
1067
+ // hidden by CSS put the hidden one first, and the click waited on it.
1068
+ return firstOf(visibleOnly(page.getByText(value, { exact: false })));
1069
+ }
1070
+
1071
+ function firstOf(locator) {
1072
+ return locator && typeof locator.first === 'function' ? locator.first() : locator;
658
1073
  }
659
1074
 
660
1075
  /**
@@ -674,7 +1089,7 @@ function markDocument() {
674
1089
  *
675
1090
  * @param {{token: string, runId: string, jobId: string, persona: string}} identity
676
1091
  */
677
- function injectPlaytestIdentity(identity) {
1092
+ export function injectPlaytestIdentity(identity) {
678
1093
  window.__RAVENSIGHT_PLAYTEST__ = identity;
679
1094
  try {
680
1095
  localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
@@ -684,6 +1099,15 @@ function injectPlaytestIdentity(identity) {
684
1099
  }
685
1100
  }
686
1101
 
1102
+ /**
1103
+ * The identity `check` hands a page it loads only to measure. The token is
1104
+ * not a real run's, so a build shipping the Ravensight web SDK presents it on
1105
+ * `POST /session` and the server refuses the session outright (a playtest
1106
+ * claim that does not check out is a 403, never a real session): `check`
1107
+ * is never recorded as a player.
1108
+ */
1109
+ export const CHECK_PLAYTEST_IDENTITY = Object.freeze({ token: 'check', runId: null, jobId: null, persona: 'check' });
1110
+
687
1111
  /**
688
1112
  * @param {Object} opts
689
1113
  * @param {(specifier: string) => Promise<any>} [opts.importer] test seam
@@ -721,6 +1145,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
721
1145
  // compared with the current one before any ref is used.
722
1146
  let lastNodesDocument = null;
723
1147
  const consoleBuffer = [];
1148
+ /** Recent failed requests, newest last, bounded: what a console line is matched against. */
1149
+ const networkFailures = [];
724
1150
 
725
1151
  /** @returns {any} */
726
1152
  function requirePage() {
@@ -729,48 +1155,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
729
1155
  }
730
1156
 
731
1157
  /**
732
- * @returns {Promise<{text: string, aria: boolean}>} the screen text, and
733
- * whether it is the aria snapshot (compacted) or the innerText fallback
734
- * (never compacted: a bullet list in the page is not snapshot structure)
1158
+ * @returns {Promise<{text: string, aria: boolean}>} see {@link readSnapshot}
735
1159
  */
736
1160
  async function snapshotText() {
737
- const current = requirePage();
738
- let text = '';
739
- let aria = true;
740
- const body = current.locator('body');
741
- // The page level AI mode snapshot carries a ref per element, which
742
- // `aria-ref=e12` resolves, and each element's box. Older Playwright
743
- // builds have neither, and the body snapshot is the fallback.
744
- if (typeof current.ariaSnapshot === 'function') {
745
- try {
746
- text = await current.ariaSnapshot({ mode: 'ai', boxes: true, timeout: ACTION_TIMEOUT_MS });
747
- } catch {
748
- text = '';
749
- }
750
- }
751
- if (!text && typeof body.ariaSnapshot === 'function') {
752
- try {
753
- text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
754
- } catch {
755
- text = '';
756
- }
757
- }
758
- if (text.trim().length < THIN_OBSERVATION_CHARS) {
759
- try {
760
- const inner = await current.evaluate(() => {
761
- const el = globalThis.document && globalThis.document.body;
762
- return el ? el.innerText : '';
763
- });
764
- if (String(inner).trim().length > text.trim().length) {
765
- text = String(inner);
766
- aria = false;
767
- }
768
- } catch {
769
- // A page mid-navigation has no body to read. The thin observation
770
- // is the honest answer, and the screenshot fallback below covers it.
771
- }
772
- }
773
- return { text, aria };
1161
+ return readSnapshot(requirePage());
774
1162
  }
775
1163
 
776
1164
  /**
@@ -783,7 +1171,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
783
1171
  */
784
1172
  function shownText(snap) {
785
1173
  if (snap.aria) {
786
- const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize });
1174
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize, values: snap.values });
787
1175
  if (fitted) return fitted;
788
1176
  }
789
1177
  return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
@@ -818,11 +1206,19 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
818
1206
  */
819
1207
  async function typeText(current, action) {
820
1208
  const text = String(action.text == null ? '' : action.text);
821
- const field = action.target || action.ref ? await targetFor(current, action) : null;
1209
+ let field = action.target || action.ref ? await targetFor(current, action) : null;
1210
+ if (field && field.point) {
1211
+ // Coordinates copied from the snapshot: focus what is there, then type
1212
+ // into whatever took the focus.
1213
+ await current.mouse.click(field.point.x, field.point.y);
1214
+ field = null;
1215
+ }
1216
+ const subject = field || current.locator('*:focus');
1217
+ const before = await fieldValue(subject);
822
1218
  if (action.replace) {
823
1219
  try {
824
- await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
825
- return;
1220
+ await subject.fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
1221
+ return { before, after: await fieldValue(subject) };
826
1222
  } catch {
827
1223
  // Not something fill can write to. Focus it if it was named, then
828
1224
  // clear it the way a player on this machine would.
@@ -834,29 +1230,51 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
834
1230
  await field.click({ timeout: ACTION_TIMEOUT_MS });
835
1231
  }
836
1232
  if (text) await current.keyboard.type(text, { delay: 20 });
1233
+ return { before, after: await fieldValue(subject) };
837
1234
  }
838
1235
 
839
1236
  /**
840
- * The locator a click or a typing target means. A ref is preferred and
841
- * checked against the page; locator code is refused at once, with no wait;
842
- * a name that matches more than one interactive node on the last screen is
843
- * answered with the candidates and their refs rather than guessed.
1237
+ * The ref locator, checked: a well formed ref, from the current document,
1238
+ * and on the screen now.
1239
+ */
1240
+ async function refLocator(current, raw) {
1241
+ const ref = String(raw).trim();
1242
+ if (!REF_PATTERN.test(ref)) throw new TargetError(`a ref looks like e12, as the snapshot prints it; ${JSON.stringify(ref)} is not one`);
1243
+ await assertSameDocument(current);
1244
+ const locator = current.locator(`aria-ref=${ref}`);
1245
+ if (typeof locator.count === 'function' && (await locator.count()) === 0) {
1246
+ throw new TargetError(`ref ${ref} is not on the current screen; take a snapshot and use a ref from it`);
1247
+ }
1248
+ return locator;
1249
+ }
1250
+
1251
+ /**
1252
+ * The locator a click or a typing target means, or `{point}` for
1253
+ * coordinates. A ref is preferred and checked against the page; a line
1254
+ * pasted from the snapshot is read into its ref, else its coordinates
1255
+ * (checked against the document like a ref); locator code is refused at once, with no wait; a name that
1256
+ * matches more than one interactive node on the last screen is answered
1257
+ * with the candidates and their refs rather than guessed, and a name that
1258
+ * matches one is clicked by that node's ref. Anything else that is not on
1259
+ * the last screen at all is refused at once rather than searched for.
844
1260
  *
845
1261
  * @param {any} current
846
1262
  * @param {{ref?: string, target?: string}} action
847
1263
  */
848
1264
  async function targetFor(current, action) {
849
- if (action.ref !== undefined && action.ref !== null && action.ref !== '') {
850
- const ref = String(action.ref).trim();
851
- if (!REF_PATTERN.test(ref)) throw new TargetError(`a ref looks like e12, as the snapshot prints it; ${JSON.stringify(ref)} is not one`);
1265
+ if (action.ref !== undefined && action.ref !== null && action.ref !== '') return refLocator(current, action.ref);
1266
+ const target = String(action.target == null ? '' : action.target);
1267
+ let parsed = parseSnapshotTarget(target);
1268
+ // A bare e4 that a node on screen is named exactly is that text (a chess
1269
+ // square, a seat number), not a ref.
1270
+ if (parsed && parsed.bare && Array.isArray(lastNodes) && lastNodes.some(node => node.name === target.trim() || node.body.endsWith(`: ${target.trim()}`))) parsed = null;
1271
+ if (parsed) {
1272
+ if (parsed.ref) return refLocator(current, parsed.ref);
1273
+ // Coordinates read off a pasted line are as old as the screen they came
1274
+ // from: refused like a ref when the document has changed since.
852
1275
  await assertSameDocument(current);
853
- const locator = current.locator(`aria-ref=${ref}`);
854
- if (typeof locator.count === 'function' && (await locator.count()) === 0) {
855
- throw new TargetError(`ref ${ref} is not on the current screen; take a snapshot and use a ref from it`);
856
- }
857
- return locator;
1276
+ return { point: { x: parsed.x, y: parsed.y } };
858
1277
  }
859
- const target = String(action.target == null ? '' : action.target);
860
1278
  const candidates = candidatesFor(lastNodes, target);
861
1279
  if (candidates.length > 1) throw new TargetError(ambiguityMessage(target, candidates));
862
1280
  if (LOCATOR_CODE.test(target)) {
@@ -868,6 +1286,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
868
1286
  }
869
1287
  throw new TargetError(LOCATOR_CODE_MESSAGE);
870
1288
  }
1289
+ const sameScreen = await isSameDocument(current);
1290
+ // One node on screen carries that name: its ref names exactly it, where a
1291
+ // text search could land on a hidden copy.
1292
+ if (candidates.length === 1 && sameScreen) return current.locator(`aria-ref=${candidates[0].ref}`);
1293
+ if (sameScreen && Array.isArray(lastNodes) && lastNodes.length > 0 && !KNOWN_SELECTOR_ENGINES.test(target.trim())) {
1294
+ const roleAndName = target.trim().match(/^([a-z]+)\s+"([^"]*)"$/);
1295
+ if (!textOnScreen(lastNodes, roleAndName ? roleAndName[2] : target)) {
1296
+ throw new TargetError(`${JSON.stringify(target)} ${NOT_A_TARGET_MESSAGE}`);
1297
+ }
1298
+ }
871
1299
  return resolveLocator(current, target);
872
1300
  }
873
1301
 
@@ -885,10 +1313,24 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
885
1313
  }
886
1314
  }
887
1315
 
1316
+ /** The page URL, or null when the page cannot answer. */
1317
+ function pageUrl(current) {
1318
+ try {
1319
+ return typeof current.url === 'function' ? String(current.url()) : null;
1320
+ } catch {
1321
+ return null;
1322
+ }
1323
+ }
1324
+
888
1325
  /** Refuse a ref when the document changed since the observation it came from. */
889
1326
  async function assertSameDocument(current) {
890
- const now = await documentToken(current);
891
- if (lastNodesDocument && now !== lastNodesDocument) throw new TargetError(STALE_REF_MESSAGE);
1327
+ if (!(await isSameDocument(current))) throw new TargetError(STALE_REF_MESSAGE);
1328
+ }
1329
+
1330
+ /** Whether the page is still the document the last observation came from. */
1331
+ async function isSameDocument(current) {
1332
+ if (!lastNodesDocument) return true;
1333
+ return (await documentToken(current)) === lastNodesDocument;
892
1334
  }
893
1335
 
894
1336
  /** The hash of the screen as it is now, on observe()'s basis. */
@@ -947,9 +1389,36 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
947
1389
  if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
948
1390
  page = await context.newPage();
949
1391
  page.on('console', message => {
950
- if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
1392
+ if (message.type() !== 'error') return;
1393
+ let location = null;
1394
+ try {
1395
+ location = typeof message.location === 'function' ? message.location() : null;
1396
+ } catch {
1397
+ location = null;
1398
+ }
1399
+ consoleBuffer.push({ type: 'console', text: message.text(), ...(location && location.url ? { locationUrl: String(location.url) } : {}) });
951
1400
  });
952
1401
  page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
1402
+ // The failing request behind a "Failed to load resource" line, which
1403
+ // the console text alone never names.
1404
+ page.on('response', response => {
1405
+ try {
1406
+ const status = response.status();
1407
+ if (status < 400) return;
1408
+ const request = typeof response.request === 'function' ? response.request() : null;
1409
+ rememberNetworkFailure(networkFailures, { url: response.url(), status, method: request && typeof request.method === 'function' ? request.method() : null });
1410
+ } catch {
1411
+ // A response that cannot be read is not worth a note.
1412
+ }
1413
+ });
1414
+ page.on('requestfailed', request => {
1415
+ try {
1416
+ const failure = typeof request.failure === 'function' ? request.failure() : null;
1417
+ rememberNetworkFailure(networkFailures, { url: request.url(), status: null, method: typeof request.method === 'function' ? request.method() : null, error: failure && failure.errorText ? String(failure.errorText) : null });
1418
+ } catch {
1419
+ // Same: nothing to record.
1420
+ }
1421
+ });
953
1422
 
954
1423
  const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
955
1424
  // A 404 is not something to guess around: the build is not there, and
@@ -1018,13 +1487,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1018
1487
  async act(action) {
1019
1488
  const current = requirePage();
1020
1489
  const before = lastHash;
1490
+ const urlBefore = pageUrl(current);
1491
+ const tokenBefore = await documentToken(current);
1492
+ let typed = null;
1021
1493
  try {
1022
1494
  switch (action.kind) {
1023
1495
  case 'key':
1024
1496
  await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
1025
1497
  break;
1026
1498
  case 'type':
1027
- await typeText(current, action);
1499
+ typed = await typeText(current, action);
1028
1500
  if (action.submit) await current.keyboard.press('Enter');
1029
1501
  break;
1030
1502
  case 'click': {
@@ -1032,7 +1504,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1032
1504
  await current.mouse.click(Number(action.target.x), Number(action.target.y));
1033
1505
  break;
1034
1506
  }
1035
- await (await targetFor(current, action)).click({ timeout: ACTION_TIMEOUT_MS });
1507
+ const resolved = await targetFor(current, action);
1508
+ if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
1509
+ else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
1036
1510
  break;
1037
1511
  }
1038
1512
  case 'navigate': {
@@ -1083,11 +1557,22 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1083
1557
  return { ok: false, changed: false, error: text };
1084
1558
  }
1085
1559
 
1086
- let changed = false;
1087
- try {
1560
+ const measure = async () => {
1561
+ const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
1088
1562
  // The same basis observe() hashes, so the two are comparable.
1089
1563
  // Without it a long screen is always "changed".
1090
- changed = (await currentHash(current)) !== before;
1564
+ facts.hashAfter = await currentHash(current);
1565
+ return computeChanged(facts);
1566
+ };
1567
+ let changed = false;
1568
+ try {
1569
+ changed = await measure();
1570
+ // A click or a submit can start a navigation that has not landed yet:
1571
+ // look once more after a short pause before calling it a no-op.
1572
+ if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
1573
+ await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
1574
+ changed = await measure();
1575
+ }
1091
1576
  } catch {
1092
1577
  changed = true;
1093
1578
  }
@@ -1110,7 +1595,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1110
1595
 
1111
1596
  /** @returns {Promise<Array<{type: string, text: string}>>} */
1112
1597
  async consoleErrors() {
1113
- return consoleBuffer.splice(0, consoleBuffer.length);
1598
+ return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
1114
1599
  },
1115
1600
 
1116
1601
  /** @returns {Promise<{videoPath: string|null}>} */
@@ -1143,6 +1628,72 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1143
1628
  };
1144
1629
  }
1145
1630
 
1631
+ /** How many recent failed requests are kept to match console lines against. */
1632
+ export const MAX_NETWORK_FAILURES = 50;
1633
+
1634
+ /** The longest request URL a note carries. */
1635
+ export const MAX_NETWORK_URL_CHARS = 200;
1636
+
1637
+ /**
1638
+ * A request URL as a note carries it: no query string and no fragment (they
1639
+ * carry tokens and session ids as often as anything useful), and bounded.
1640
+ *
1641
+ * @param {string} url
1642
+ * @returns {string}
1643
+ */
1644
+ export function networkUrl(url) {
1645
+ let value = String(url == null ? '' : url);
1646
+ try {
1647
+ const parsed = new URL(value);
1648
+ value = `${parsed.origin === 'null' ? `${parsed.protocol}` : parsed.origin}${parsed.pathname}`;
1649
+ } catch {
1650
+ value = value.split(/[?#]/)[0];
1651
+ }
1652
+ return value.length > MAX_NETWORK_URL_CHARS ? `${value.slice(0, MAX_NETWORK_URL_CHARS - 3)}...` : value;
1653
+ }
1654
+
1655
+ /**
1656
+ * @param {Array<Object>} list
1657
+ * @param {{url: string, status: number|null, method?: string|null, error?: string|null}} failure
1658
+ */
1659
+ export function rememberNetworkFailure(list, failure) {
1660
+ list.push({ url: networkUrl(failure.url), status: Number.isInteger(failure.status) ? failure.status : null, method: failure.method || null, error: failure.error || null, claimed: false });
1661
+ while (list.length > MAX_NETWORK_FAILURES) list.shift();
1662
+ }
1663
+
1664
+ /** The console line a browser prints for a failed request. */
1665
+ const FAILED_RESOURCE = /^Failed to load resource\b/i;
1666
+
1667
+ /**
1668
+ * Give each "Failed to load resource" console line the request it was about:
1669
+ * the one at the line's own location when the browser gave one, otherwise the
1670
+ * oldest unclaimed failure with the same status (or the same network error).
1671
+ * The line's text gains the method, the URL and the status, so the note says
1672
+ * what failed; `url` and `status` ride along for the report.
1673
+ *
1674
+ * @param {Array<{type: string, text: string, locationUrl?: string}>} entries
1675
+ * @param {Array<Object>} failures the driver's recent failed requests
1676
+ * @returns {Array<Object>}
1677
+ */
1678
+ export function attachNetworkFailures(entries, failures) {
1679
+ return entries.map(entry => {
1680
+ const { locationUrl, ...rest } = entry;
1681
+ if (!FAILED_RESOURCE.test(String(entry.text || ''))) return rest;
1682
+ const status = Number((String(entry.text).match(/status of (\d{3})/) || [])[1]) || null;
1683
+ const location = locationUrl ? networkUrl(locationUrl) : null;
1684
+ let match = location ? failures.find(failure => !failure.claimed && failure.url === location) : null;
1685
+ if (!match) {
1686
+ match = failures.find(failure => !failure.claimed && (status ? failure.status === status : failure.status === null && failure.error && String(entry.text).includes(failure.error)));
1687
+ }
1688
+ const url = match ? match.url : location;
1689
+ if (!url) return rest;
1690
+ if (match) match.claimed = true;
1691
+ const shownStatus = match && match.status ? match.status : status;
1692
+ const method = match && match.method ? `${match.method} ` : '';
1693
+ return { ...rest, text: `${rest.text} (${method}${url}${shownStatus ? ` answered ${shownStatus}` : ''})`, url, status: shownStatus || null };
1694
+ });
1695
+ }
1696
+
1146
1697
  /**
1147
1698
  * Playwright names its recording with a random id. The upload allowlist and
1148
1699
  * the server's 90 day lifecycle rule are written against `session.webm`, so