ravensight-playtest 0.2.9 → 0.3.1

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.
@@ -2,6 +2,7 @@ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  DriverError,
5
+ STALE_REF_MESSAGE,
5
6
  THIN_OBSERVATION_CHARS,
6
7
  observationHash,
7
8
  screenshotName,
@@ -63,48 +64,289 @@ const INTERACTIVE_ROLES = new Set([
63
64
  'canvas'
64
65
  ]);
65
66
 
67
+ /**
68
+ * Landmark roles, and how a duplicate's hint names them when they carry no
69
+ * name of their own.
70
+ */
71
+ const LANDMARK_LABELS = Object.freeze({
72
+ banner: 'header',
73
+ navigation: 'navigation',
74
+ main: 'main',
75
+ contentinfo: 'footer',
76
+ complementary: 'sidebar',
77
+ region: 'region',
78
+ dialog: 'dialog',
79
+ alertdialog: 'dialog',
80
+ form: 'form',
81
+ search: 'search',
82
+ toolbar: 'toolbar',
83
+ tablist: 'tab list',
84
+ menu: 'menu',
85
+ menubar: 'menu bar',
86
+ group: 'group'
87
+ });
88
+
89
+ /** Attributes the snapshot prints that tell a persona nothing. */
90
+ const NOISE_ATTRIBUTE = / \[(?:ref=[^\]]*|box=[^\]]*|cursor=[^\]]*|active)\]/g;
91
+
92
+ /** The `[ref=e12]` Playwright's AI mode snapshot prints. */
93
+ const REF_ATTRIBUTE = /\[ref=(e\d+)\]/;
94
+
95
+ /** The `[box=x,y,width,height]` a snapshot taken with `boxes` prints. */
96
+ const BOX_ATTRIBUTE = /\[box=(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/;
97
+
66
98
  /**
67
99
  * Keep the nodes a player could act on or read, and drop the structure in
68
100
  * between. An aria snapshot prints every wrapper (`- generic:`, `- list:`, a
69
101
  * nameless `- img`) and the attribute lines under links (`- /url: ...`), none
70
102
  * of which tell a persona anything, and all of which cost tokens every turn.
71
103
  *
72
- * Indentation is dropped too: the persona targets nodes by role and name, not
73
- * by depth. Text that is not an aria snapshot (the innerText fallback for a
74
- * thin page) is returned unchanged.
104
+ * Indentation is dropped too: the persona targets nodes by ref, role and
105
+ * name, not by depth. Text that is not an aria snapshot (the innerText
106
+ * fallback for a thin page) is returned unchanged.
75
107
  *
76
108
  * @param {string} text
109
+ * @param {{viewport?: {width: number, height: number}}} [options]
77
110
  * @returns {string}
78
111
  */
79
- export function compactAriaSnapshot(text) {
80
- const nodes = compactAriaNodes(text);
112
+ export function compactAriaSnapshot(text, options = {}) {
113
+ const nodes = compactAriaNodes(text, options);
81
114
  return nodes === null ? String(text == null ? '' : text) : nodes.map(node => node.line).join('\n');
82
115
  }
83
116
 
117
+ /**
118
+ * The centre of a box, or null when the node is not visible in the viewport.
119
+ * Rounded, and bounded by the viewport, so a coordinate the persona copies is
120
+ * always one the mouse can reach.
121
+ *
122
+ * @param {Array<number>|null} box [x, y, width, height]
123
+ * @param {{width: number, height: number}} viewport
124
+ * @returns {{x: number, y: number}|null}
125
+ */
126
+ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
127
+ if (!box) return null;
128
+ const [x, y, width, height] = box;
129
+ if (!(width > 0 && height > 0)) return null;
130
+ const cx = Math.round(x + width / 2);
131
+ const cy = Math.round(y + height / 2);
132
+ if (cx < 0 || cy < 0 || cx > viewport.width || cy > viewport.height) return null;
133
+ return { x: cx, y: cy };
134
+ }
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
+
84
266
  /**
85
267
  * The kept nodes of an aria snapshot with the depth each one had, or null for
86
268
  * text that is not an aria snapshot. The depth is not shown to the model; it
87
269
  * is what tells repeated siblings apart from a panel that follows them.
88
270
  *
271
+ * An interactive node from Playwright's AI mode snapshot carries its short ref
272
+ * (`[e12]`), which `click` and `type` accept as a target, and, when it is on
273
+ * screen, its centre `@(x,y)`. Two interactive nodes with the same role and
274
+ * name each get a hint naming the nearest landmark or named container around
275
+ * them ("in header", "in main"), so the persona can tell them apart.
276
+ *
277
+ * `hashLine` is the line without the centre: what "changed" and "new since
278
+ * the last screen" compare, so a scroll or an animation does not read as a
279
+ * new screen.
280
+ *
89
281
  * @param {string} text
90
- * @returns {Array<{depth: number, line: string, body: string, role: string}>|null}
282
+ * @param {{viewport?: {width: number, height: number}}} [options]
283
+ * @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}
91
284
  */
92
- export function compactAriaNodes(text) {
285
+ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT, values = null } = {}) {
93
286
  const value = String(text == null ? '' : text);
94
287
  const lines = value.split('\n');
95
288
  if (!lines.some(line => /^\s*- /.test(line))) return null;
96
289
  const kept = [];
290
+ const ancestors = [];
97
291
  for (const line of lines) {
98
292
  const trimmed = line.trim();
99
293
  if (!trimmed.startsWith('- ')) continue;
100
- const body = unquoteKey(trimmed.slice(2));
101
- if (body.startsWith('/')) continue;
102
- const role = (body.match(/^([a-z]+)/) || [])[1] || '';
294
+ const depth = line.length - line.trimStart().length;
295
+ const raw = unquoteKey(trimmed.slice(2));
296
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1].depth >= depth) ancestors.pop();
297
+ if (raw.startsWith('/')) continue;
298
+ const role = (raw.match(/^([a-z]+)/) || [])[1] || '';
299
+ const name = (raw.match(/^[a-z]+ "([^"]*)"/) || [])[1] ?? null;
300
+ const ref = (raw.match(REF_ATTRIBUTE) || [])[1] || null;
301
+ const boxMatch = raw.match(BOX_ATTRIBUTE);
302
+ const box = boxMatch ? boxMatch.slice(1, 5).map(Number) : null;
303
+ // A field's printed value goes before anything else reads the line: only
304
+ // a value the DOM confirmed (and never a password) is shown, below.
305
+ const body = TYPED_VALUE_ROLES.has(role) ? withoutPrintedValue(raw.replace(NOISE_ATTRIBUTE, '')) : raw.replace(NOISE_ATTRIBUTE, '');
306
+ ancestors.push({ depth, role, name });
103
307
  const named = /^[a-z]+ "[^"]*"/.test(body);
