ravensight-playtest 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,148 @@ 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
+
84
136
  /**
85
137
  * The kept nodes of an aria snapshot with the depth each one had, or null for
86
138
  * text that is not an aria snapshot. The depth is not shown to the model; it
87
139
  * is what tells repeated siblings apart from a panel that follows them.
88
140
  *
141
+ * An interactive node from Playwright's AI mode snapshot carries its short ref
142
+ * (`[e12]`), which `click` and `type` accept as a target, and, when it is on
143
+ * screen, its centre `@(x,y)`. Two interactive nodes with the same role and
144
+ * name each get a hint naming the nearest landmark or named container around
145
+ * them ("in header", "in main"), so the persona can tell them apart.
146
+ *
147
+ * `hashLine` is the line without the centre: what "changed" and "new since
148
+ * the last screen" compare, so a scroll or an animation does not read as a
149
+ * new screen.
150
+ *
89
151
  * @param {string} text
90
- * @returns {Array<{depth: number, line: string, body: string, role: string}>|null}
152
+ * @param {{viewport?: {width: number, height: number}}} [options]
153
+ * @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
154
  */
92
- export function compactAriaNodes(text) {
155
+ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
93
156
  const value = String(text == null ? '' : text);
94
157
  const lines = value.split('\n');
95
158
  if (!lines.some(line => /^\s*- /.test(line))) return null;
96
159
  const kept = [];
160
+ const ancestors = [];
97
161
  for (const line of lines) {
98
162
  const trimmed = line.trim();
99
163
  if (!trimmed.startsWith('- ')) continue;
100
- const body = unquoteKey(trimmed.slice(2));
101
- if (body.startsWith('/')) continue;
102
- const role = (body.match(/^([a-z]+)/) || [])[1] || '';
164
+ const depth = line.length - line.trimStart().length;
165
+ const raw = unquoteKey(trimmed.slice(2));
166
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1].depth >= depth) ancestors.pop();
167
+ if (raw.startsWith('/')) continue;
168
+ const role = (raw.match(/^([a-z]+)/) || [])[1] || '';
169
+ const name = (raw.match(/^[a-z]+ "([^"]*)"/) || [])[1] ?? null;
170
+ const ref = (raw.match(REF_ATTRIBUTE) || [])[1] || null;
171
+ const boxMatch = raw.match(BOX_ATTRIBUTE);
172
+ const box = boxMatch ? boxMatch.slice(1, 5).map(Number) : null;
173
+ const body = raw.replace(NOISE_ATTRIBUTE, '');
174
+ ancestors.push({ depth, role, name });
103
175
  const named = /^[a-z]+ "[^"]*"/.test(body);
104
176
  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 });
107
- }
177
+ const interactive = INTERACTIVE_ROLES.has(role);
178
+ if (!(interactive || named || inlineText)) continue;
179
+ const container = ancestors.slice(0, -1).reverse().find(entry => LANDMARK_LABELS[entry.role] || entry.name);
180
+ const hint = container
181
+ ? (container.name ? `${container.name}${LANDMARK_LABELS[container.role] && container.role !== 'region' ? ` ${LANDMARK_LABELS[container.role]}` : ''}` : LANDMARK_LABELS[container.role])
182
+ : null;
183
+ kept.push({
184
+ depth,
185
+ body,
186
+ role,
187
+ name,
188
+ ref: interactive ? ref : null,
189
+ centre: interactive ? visibleCentre(box, viewport) : null,
190
+ hint,
191
+ key: interactive && name ? `${role}|${name}` : null,
192
+ duplicate: false,
193
+ line: '',
194
+ hashLine: ''
195
+ });
196
+ }
197
+ const counts = new Map();
198
+ for (const node of kept) if (node.key) counts.set(node.key, (counts.get(node.key) || 0) + 1);
199
+ for (const node of kept) {
200
+ node.duplicate = Boolean(node.key) && counts.get(node.key) > 1;
201
+ const split = node.body.match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(.*)$/s) || [null, node.body, ''];
202
+ const head = split[1];
203
+ const tail = split[2];
204
+ const refPart = node.ref ? ` [${node.ref}]` : '';
205
+ const hintPart = node.duplicate && node.hint ? ` (in ${node.hint})` : '';
206
+ const centrePart = node.centre ? ` @(${node.centre.x},${node.centre.y})` : '';
207
+ node.hashLine = `- ${head}${refPart}${hintPart}${tail}`;
208
+ node.line = `- ${head}${refPart}${centrePart}${hintPart}${tail}`;
108
209
  }
