ravensight-playtest 0.3.1 → 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.
@@ -263,6 +263,60 @@ export async function readControlValues(page, text) {
263
263
  return values;
264
264
  }
265
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
+
266
320
  /**
267
321
  * The kept nodes of an aria snapshot with the depth each one had, or null for
268
322
  * text that is not an aria snapshot. The depth is not shown to the model; it
@@ -366,6 +420,13 @@ export const COLLAPSE_MIN_RUN = 4;
366
420
  /** How many of a collapsed run are still shown, so the model sees what the items look like. */
367
421
  export const COLLAPSE_KEEP = 2;
368
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
+
369
430
  /**
370
431
  * The shape of one node, without the words that make it unique: its role,
371
432
  * its state attributes, whether it has a name, whether the name carries
@@ -390,9 +451,15 @@ function nodeShape(body) {
390
451
  * says how many more there are.
391
452
  *
392
453
  * @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
393
- * @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
394
461
  */
395
- export function collapseRepeatedSiblings(nodes) {
462
+ export function collapseRepeatedSiblings(nodes, { minRun = COLLAPSE_MIN_RUN, keepNamedTwins = false } = {}) {
396
463
  const keyCounts = new Map();
397
464
  for (const node of nodes) if (node.key) keyCounts.set(node.key, (keyCounts.get(node.key) || 0) + 1);
398
465
  const unitEnd = start => {
@@ -403,6 +470,8 @@ export function collapseRepeatedSiblings(nodes) {
403
470
  const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
404
471
  const entries = [];
405
472
  let collapsed = 0;
473
+ let longest = nodes.length > 0 ? 1 : 0;
474
+ let longestTwins = false;
406
475
  let i = 0;
407
476
  while (i < nodes.length) {
408
477
  const units = [[i, unitEnd(i)]];
@@ -414,7 +483,23 @@ export function collapseRepeatedSiblings(nodes) {
414
483
  units.push([next, end]);
415
484
  next = end;
416
485
  }
417
- 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) {
418
503
  // A node whose role and name also appear outside this run is never
419
504
  // hidden in the count: two buttons called "Run the week", one in the
420
505
  // header and one in the main panel, both stay visible with their refs.
@@ -448,14 +533,17 @@ export function collapseRepeatedSiblings(nodes) {
448
533
  i += 1;
449
534
  }
450
535
  }
451
- return { entries, collapsed };
536
+ return { entries, collapsed, longest, longestTwins };
452
537
  }
453
538
 
454
539
  /**
455
540
  * Fit a compacted aria snapshot under the web ceiling without losing the
456
541
  * part of the page the persona has to act on.
457
542
  *
458
- * 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
459
547
  * are what is kept (the last tenth of them on the page first, since that is
460
548
  * where a form after a long list sits, then inputs, then buttons and links,
461
549
  * then any role with a name); runs of look-alike siblings collapse to their
@@ -474,15 +562,19 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
474
562
  if (nodes === null) return null;
475
563
  const lines = nodes.map(node => node.hashLine);
476
564
  const full = nodes.map(node => node.line).join('\n');
477
- if (full.length <= max) return { text: full, lines, nodes };
478
-
479
- const { entries, collapsed } = collapseRepeatedSiblings(nodes);
480
565
  const header = (left, out) => {
481
566
  const parts = [];
482
567
  if (left > 0) parts.push(`${left} similar items collapsed`);
483
568
  if (out > 0) parts.push(`${out} lines left out to fit`);
484
569
  return parts.length > 0 ? `[${parts.join('; ')}]` : '';
485
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);
486
578
  const collapsedText = entries.map(entry => entry.line).join('\n');
487
579
  const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
488
580
  if (withHeader.length <= max) return { text: withHeader, lines, nodes };
@@ -538,6 +630,113 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
538
630
  return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
539
631
  }
540
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
+
541
740
  /**
542
741
  * Playwright writes a node key as a single quoted YAML scalar when the name
543
742
  * contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
@@ -769,6 +968,73 @@ export function ambiguityMessage(target, candidates, count = null) {
769
968
  : `${total} elements match ${JSON.stringify(String(target))}; take a snapshot and use the ref of the one you mean`;
770
969
  }
771
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
+
772
1038
  /**
773
1039
  * Turn what the model wrote into a Playwright locator.
774
1040
  *
@@ -791,11 +1057,19 @@ export function resolveLocator(page, target) {
791
1057
  // escape when it is spliced back into the `role=` engine. A target whose
792
1058
  // name really does contain a quote falls through to the text search below.
793
1059
  const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
794
- 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]}"]`));
795
1063
 
796
1064
  if (/^[#.[]/.test(value) || /^[a-z][a-z0-9-]*(\[|\.|#|:| >)/i.test(value)) return page.locator(value);
797
1065
 
798
- 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;
799
1073
  }
800
1074
 
801
1075
  /**
@@ -815,7 +1089,7 @@ function markDocument() {
815
1089
  *
816
1090
  * @param {{token: string, runId: string, jobId: string, persona: string}} identity
817
1091
  */
818
- function injectPlaytestIdentity(identity) {
1092
+ export function injectPlaytestIdentity(identity) {
819
1093
  window.__RAVENSIGHT_PLAYTEST__ = identity;
820
1094
  try {
821
1095
  localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
@@ -825,6 +1099,15 @@ function injectPlaytestIdentity(identity) {
825
1099
  }
826
1100
  }
827
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
+
828
1111
  /**
829
1112
  * @param {Object} opts
830
1113
  * @param {(specifier: string) => Promise<any>} [opts.importer] test seam
@@ -872,53 +1155,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
872
1155
  }
873
1156
 
874
1157
  /**
875
- * @returns {Promise<{text: string, aria: boolean}>} the screen text, and
876
- * whether it is the aria snapshot (compacted) or the innerText fallback
877
- * (never compacted: a bullet list in the page is not snapshot structure)
1158
+ * @returns {Promise<{text: string, aria: boolean}>} see {@link readSnapshot}
878
1159
  */
879
1160
  async function snapshotText() {
880
- const current = requirePage();
881
- let text = '';
882
- let aria = true;
883
- let values = null;
884
- const body = current.locator('body');
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') {
900
- try {
901
- text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
902
- } catch {
903
- text = '';
904
- }
905
- }
906
- if (text.trim().length < THIN_OBSERVATION_CHARS) {
907
- try {
908
- const inner = await current.evaluate(() => {
909
- const el = globalThis.document && globalThis.document.body;
910
- return el ? el.innerText : '';
911
- });
912
- if (String(inner).trim().length > text.trim().length) {
913
- text = String(inner);
914
- aria = false;
915
- }
916
- } catch {
917
- // A page mid-navigation has no body to read. The thin observation
918
- // is the honest answer, and the screenshot fallback below covers it.
919
- }
920
- }
921
- return { text, aria, values };
1161
+ return readSnapshot(requirePage());
922
1162
  }
923
1163
 
924
1164
  /**
@@ -966,11 +1206,19 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
966
1206
  */
967
1207
  async function typeText(current, action) {
968
1208
  const text = String(action.text == null ? '' : action.text);
969
- 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);
970
1218
  if (action.replace) {
971
1219
  try {
972
- await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
973
- return;
1220
+ await subject.fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
1221
+ return { before, after: await fieldValue(subject) };
974
1222
  } catch {
975
1223
  // Not something fill can write to. Focus it if it was named, then
976
1224
  // clear it the way a player on this machine would.
@@ -982,29 +1230,51 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
982
1230
  await field.click({ timeout: ACTION_TIMEOUT_MS });
983
1231
  }
984
1232
  if (text) await current.keyboard.type(text, { delay: 20 });
1233
+ return { before, after: await fieldValue(subject) };
1234
+ }
1235
+
1236
+ /**
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;
985
1249
  }
986
1250
 
987
1251
  /**
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.
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.
992
1260
  *
993
1261
  * @param {any} current
994
1262
  * @param {{ref?: string, target?: string}} action
995
1263
  */
996
1264
  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`);
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.
1000
1275
  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;
1276
+ return { point: { x: parsed.x, y: parsed.y } };
1006
1277
  }
1007
- const target = String(action.target == null ? '' : action.target);
1008
1278
  const candidates = candidatesFor(lastNodes, target);
1009
1279
  if (candidates.length > 1) throw new TargetError(ambiguityMessage(target, candidates));
1010
1280
  if (LOCATOR_CODE.test(target)) {
@@ -1016,6 +1286,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1016
1286
  }
1017
1287
  throw new TargetError(LOCATOR_CODE_MESSAGE);
1018
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
+ }
1019
1299
  return resolveLocator(current, target);
1020
1300
  }
1021
1301
 
@@ -1033,10 +1313,24 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1033
1313
  }
1034
1314
  }
1035
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
+
1036
1325
  /** Refuse a ref when the document changed since the observation it came from. */
1037
1326
  async function assertSameDocument(current) {
1038
- const now = await documentToken(current);
1039
- 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;
1040
1334
  }
1041
1335
 
1042
1336
  /** The hash of the screen as it is now, on observe()'s basis. */
@@ -1193,13 +1487,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1193
1487
  async act(action) {
1194
1488
  const current = requirePage();
1195
1489
  const before = lastHash;
1490
+ const urlBefore = pageUrl(current);
1491
+ const tokenBefore = await documentToken(current);
1492
+ let typed = null;
1196
1493
  try {
1197
1494
  switch (action.kind) {
1198
1495
  case 'key':
1199
1496
  await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
1200
1497
  break;
1201
1498
  case 'type':
1202
- await typeText(current, action);
1499
+ typed = await typeText(current, action);
1203
1500
  if (action.submit) await current.keyboard.press('Enter');
1204
1501
  break;
1205
1502
  case 'click': {
@@ -1207,7 +1504,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1207
1504
  await current.mouse.click(Number(action.target.x), Number(action.target.y));
1208
1505
  break;
1209
1506
  }
1210
- 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 });
1211
1510
  break;
1212
1511
  }
1213
1512
  case 'navigate': {
@@ -1258,11 +1557,22 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1258
1557
  return { ok: false, changed: false, error: text };
1259
1558
  }
1260
1559
 
1261
- let changed = false;
1262
- 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 };
1263
1562
  // The same basis observe() hashes, so the two are comparable.
1264
1563
  // Without it a long screen is always "changed".
1265
- 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
+ }
1266
1576
  } catch {
1267
1577
  changed = true;
1268
1578
  }