104
308
  const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
105
- if (INTERACTIVE_ROLES.has(role) || named || inlineText) {
106
- kept.push({ depth: line.length - line.trimStart().length, line: `- ${body}`, body, role });
309
+ const interactive = INTERACTIVE_ROLES.has(role);
310
+ if (!(interactive || named || inlineText)) continue;
311
+ const container = ancestors.slice(0, -1).reverse().find(entry => LANDMARK_LABELS[entry.role] || entry.name);
312
+ const hint = container
313
+ ? (container.name ? `${container.name}${LANDMARK_LABELS[container.role] && container.role !== 'region' ? ` ${LANDMARK_LABELS[container.role]}` : ''}` : LANDMARK_LABELS[container.role])
314
+ : null;
315
+ const state = interactive && ref && values && typeof values.get === 'function' && VALUE_ROLES.has(role) ? values.get(ref) : null;
316
+ kept.push({
317
+ depth,
318
+ body,
319
+ role,
320
+ name,
321
+ state: state ? formatControlState(state) : null,
322
+ ref: interactive ? ref : null,
323
+ centre: interactive ? visibleCentre(box, viewport) : null,
324
+ hint,
325
+ key: interactive && name ? `${role}|${name}` : null,
326
+ duplicate: false,
327
+ line: '',
328
+ hashLine: ''
329
+ });
330
+ }
331
+ const counts = new Map();
332
+ for (const node of kept) if (node.key) counts.set(node.key, (counts.get(node.key) || 0) + 1);
333
+ for (const node of kept) {
334
+ node.duplicate = Boolean(node.key) && counts.get(node.key) > 1;
335
+ const split = node.body.match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(.*)$/s) || [null, node.body, ''];
336
+ let head = split[1];
337
+ let tail = split[2];
338
+ if (node.state) {
339
+ // The value read from the DOM replaces whatever the snapshot printed
340
+ // after the name and its checked attribute, so there is one answer, and
341
+ // it is the one the page holds.
342
+ head = head.replace(/ \[(?:checked|selected)(?:=[^\]]*)?\]/g, '');
343
+ tail = ` ${node.state}`;
107
344
  }