109
210
  return kept;
110
211
  }
@@ -151,6 +252,8 @@ function nodeShape(body) {
151
252
  * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
152
253
  */
153
254
  export function collapseRepeatedSiblings(nodes) {
255
+ const keyCounts = new Map();
256
+ for (const node of nodes) if (node.key) keyCounts.set(node.key, (keyCounts.get(node.key) || 0) + 1);
154
257
  const unitEnd = start => {
155
258
  let end = start + 1;
156
259
  while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
@@ -171,15 +274,36 @@ export function collapseRepeatedSiblings(nodes) {
171
274
  next = end;
172
275
  }
173
276
  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 });
277
+ // A node whose role and name also appear outside this run is never
278
+ // hidden in the count: two buttons called "Run the week", one in the
279
+ // header and one in the main panel, both stay visible with their refs.
280
+ const inRun = new Map();
281
+ for (const node of nodes.slice(i, next)) if (node.key) inRun.set(node.key, (inRun.get(node.key) || 0) + 1);
282
+ // Only the first in-run twin of a name that also appears outside the
283
+ // run is kept, beside the first two items. One "View" button in the
284
+ // header must not stop a list of 249 "View" cards from collapsing.
285
+ const outside = key => key && (keyCounts.get(key) || 0) > (inRun.get(key) || 0);
286
+ const shownKeys = new Set();
287
+ let more = 0;
288
+ units.forEach((unit, index) => {
289
+ const unitNodes = nodes.slice(unit[0], unit[1]);
290
+ const twins = unitNodes.map(node => node.key).filter(key => outside(key) && !shownKeys.has(key));
291
+ if (index < COLLAPSE_KEEP || twins.length > 0) {
292
+ for (const node of unitNodes) {
293
+ entries.push({ line: node.line, hashLine: node.hashLine, role: node.role, key: node.key });
294
+ if (node.key) shownKeys.add(node.key);
295
+ }
296
+ } else {
297
+ more += 1;
298
+ }
299
+ });
300
+ if (more > 0) {
301
+ collapsed += more;
302
+ entries.push({ line: `- ... and ${more} more like it (${nodes[i].role || 'items'}; target one by its name)`, role: '', marker: true });
176
303
  }
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
304
  i = next;
181
305
  } else {
182
- entries.push({ line: nodes[i].line, role: nodes[i].role });
306
+ entries.push({ line: nodes[i].line, hashLine: nodes[i].hashLine, role: nodes[i].role, key: nodes[i].key });
183
307
  i += 1;
184
308
  }
185
309
  }
@@ -204,12 +328,12 @@ export function collapseRepeatedSiblings(nodes) {
204
328
  * @param {{max?: number, previous?: Set<string>|null}} [options]
205
329
  * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
206
330
  */
207
- export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null } = {}) {
208
- const nodes = compactAriaNodes(text);
331
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT } = {}) {
332
+ const nodes = compactAriaNodes(text, { viewport });
209
333
  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 };
334
+ const lines = nodes.map(node => node.hashLine);
335
+ const full = nodes.map(node => node.line).join('\n');
336
+ if (full.length <= max) return { text: full, lines, nodes };
213
337
 
214
338
  const { entries, collapsed } = collapseRepeatedSiblings(nodes);
215
339
  const header = (left, out) => {
@@ -220,7 +344,7 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
220
344
  };
221
345
  const collapsedText = entries.map(entry => entry.line).join('\n');
222
346
  const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
223
- if (withHeader.length <= max) return { text: withHeader, lines };
347
+ if (withHeader.length <= max) return { text: withHeader, lines, nodes };
224
348
 
225
349
  const rank = entry => {
226
350
  if (entry.marker) return 0;
@@ -229,7 +353,7 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
229
353
  if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
230
354
  return 5;
231
355
  };
232
- const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.line);
356
+ const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.hashLine || entry.line);
233
357
  // The last interactive nodes on the page are usually its form or its
234
358
  // confirm button (a panel after a list). When the list could not be
235
359
  // collapsed they would otherwise lose to the list on page order, so the
@@ -256,9 +380,21 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
256
380
  chosen.add(candidate.index);
257
381
  used += cost;
258
382
  }
