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.
- package/README.md +181 -20
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +240 -21
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +389 -79
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +308 -24
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +1 -1
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/run/drivers/web.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}>}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
}
|