345
+ const refPart = node.ref ? ` [${node.ref}]` : '';
346
+ const hintPart = node.duplicate && node.hint ? ` (in ${node.hint})` : '';
347
+ const centrePart = node.centre ? ` @(${node.centre.x},${node.centre.y})` : '';
348
+ node.hashLine = `- ${head}${refPart}${hintPart}${tail}`;
349
+ node.line = `- ${head}${refPart}${centrePart}${hintPart}${tail}`;
108
350
  }
109
351
  return kept;
110
352
  }
@@ -151,6 +393,8 @@ function nodeShape(body) {
151
393
  * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
152
394
  */
153
395
  export function collapseRepeatedSiblings(nodes) {
396
+ const keyCounts = new Map();
397
+ for (const node of nodes) if (node.key) keyCounts.set(node.key, (keyCounts.get(node.key) || 0) + 1);
154
398
  const unitEnd = start => {
155
399
  let end = start + 1;
156
400
  while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
@@ -171,15 +415,36 @@ export function collapseRepeatedSiblings(nodes) {
171
415
  next = end;
172
416
  }
173
417
  if (units.length >= COLLAPSE_MIN_RUN) {
174
- for (const [start, end] of units.slice(0, COLLAPSE_KEEP)) {
175
- for (const node of nodes.slice(start, end)) entries.push({ line: node.line, role: node.role });
418
+ // A node whose role and name also appear outside this run is never
419
+ // hidden in the count: two buttons called "Run the week", one in the
420
+ // header and one in the main panel, both stay visible with their refs.
421
+ const inRun = new Map();
422
+ for (const node of nodes.slice(i, next)) if (node.key) inRun.set(node.key, (inRun.get(node.key) || 0) + 1);
423
+ // Only the first in-run twin of a name that also appears outside the
424
+ // run is kept, beside the first two items. One "View" button in the
425
+ // header must not stop a list of 249 "View" cards from collapsing.
426
+ const outside = key => key && (keyCounts.get(key) || 0) > (inRun.get(key) || 0);
427
+ const shownKeys = new Set();
428
+ let more = 0;
429
+ units.forEach((unit, index) => {
430
+ const unitNodes = nodes.slice(unit[0], unit[1]);
431
+ const twins = unitNodes.map(node => node.key).filter(key => outside(key) && !shownKeys.has(key));
432
+ if (index < COLLAPSE_KEEP || twins.length > 0) {
433
+ for (const node of unitNodes) {
434
+ entries.push({ line: node.line, hashLine: node.hashLine, role: node.role, key: node.key });
435
+ if (node.key) shownKeys.add(node.key);
436
+ }
437
+ } else {
438
+ more += 1;
439
+ }
440
+ });
441
+ if (more > 0) {
442
+ collapsed += more;
443
+ entries.push({ line: `- ... and ${more} more like it (${nodes[i].role || 'items'}; target one by its name)`, role: '', marker: true });
176
444
  }
177
- const more = units.length - COLLAPSE_KEEP;
178
- collapsed += more;
179
- entries.push({ line: `- ... and ${more} more like it (${nodes[i].role || 'items'}; target one by its name)`, role: '', marker: true });
180
445
  i = next;
181
446
  } else {
182
- entries.push({ line: nodes[i].line, role: nodes[i].role });
447
+ entries.push({ line: nodes[i].line, hashLine: nodes[i].hashLine, role: nodes[i].role, key: nodes[i].key });
183
448
  i += 1;
184
449
  }
185
450
  }
@@ -204,12 +469,12 @@ export function collapseRepeatedSiblings(nodes) {
204
469
  * @param {{max?: number, previous?: Set<string>|null}} [options]
205
470
  * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
206
471
  */
207
- export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null } = {}) {
208
- const nodes = compactAriaNodes(text);
472
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT, values = null } = {}) {
473
+ const nodes = compactAriaNodes(text, { viewport, values });
209
474
  if (nodes === null) return null;
210
- const lines = nodes.map(node => node.line);
211
- const full = lines.join('\n');
212
- if (full.length <= max) return { text: full, lines };
475
+ const lines = nodes.map(node => node.hashLine);
476
+ const full = nodes.map(node => node.line).join('\n');
477
+ if (full.length <= max) return { text: full, lines, nodes };
213
478
 
214
479
  const { entries, collapsed } = collapseRepeatedSiblings(nodes);
215
480
  const header = (left, out) => {
@@ -220,7 +485,7 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
220
485
  };
221
486
  const collapsedText = entries.map(entry => entry.line).join('\n');
222
487
  const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
223
- if (withHeader.length <= max) return { text: withHeader, lines };
488
+ if (withHeader.length <= max) return { text: withHeader, lines, nodes };
224
489
 
225
490
  const rank = entry => {
226
491
  if (entry.marker) return 0;
@@ -229,7 +494,7 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
229
494
  if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
230
495
  return 5;
231
496
  };
232
- const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.line);
497
+ const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.hashLine || entry.line);
233
498
  // The last interactive nodes on the page are usually its form or its