383
+ // Duplicates by name travel together: showing one "Run the week" and not
384
+ // the other is how a persona ends up clicking a name that matches twice.
385
+ const chosenKeys = new Set(entries.filter((entry, index) => chosen.has(index) && entry.key).map(entry => entry.key));
386
+ // Bounded by the cap's own slack: a page whose every card shares a name
387
+ // is the trim's job, not this one's.
388
+ entries.forEach((entry, index) => {
389
+ if (!entry.key || !chosenKeys.has(entry.key) || chosen.has(index)) return;
390
+ const cost = entry.line.length + 1;
391
+ if (used + cost > budget) return;
392
+ chosen.add(index);
393
+ used += cost;
394
+ });
259
395
  const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
260
396
  const out = entries.length - kept.length;
261
- return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines };
397
+ return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
262
398
  }
263
399
 
264
400
  /**
@@ -420,6 +556,78 @@ export function isLoopback(hostname) {
420
556
  return LOOPBACK_HOSTS.includes(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
421
557
  }
422
558
 
559
+ /**
560
+ * A target written as locator code rather than as something on screen. A
561
+ * persona that writes `getByTestId('strip-advance')` means a selector, and
562
+ * searching the page for that literal text waits the whole action timeout
563
+ * for nothing.
564
+ */
565
+ export const LOCATOR_CODE = /^\s*(?:getBy|page\.|locator\(|css=|xpath=|\/\/)/;
566
+
567
+ /** What the persona is told when it writes locator code as a target. */
568
+ export const LOCATOR_CODE_MESSAGE = 'targets are refs or visible text, not locator code';
569
+
570
+ export { STALE_REF_MESSAGE };
571
+
572
+ /** A ref as the snapshot prints it. */
573
+ export const REF_PATTERN = /^e\d{1,6}$/;
574
+
575
+ /**
576
+ * A failure on the driver's side, not the game's: a target that names two
577
+ * elements, a target that is not a target, a ref that is not on screen. The
578
+ * transcript records these as `driver_error`, and the report step is told
579
+ * they are never evidence of a game bug.
580
+ */
581
+ export class TargetError extends Error {
582
+ constructor(message) {
583
+ super(message);
584
+ this.name = 'TargetError';
585
+ this.driverSide = true;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * The candidates a target could mean, from the last snapshot: the
591
+ * interactive nodes whose name is the target (or whose role and name are,
592
+ * for `button "Start"`), with their refs and hints.
593
+ *
594
+ * @param {Array<Object>|null} nodes
595
+ * @param {string} target
596
+ * @returns {Array<Object>}
597
+ */
598
+ export function candidatesFor(nodes, target) {
599
+ if (!Array.isArray(nodes)) return [];
600
+ const value = String(target).trim();
601
+ const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
602
+ return nodes.filter(node => node.key && node.ref && (roleAndName
603
+ ? node.role === roleAndName[1] && node.name === roleAndName[2]
604
+ : String(node.name).trim().toLowerCase() === value.toLowerCase()));
605
+ }
606
+
607
+ /** The most candidates an ambiguity answer lists before "and N more". */
608
+ export const AMBIGUITY_LIST_MAX = 5;
609
+
610
+ /**
611
+ * "2 elements match "Run the week"; use ref e3 (in header) or e5 (in main)".
612
+ *
613
+ * @param {string} target
614
+ * @param {Array<Object>} candidates
615
+ * @param {number} [count] how many Playwright said matched, when known
616
+ * @returns {string}
617
+ */
618
+ export function ambiguityMessage(target, candidates, count = null) {
619
+ const total = Math.max(count || 0, candidates.length);
620
+ const shown = candidates.slice(0, AMBIGUITY_LIST_MAX);
621
+ const listed = shown.map(node => `${node.ref}${node.hint ? ` (in ${node.hint})` : ''}`);
622
+ const more = candidates.length - shown.length;
623
+ const choice = more > 0
624
+ ? `${listed.join(', ')} and ${more} more`
625
+ : listed.length > 1 ? `${listed.slice(0, -1).join(', ')} or ${listed[listed.length - 1]}` : listed[0];
626
+ return listed.length > 0
627
+ ? `${total} elements match ${JSON.stringify(String(target))}; use ref ${choice}`
628
+ : `${total} elements match ${JSON.stringify(String(target))}; take a snapshot and use the ref of the one you mean`;
629
+ }
630
+
423
631
  /**
424
632
  * Turn what the model wrote into a Playwright locator.
425
633
  *
@@ -435,6 +643,7 @@ export function isLoopback(hostname) {
435
643
  */
436
644
  export function resolveLocator(page, target) {
437
645
  const value = String(target).trim();
646
+ if (LOCATOR_CODE.test(value)) throw new TargetError(LOCATOR_CODE_MESSAGE);
438
647
  if (KNOWN_SELECTOR_ENGINES.test(value)) return page.locator(value);
439
648
 
440
649
  // The name group cannot contain a quote (`[^"]*`), so there is nothing to
@@ -448,6 +657,16 @@ export function resolveLocator(page, target) {
448
657
  return page.getByText(value, { exact: false }).first();
449
658
  }
450
659
 
660
+ /**
661
+ * Give the document a random token. Runs in the browser at the start of every
662
+ * new document, so a reload or a navigation gets a new one and a single page
663
+ * app's route change keeps it. Serialised by Playwright, so it must not close
664
+ * over anything.
665
+ */
666
+ function markDocument() {
667
+ globalThis.__ravensightDoc = `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
668
+ }
669
+
451
670
  /**
452
671
  * Hand the run's playtest identity to the page. Runs in the browser, before
453
672
  * any of the game's own scripts, so the Ravensight JS SDK finds it at
@@ -492,6 +711,15 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
492
711
  let lastObservedHash = null;
493
712
  /** The compacted lines of the last observation, so a cut can prefer what is new. */
494
713
  let lastObservedLines = null;
714
+ /** The kept nodes of the last observation: refs, names and hints, for a target that matches twice. */
715
+ let lastNodes = null;
716
+ let viewportSize = DEFAULT_VIEWPORT;
717
+ // Refs are numbered per document: after a navigation or a reload, e12 can
718
+ // be a different element. Each document gets a random token from an init
719
+ // script; a pushState or hash route change keeps it, a new document does
720
+ // not. The token the last observation (and so its refs) came from is
721
+ // compared with the current one before any ref is used.
722
+ let lastNodesDocument = null;
495
723
  const consoleBuffer = [];
496
724
 
497
725
  /** @returns {any} */
@@ -510,7 +738,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
510
738
  let text = '';
511
739
  let aria = true;
512
740
  const body = current.locator('body');
513
- if (typeof body.ariaSnapshot === 'function') {
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') {
514
752
  try {
515
753
  text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
516
754
  } catch {
@@ -545,10 +783,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
545
783
  */
546
784
  function shownText(snap) {
547
785
  if (snap.aria) {
548
- const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null });
786
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize });
549
787
  if (fitted) return fitted;
550
788
  }
551
- return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null };
789
+ return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
552
790
  }
553
791
 
554
792
  /**
@@ -580,7 +818,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
580
818
  */
581
819
  async function typeText(current, action) {
582
820
  const text = String(action.text == null ? '' : action.text);
583
- const field = action.target ? resolveLocator(current, action.target) : null;
821
+ const field = action.target || action.ref ? await targetFor(current, action) : null;
584
822
  if (action.replace) {
585
823
  try {
586
824
  await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
@@ -598,8 +836,75 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
598
836
  if (text) await current.keyboard.type(text, { delay: 20 });
599
837
  }
600
838
 
839
+ /**
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.
844
+ *
845
+ * @param {any} current
846
+ * @param {{ref?: string, target?: string}} action
847
+ */
848
+ 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`);
852
+ 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;
858
+ }
859
+ const target = String(action.target == null ? '' : action.target);
860
+ const candidates = candidatesFor(lastNodes, target);
861
+ if (candidates.length > 1) throw new TargetError(ambiguityMessage(target, candidates));
862
+ if (LOCATOR_CODE.test(target)) {
863
+ // Text that only looks like code: a node on screen really is named
864
+ // that, so it is clicked by its ref rather than refused.
865
+ if (candidates.length === 1) {
866
+ await assertSameDocument(current);
867
+ return current.locator(`aria-ref=${candidates[0].ref}`);
868
+ }
869
+ throw new TargetError(LOCATOR_CODE_MESSAGE);
870
+ }
871
+ return resolveLocator(current, target);
872
+ }
873
+
874
+ /**
875
+ * The current document's token, read from the main frame only (a child
876
+ * frame has its own document and its own token), or null when the page
877
+ * cannot answer.
878
+ */
879
+ async function documentToken(current) {
880
+ try {
881
+ const token = await current.evaluate(() => globalThis.__ravensightDoc || null);
882
+ return typeof token === 'string' && token ? token : null;
883
+ } catch {
884
+ return null;
885
+ }
886
+ }
887
+
888
+ /** Refuse a ref when the document changed since the observation it came from. */
889
+ async function assertSameDocument(current) {
890
+ const now = await documentToken(current);
891
+ if (lastNodesDocument && now !== lastNodesDocument) throw new TargetError(STALE_REF_MESSAGE);
892
+ }
893
+
894
+ /** The hash of the screen as it is now, on observe()'s basis. */
895
+ async function currentHash(current) {
896
+ const snap = await snapshotText();
897
+ const after = { text: hashText(snap, shownText(snap)) };
898
+ if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
899
+ const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
900
+ after.screenshotBase64 = frame.toString('base64');
901
+ }
902
+ return observationHash(after);
903
+ }
904
+
601
905
  return {
602
906
  name: 'playwright_web',
907
+ supportsRefs: true,
603
908
  editModifier,
604
909
  keepObservations: WEB_KEEP_OBSERVATIONS,
605
910
 
@@ -613,6 +918,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
613
918
  async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
614
919
  if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
615
920
  target = url;
921
+ viewportSize = viewport || DEFAULT_VIEWPORT;
616
922
  runDirectory = runDir;
617
923
  videoRequested = Boolean(recordVideo);
618
924
 
@@ -636,6 +942,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
636
942
  persona: playtest.persona || null
637
943
  });