234
499
  // confirm button (a panel after a list). When the list could not be
235
500
  // collapsed they would otherwise lose to the list on page order, so the
@@ -256,9 +521,21 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
256
521
  chosen.add(candidate.index);
257
522
  used += cost;
258
523
  }
524
+ // Duplicates by name travel together: showing one "Run the week" and not
525
+ // the other is how a persona ends up clicking a name that matches twice.
526
+ const chosenKeys = new Set(entries.filter((entry, index) => chosen.has(index) && entry.key).map(entry => entry.key));
527
+ // Bounded by the cap's own slack: a page whose every card shares a name
528
+ // is the trim's job, not this one's.
529
+ entries.forEach((entry, index) => {
530
+ if (!entry.key || !chosenKeys.has(entry.key) || chosen.has(index)) return;
531
+ const cost = entry.line.length + 1;
532
+ if (used + cost > budget) return;
533
+ chosen.add(index);
534
+ used += cost;
535
+ });
259
536
  const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
260
537
  const out = entries.length - kept.length;
261
- return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines };
538
+ return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
262
539
  }
263
540
 
264
541
  /**
@@ -420,6 +697,78 @@ export function isLoopback(hostname) {
420
697
  return LOOPBACK_HOSTS.includes(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
421
698
  }
422
699
 
700
+ /**
701
+ * A target written as locator code rather than as something on screen. A
702
+ * persona that writes `getByTestId('strip-advance')` means a selector, and
703
+ * searching the page for that literal text waits the whole action timeout
704
+ * for nothing.
705
+ */
706
+ export const LOCATOR_CODE = /^\s*(?:getBy|page\.|locator\(|css=|xpath=|\/\/)/;
707
+
708
+ /** What the persona is told when it writes locator code as a target. */
709
+ export const LOCATOR_CODE_MESSAGE = 'targets are refs or visible text, not locator code';
710
+
711
+ export { STALE_REF_MESSAGE };
712
+
713
+ /** A ref as the snapshot prints it. */
714
+ export const REF_PATTERN = /^e\d{1,6}$/;
715
+
716
+ /**
717
+ * A failure on the driver's side, not the game's: a target that names two
718
+ * elements, a target that is not a target, a ref that is not on screen. The
719
+ * transcript records these as `driver_error`, and the report step is told
720
+ * they are never evidence of a game bug.
721
+ */
722
+ export class TargetError extends Error {
723
+ constructor(message) {
724
+ super(message);
725
+ this.name = 'TargetError';
726
+ this.driverSide = true;
727
+ }
728
+ }
729
+
730
+ /**
731
+ * The candidates a target could mean, from the last snapshot: the
732
+ * interactive nodes whose name is the target (or whose role and name are,
733
+ * for `button "Start"`), with their refs and hints.
734
+ *
735
+ * @param {Array<Object>|null} nodes
736
+ * @param {string} target
737
+ * @returns {Array<Object>}
738
+ */
739
+ export function candidatesFor(nodes, target) {
740
+ if (!Array.isArray(nodes)) return [];
741
+ const value = String(target).trim();
742
+ const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
743
+ return nodes.filter(node => node.key && node.ref && (roleAndName
744
+ ? node.role === roleAndName[1] && node.name === roleAndName[2]
745
+ : String(node.name).trim().toLowerCase() === value.toLowerCase()));
746
+ }
747
+
748
+ /** The most candidates an ambiguity answer lists before "and N more". */
749
+ export const AMBIGUITY_LIST_MAX = 5;
750
+
751
+ /**
752
+ * "2 elements match "Run the week"; use ref e3 (in header) or e5 (in main)".
753
+ *
754
+ * @param {string} target
755
+ * @param {Array<Object>} candidates
756
+ * @param {number} [count] how many Playwright said matched, when known
757
+ * @returns {string}
758
+ */
759
+ export function ambiguityMessage(target, candidates, count = null) {
760
+ const total = Math.max(count || 0, candidates.length);
761
+ const shown = candidates.slice(0, AMBIGUITY_LIST_MAX);
762
+ const listed = shown.map(node => `${node.ref}${node.hint ? ` (in ${node.hint})` : ''}`);
763
+ const more = candidates.length - shown.length;
764
+ const choice = more > 0
765
+ ? `${listed.join(', ')} and ${more} more`
766
+ : listed.length > 1 ? `${listed.slice(0, -1).join(', ')} or ${listed[listed.length - 1]}` : listed[0];
767
+ return listed.length > 0
768
+ ? `${total} elements match ${JSON.stringify(String(target))}; use ref ${choice}`
769
+ : `${total} elements match ${JSON.stringify(String(target))}; take a snapshot and use the ref of the one you mean`;
770
+ }
771
+
423
772
  /**
424
773
  * Turn what the model wrote into a Playwright locator.
425
774
  *
@@ -435,6 +784,7 @@ export function isLoopback(hostname) {
435
784
  */
436
785
  export function resolveLocator(page, target) {
437
786
  const value = String(target).trim();
787
+ if (LOCATOR_CODE.test(value)) throw new TargetError(LOCATOR_CODE_MESSAGE);
438
788
  if (KNOWN_SELECTOR_ENGINES.test(value)) return page.locator(value);
439
789
 
440
790
  // The name group cannot contain a quote (`[^"]*`), so there is nothing to
@@ -448,6 +798,16 @@ export function resolveLocator(page, target) {
448
798
  return page.getByText(value, { exact: false }).first();
449
799
  }
450
800
 
801
+ /**
802
+ * Give the document a random token. Runs in the browser at the start of every
803
+ * new document, so a reload or a navigation gets a new one and a single page
804
+ * app's route change keeps it. Serialised by Playwright, so it must not close
805
+ * over anything.
806
+ */
807
+ function markDocument() {
808
+ globalThis.__ravensightDoc = `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
809
+ }
810
+
451
811
  /**
452
812
  * Hand the run's playtest identity to the page. Runs in the browser, before
453
813
  * any of the game's own scripts, so the Ravensight JS SDK finds it at
@@ -492,7 +852,18 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
492
852
  let lastObservedHash = null;
493
853
  /** The compacted lines of the last observation, so a cut can prefer what is new. */
494
854
  let lastObservedLines = null;
855
+ /** The kept nodes of the last observation: refs, names and hints, for a target that matches twice. */
856
+ let lastNodes = null;
857
+ let viewportSize = DEFAULT_VIEWPORT;
858
+ // Refs are numbered per document: after a navigation or a reload, e12 can
859
+ // be a different element. Each document gets a random token from an init
860
+ // script; a pushState or hash route change keeps it, a new document does
861
+ // not. The token the last observation (and so its refs) came from is
862
+ // compared with the current one before any ref is used.
863
+ let lastNodesDocument = null;
495
864
  const consoleBuffer = [];
865
+ /** Recent failed requests, newest last, bounded: what a console line is matched against. */
866
+ const networkFailures = [];
496
867
 
497
868
  /** @returns {any} */
498
869
  function requirePage() {
@@ -509,8 +880,23 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
509
880
  const current = requirePage();
510
881
  let text = '';
511
882
  let aria = true;
883
+ let values = null;
512
884
  const body = current.locator('body');
513
- if (typeof body.ariaSnapshot === 'function') {
885
+ // The page level AI mode snapshot carries a ref per element, which
886
+ // `aria-ref=e12` resolves, and each element's box. Older Playwright
887
+ // builds have neither, and the body snapshot is the fallback.
888
+ if (typeof current.ariaSnapshot === 'function') {
889
+ try {
890
+ text = await current.ariaSnapshot({ mode: 'ai', boxes: true, timeout: ACTION_TIMEOUT_MS });
891
+ } catch {
892
+ text = '';
893
+ }
894
+ // What each field, box and select holds now, from the DOM rather than
895
+ // the snapshot's text: the Texas run typed a coach name twice and
896
+ // filed a bug because the observation kept showing the field empty.
897
+ if (text) values = await readControlValues(current, text);
898
+ }
899
+ if (!text && typeof body.ariaSnapshot === 'function') {
514
900
  try {
515
901
  text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
516
902
  } catch {
@@ -532,7 +918,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
532
918
  // is the honest answer, and the screenshot fallback below covers it.
533
919
  }
534
920
  }
535
- return { text, aria };
921
+ return { text, aria, values };
536
922
  }
537
923
 
538
924
  /**
@@ -545,10 +931,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
545
931
  */
546
932
  function shownText(snap) {
547
933
  if (snap.aria) {
548
- const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null });
934
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize, values: snap.values });
549
935
  if (fitted) return fitted;
550
936
  }
551
- return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null };
937
+ return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
552
938
  }
553
939
 
554
940
  /**
@@ -580,7 +966,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
580
966
  */
581
967
  async function typeText(current, action) {
582
968
  const text = String(action.text == null ? '' : action.text);
583
- const field = action.target ? resolveLocator(current, action.target) : null;
969
+ const field = action.target || action.ref ? await targetFor(current, action) : null;
584
970
  if (action.replace) {
585
971
  try {
586
972
  await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
@@ -598,8 +984,75 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
598
984
  if (text) await current.keyboard.type(text, { delay: 20 });
599
985
  }
600
986
 
987
+ /**
988
+ * The locator a click or a typing target means. A ref is preferred and
989
+ * checked against the page; locator code is refused at once, with no wait;
990
+ * a name that matches more than one interactive node on the last screen is
991
+ * answered with the candidates and their refs rather than guessed.
992
+ *
993
+ * @param {any} current
994
+ * @param {{ref?: string, target?: string}} action
995
+ */
996
+ async function targetFor(current, action) {
997
+ if (action.ref !== undefined && action.ref !== null && action.ref !== '') {
998
+ const ref = String(action.ref).trim();
999
+ if (!REF_PATTERN.test(ref)) throw new TargetError(`a ref looks like e12, as the snapshot prints it; ${JSON.stringify(ref)} is not one`);
1000
+ await assertSameDocument(current);
1001
+ const locator = current.locator(`aria-ref=${ref}`);
1002
+ if (typeof locator.count === 'function' && (await locator.count()) === 0) {
1003
+ throw new TargetError(`ref ${ref} is not on the current screen; take a snapshot and use a ref from it`);
1004
+ }
1005
+ return locator;
1006
+ }
1007
+ const target = String(action.target == null ? '' : action.target);
1008
+ const candidates = candidatesFor(lastNodes, target);
1009
+ if (candidates.length > 1) throw new TargetError(ambiguityMessage(target, candidates));
1010
+ if (LOCATOR_CODE.test(target)) {
1011
+ // Text that only looks like code: a node on screen really is named
1012
+ // that, so it is clicked by its ref rather than refused.
1013
+ if (candidates.length === 1) {
1014
+ await assertSameDocument(current);
1015
+ return current.locator(`aria-ref=${candidates[0].ref}`);
1016
+ }
1017
+ throw new TargetError(LOCATOR_CODE_MESSAGE);
1018
+ }
1019
+ return resolveLocator(current, target);
1020
+ }
1021
+
1022
+ /**
1023
+ * The current document's token, read from the main frame only (a child
1024
+ * frame has its own document and its own token), or null when the page
1025
+ * cannot answer.
1026
+ */
1027
+ async function documentToken(current) {
1028
+ try {
1029
+ const token = await current.evaluate(() => globalThis.__ravensightDoc || null);
1030
+ return typeof token === 'string' && token ? token : null;
1031
+ } catch {
1032
+ return null;
1033
+ }
1034
+ }
1035
+
1036
+ /** Refuse a ref when the document changed since the observation it came from. */
1037
+ async function assertSameDocument(current) {
1038
+ const now = await documentToken(current);
1039
+ if (lastNodesDocument && now !== lastNodesDocument) throw new TargetError(STALE_REF_MESSAGE);
1040
+ }
1041
+
1042
+ /** The hash of the screen as it is now, on observe()'s basis. */
1043
+ async function currentHash(current) {
1044
+ const snap = await snapshotText();
1045
+ const after = { text: hashText(snap, shownText(snap)) };
1046
+ if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
1047
+ const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
1048
+ after.screenshotBase64 = frame.toString('base64');
1049
+ }
1050
+ return observationHash(after);
1051
+ }
1052
+
601
1053
  return {
602
1054
  name: 'playwright_web',
1055
+ supportsRefs: true,
603
1056
  editModifier,
604
1057
  keepObservations: WEB_KEEP_OBSERVATIONS,
605
1058
 
@@ -613,6 +1066,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
613
1066
  async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
614
1067
  if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
615
1068
  target = url;
1069
+ viewportSize = viewport || DEFAULT_VIEWPORT;
616
1070
  runDirectory = runDir;
617
1071
  videoRequested = Boolean(recordVideo);
618
1072
 
@@ -636,11 +1090,41 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
636
1090
  persona: playtest.persona || null
637
1091
  });
638
1092
  }
1093
+ // One token per document: set again by every new document, kept by
1094
+ // pushState and hash route changes, which do not run init scripts.
1095
+ if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
639
1096
  page = await context.newPage();
640
1097
  page.on('console', message => {
641
- if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
1098
+ if (message.type() !== 'error') return;
1099
+ let location = null;
1100
+ try {
1101
+ location = typeof message.location === 'function' ? message.location() : null;
1102
+ } catch {
1103
+ location = null;
1104
+ }
1105
+ consoleBuffer.push({ type: 'console', text: message.text(), ...(location && location.url ? { locationUrl: String(location.url) } : {}) });
642
1106
  });
643
1107
  page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
1108
+ // The failing request behind a "Failed to load resource" line, which
1109
+ // the console text alone never names.
1110
+ page.on('response', response => {
1111
+ try {
1112
+ const status = response.status();
1113
+ if (status < 400) return;
1114
+ const request = typeof response.request === 'function' ? response.request() : null;
1115
+ rememberNetworkFailure(networkFailures, { url: response.url(), status, method: request && typeof request.method === 'function' ? request.method() : null });
1116
+ } catch {
1117
+ // A response that cannot be read is not worth a note.
1118
+ }
1119
+ });
1120
+ page.on('requestfailed', request => {
1121
+ try {
1122
+ const failure = typeof request.failure === 'function' ? request.failure() : null;
1123
+ rememberNetworkFailure(networkFailures, { url: request.url(), status: null, method: typeof request.method === 'function' ? request.method() : null, error: failure && failure.errorText ? String(failure.errorText) : null });
1124
+ } catch {
1125
+ // Same: nothing to record.
1126
+ }
1127
+ });
644
1128
 
645
1129
  const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
646
1130
  // A 404 is not something to guess around: the build is not there, and
@@ -692,6 +1176,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
692
1176
  // because a fit keeps new nodes first), the truncated text otherwise.
693
1177
  lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
694
1178
  lastObservedLines = shown.lines;
1179
+ lastNodes = shown.nodes || null;
1180
+ lastNodesDocument = await documentToken(current);
695
1181
  // The same screen twice is sent as a marker rather than in full
696
1182
  // (./tools.js), which is most of what a stuck persona would otherwise
697
1183
  // pay for.
@@ -717,11 +1203,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
717
1203
  if (action.submit) await current.keyboard.press('Enter');
718
1204
  break;
719
1205
  case 'click': {
720
- if (action.target && typeof action.target === 'object') {
1206
+ if (!action.ref && action.target && typeof action.target === 'object') {
721
1207
  await current.mouse.click(Number(action.target.x), Number(action.target.y));
722
1208
  break;
723
1209
  }
724
- await resolveLocator(current, action.target).click({ timeout: ACTION_TIMEOUT_MS });
1210
+ await (await targetFor(current, action)).click({ timeout: ACTION_TIMEOUT_MS });
725
1211
  break;
726
1212
  }
727
1213
  case 'navigate': {
@@ -739,20 +1225,44 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
739
1225
  return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
740
1226
  }
741
1227
  } catch (error) {
742
- return { ok: false, changed: false, error: String(error && error.message ? error.message : error) };
1228
+ // Playwright colours its call log; the colour codes are noise here.
1229
+ const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
1230
+ // Two elements under one name: Playwright's strict mode. Answered
1231
+ // with the candidates and their refs, so the next try can work.
1232
+ if (/strict mode violation/i.test(text)) {
1233
+ const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
1234
+ const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
1235
+ return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
1236
+ }
1237
+ if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
1238
+ // A click the page kept intercepting is the game's: something sat on
1239
+ // top of the target and never went away. Named, never a driver error.
1240
+ const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
1241
+ const intercepted = interceptor ? interceptor[1] : null;
1242
+ if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
1243
+ return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
1244
+ }
1245
+ // A timeout with the page unchanged while resolving a click or typing
1246
+ // target means the target was never found or never actionable: the
1247
+ // driver's failure, not the game's. Never for navigate: a game that
1248
+ // never loads is a real blocker.
1249
+ if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
1250
+ let unchanged = false;
1251
+ try {
1252
+ unchanged = (await currentHash(current)) === before;
1253
+ } catch {
1254
+ unchanged = false;
1255
+ }
1256
+ return { ok: false, changed: false, error: text, driverError: unchanged };
1257
+ }
1258
+ return { ok: false, changed: false, error: text };
743
1259
  }
744
1260
 
745
1261
  let changed = false;
746
1262
  try {
747
1263
  // The same basis observe() hashes, so the two are comparable.
748
1264
  // Without it a long screen is always "changed".
749
- const snap = await snapshotText();
750
- const after = { text: hashText(snap, shownText(snap)) };
751
- if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
752
- const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
753
- after.screenshotBase64 = frame.toString('base64');
754
- }
755
- changed = observationHash(after) !== before;
1265
+ changed = (await currentHash(current)) !== before;
756
1266
  } catch {
757
1267
  changed = true;
758
1268
  }
@@ -775,7 +1285,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
775
1285
 
776
1286
  /** @returns {Promise<Array<{type: string, text: string}>>} */
777
1287
  async consoleErrors() {
778
- return consoleBuffer.splice(0, consoleBuffer.length);
1288
+ return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
779
1289
  },
780
1290
 
781
1291
  /** @returns {Promise<{videoPath: string|null}>} */
@@ -808,6 +1318,72 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
808
1318
  };
809
1319
  }
810
1320
 
1321
+ /** How many recent failed requests are kept to match console lines against. */
1322
+ export const MAX_NETWORK_FAILURES = 50;
1323
+
1324
+ /** The longest request URL a note carries. */
1325
+ export const MAX_NETWORK_URL_CHARS = 200;
1326
+
1327
+ /**
1328
+ * A request URL as a note carries it: no query string and no fragment (they
1329
+ * carry tokens and session ids as often as anything useful), and bounded.
1330
+ *
1331
+ * @param {string} url
1332
+ * @returns {string}
1333
+ */
1334
+ export function networkUrl(url) {
1335
+ let value = String(url == null ? '' : url);
1336
+ try {
1337
+ const parsed = new URL(value);
1338
+ value = `${parsed.origin === 'null' ? `${parsed.protocol}` : parsed.origin}${parsed.pathname}`;
1339
+ } catch {
1340
+ value = value.split(/[?#]/)[0];
1341
+ }
1342
+ return value.length > MAX_NETWORK_URL_CHARS ? `${value.slice(0, MAX_NETWORK_URL_CHARS - 3)}...` : value;
1343
+ }
1344
+
1345
+ /**
1346
+ * @param {Array<Object>} list
1347
+ * @param {{url: string, status: number|null, method?: string|null, error?: string|null}} failure
1348
+ */
1349
+ export function rememberNetworkFailure(list, failure) {
1350
+ list.push({ url: networkUrl(failure.url), status: Number.isInteger(failure.status) ? failure.status : null, method: failure.method || null, error: failure.error || null, claimed: false });
1351
+ while (list.length > MAX_NETWORK_FAILURES) list.shift();
1352
+ }
1353
+
1354
+ /** The console line a browser prints for a failed request. */
1355
+ const FAILED_RESOURCE = /^Failed to load resource\b/i;
1356
+
1357
+ /**
1358
+ * Give each "Failed to load resource" console line the request it was about:
1359
+ * the one at the line's own location when the browser gave one, otherwise the
1360
+ * oldest unclaimed failure with the same status (or the same network error).
1361
+ * The line's text gains the method, the URL and the status, so the note says
1362
+ * what failed; `url` and `status` ride along for the report.
1363
+ *
1364
+ * @param {Array<{type: string, text: string, locationUrl?: string}>} entries
1365
+ * @param {Array<Object>} failures the driver's recent failed requests
1366
+ * @returns {Array<Object>}
1367
+ */
1368
+ export function attachNetworkFailures(entries, failures) {
1369
+ return entries.map(entry => {
1370
+ const { locationUrl, ...rest } = entry;
1371
+ if (!FAILED_RESOURCE.test(String(entry.text || ''))) return rest;
1372
+ const status = Number((String(entry.text).match(/status of (\d{3})/) || [])[1]) || null;
1373
+ const location = locationUrl ? networkUrl(locationUrl) : null;
1374
+ let match = location ? failures.find(failure => !failure.claimed && failure.url === location) : null;
1375
+ if (!match) {
1376
+ match = failures.find(failure => !failure.claimed && (status ? failure.status === status : failure.status === null && failure.error && String(entry.text).includes(failure.error)));
1377
+ }
1378
+ const url = match ? match.url : location;
1379
+ if (!url) return rest;
1380
+ if (match) match.claimed = true;
1381
+ const shownStatus = match && match.status ? match.status : status;
1382
+ const method = match && match.method ? `${match.method} ` : '';
1383
+ return { ...rest, text: `${rest.text} (${method}${url}${shownStatus ? ` answered ${shownStatus}` : ''})`, url, status: shownStatus || null };
1384
+ });
1385
+ }
1386
+
811
1387
  /**
812
1388
  * Playwright names its recording with a random id. The upload allowlist and
813
1389
  * the server's 90 day lifecycle rule are written against `session.webm`, so