638
944
  }
945
+ // One token per document: set again by every new document, kept by
946
+ // pushState and hash route changes, which do not run init scripts.
947
+ if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
639
948
  page = await context.newPage();
640
949
  page.on('console', message => {
641
950
  if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
@@ -692,6 +1001,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
692
1001
  // because a fit keeps new nodes first), the truncated text otherwise.
693
1002
  lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
694
1003
  lastObservedLines = shown.lines;
1004
+ lastNodes = shown.nodes || null;
1005
+ lastNodesDocument = await documentToken(current);
695
1006
  // The same screen twice is sent as a marker rather than in full
696
1007
  // (./tools.js), which is most of what a stuck persona would otherwise
697
1008
  // pay for.
@@ -717,11 +1028,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
717
1028
  if (action.submit) await current.keyboard.press('Enter');
718
1029
  break;
719
1030
  case 'click': {
720
- if (action.target && typeof action.target === 'object') {
1031
+ if (!action.ref && action.target && typeof action.target === 'object') {
721
1032
  await current.mouse.click(Number(action.target.x), Number(action.target.y));
722
1033
  break;
723
1034
  }
724
- await resolveLocator(current, action.target).click({ timeout: ACTION_TIMEOUT_MS });
1035
+ await (await targetFor(current, action)).click({ timeout: ACTION_TIMEOUT_MS });
725
1036
  break;
726
1037
  }
727
1038
  case 'navigate': {
@@ -739,20 +1050,44 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
739
1050
  return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
740
1051
  }
741
1052
  } catch (error) {
742
- return { ok: false, changed: false, error: String(error && error.message ? error.message : error) };
1053
+ // Playwright colours its call log; the colour codes are noise here.
1054
+ const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
1055
+ // Two elements under one name: Playwright's strict mode. Answered
1056
+ // with the candidates and their refs, so the next try can work.
1057
+ if (/strict mode violation/i.test(text)) {
1058
+ const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
1059
+ const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
1060
+ return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
1061
+ }
1062
+ if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
1063
+ // A click the page kept intercepting is the game's: something sat on
1064
+ // top of the target and never went away. Named, never a driver error.
1065
+ const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
1066
+ const intercepted = interceptor ? interceptor[1] : null;
1067
+ if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
1068
+ return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
1069
+ }
1070
+ // A timeout with the page unchanged while resolving a click or typing
1071
+ // target means the target was never found or never actionable: the
1072
+ // driver's failure, not the game's. Never for navigate: a game that
1073
+ // never loads is a real blocker.
1074
+ if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
1075
+ let unchanged = false;
1076
+ try {
1077
+ unchanged = (await currentHash(current)) === before;
1078
+ } catch {
1079
+ unchanged = false;
1080
+ }
1081
+ return { ok: false, changed: false, error: text, driverError: unchanged };
1082
+ }
1083
+ return { ok: false, changed: false, error: text };
743
1084
  }
744
1085
 
745
1086
  let changed = false;
746
1087
  try {
747
1088
  // The same basis observe() hashes, so the two are comparable.
748
1089
  // 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;
1090
+ changed = (await currentHash(current)) !== before;
756
1091
  } catch {
757
1092
  changed = true;
758
1093
  }