ravensight-playtest 0.2.7 → 0.2.9

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.
@@ -35,6 +35,12 @@ export const MAX_WAIT_MS = 10000;
35
35
  /** How long a single Playwright call may take before it is an error, not a hang. */
36
36
  export const ACTION_TIMEOUT_MS = 15000;
37
37
 
38
+ /**
39
+ * How long a replace waits for a focused field. Short, because nothing
40
+ * focused is an ordinary answer (a canvas game), not a page still loading.
41
+ */
42
+ export const FOCUSED_FILL_TIMEOUT_MS = 2000;
43
+
38
44
  /**
39
45
  * The web observation ceiling. An aria snapshot is re-sent on every turn the
40
46
  * observation stays in context, and the run's model budget is fixed, so a busy
@@ -71,9 +77,22 @@ const INTERACTIVE_ROLES = new Set([
71
77
  * @returns {string}
72
78
  */
73
79
  export function compactAriaSnapshot(text) {
80
+ const nodes = compactAriaNodes(text);
81
+ return nodes === null ? String(text == null ? '' : text) : nodes.map(node => node.line).join('\n');
82
+ }
83
+
84
+ /**
85
+ * The kept nodes of an aria snapshot with the depth each one had, or null for
86
+ * text that is not an aria snapshot. The depth is not shown to the model; it
87
+ * is what tells repeated siblings apart from a panel that follows them.
88
+ *
89
+ * @param {string} text
90
+ * @returns {Array<{depth: number, line: string, body: string, role: string}>|null}
91
+ */
92
+ export function compactAriaNodes(text) {
74
93
  const value = String(text == null ? '' : text);
75
94
  const lines = value.split('\n');
76
- if (!lines.some(line => /^\s*- /.test(line))) return value;
95
+ if (!lines.some(line => /^\s*- /.test(line))) return null;
77
96
  const kept = [];
78
97
  for (const line of lines) {
79
98
  const trimmed = line.trim();
@@ -83,9 +102,163 @@ export function compactAriaSnapshot(text) {
83
102
  const role = (body.match(/^([a-z]+)/) || [])[1] || '';
84
103
  const named = /^[a-z]+ "[^"]*"/.test(body);
85
104
  const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
86
- if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
105
+ if (INTERACTIVE_ROLES.has(role) || named || inlineText) {
106
+ kept.push({ depth: line.length - line.trimStart().length, line: `- ${body}`, body, role });
107
+ }
108
+ }
109
+ return kept;
110
+ }
111
+
112
+ /** Roles a player types into or sets, kept first when an observation has to be cut. */
113
+ const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'listbox']);
114
+
115
+ /** The share of interactive nodes, counted from the end of the page, kept ahead of the middle when a page is cut. */
116
+ export const TAIL_FRACTION = 0.1;
117
+
118
+ /** The tail window is never smaller than this many interactive nodes. */
119
+ const TAIL_MIN = 3;
120
+
121
+ /** A run of this many look-alike siblings or more is collapsed when the page is over the cap. */
122
+ export const COLLAPSE_MIN_RUN = 4;
123
+
124
+ /** How many of a collapsed run are still shown, so the model sees what the items look like. */
125
+ export const COLLAPSE_KEEP = 2;
126
+
127
+ /**
128
+ * The shape of one node, without the words that make it unique: its role,
129
+ * its state attributes, whether it has a name, whether the name carries
130
+ * digits and how many separators. Two school cards share a shape; the card
131
+ * list and a "TAKE THE JOB" button after it do not.
132
+ *
133
+ * @param {string} body
134
+ * @returns {string}
135
+ */
136
+ function nodeShape(body) {
137
+ const role = (body.match(/^([a-z]+)/) || [])[1] || '';
138
+ const name = (body.match(/^[a-z]+ "([^"]*)"/) || [])[1];
139
+ const attributes = [...body.matchAll(/\[([a-z-]+)(?:=[^\]]*)?\]/g)].map(match => match[1]).filter(key => key !== 'ref').join(',');
140
+ const nameShape = name === undefined ? '' : `n${/\d/.test(name) ? 'd' : ''}${(name.match(/[·•|/]/g) || []).length}`;
141
+ const inline = /: \S/.test(body.replace(/^[a-z]+(?: "[^"]*")?/, '')) ? 't' : '';
142
+ return `${role}|${nameShape}|${attributes}|${inline}`;
143
+ }
144
+
145
+ /**
146
+ * Collapse runs of look-alike siblings (a node and its kept descendants,
147
+ * compared by shape) into the first {@link COLLAPSE_KEEP} plus one line that
148
+ * says how many more there are.
149
+ *
150
+ * @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
151
+ * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
152
+ */
153
+ export function collapseRepeatedSiblings(nodes) {
154
+ const unitEnd = start => {
155
+ let end = start + 1;
156
+ while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
157
+ return end;
158
+ };
159
+ const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
160
+ const entries = [];
161
+ let collapsed = 0;
162
+ let i = 0;
163
+ while (i < nodes.length) {
164
+ const units = [[i, unitEnd(i)]];
165
+ const sig = signature(i, units[0][1]);
166
+ let next = units[0][1];
167
+ while (next < nodes.length && nodes[next].depth === nodes[i].depth) {
168
+ const end = unitEnd(next);
169
+ if (signature(next, end) !== sig) break;
170
+ units.push([next, end]);
171
+ next = end;
172
+ }
173
+ 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 });
176
+ }
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
+ i = next;
181
+ } else {
182
+ entries.push({ line: nodes[i].line, role: nodes[i].role });
183
+ i += 1;
184
+ }
185
+ }
186
+ return { entries, collapsed };
187
+ }
188
+
189
+ /**
190
+ * Fit a compacted aria snapshot under the web ceiling without losing the
191
+ * part of the page the persona has to act on.
192
+ *
193
+ * Under the cap nothing changes. Over it, in this order: interactive nodes
194
+ * are what is kept (the last tenth of them on the page first, since that is
195
+ * where a form after a long list sits, then inputs, then buttons and links,
196
+ * then any role with a name); runs of look-alike siblings collapse to their
197
+ * first two and a count; nodes that are new since the previous observation
198
+ * come next; then
199
+ * the rest, and only what still does not fit is trimmed. Kept lines stay in
200
+ * page order, and a first line says how many items were collapsed and how
201
+ * many lines were left out, so the model knows the screen was cut.
202
+ *
203
+ * @param {string} text the raw aria snapshot
204
+ * @param {{max?: number, previous?: Set<string>|null}} [options]
205
+ * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
206
+ */
207
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null } = {}) {
208
+ const nodes = compactAriaNodes(text);
209
+ 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 };
213
+
214
+ const { entries, collapsed } = collapseRepeatedSiblings(nodes);
215
+ const header = (left, out) => {
216
+ const parts = [];
217
+ if (left > 0) parts.push(`${left} similar items collapsed`);
218
+ if (out > 0) parts.push(`${out} lines left out to fit`);
219
+ return parts.length > 0 ? `[${parts.join('; ')}]` : '';
220
+ };
221
+ const collapsedText = entries.map(entry => entry.line).join('\n');
222
+ const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
223
+ if (withHeader.length <= max) return { text: withHeader, lines };
224
+
225
+ const rank = entry => {
226
+ if (entry.marker) return 0;
227
+ if (INPUT_ROLES.has(entry.role)) return 1;
228
+ if (entry.role === 'button' || entry.role === 'link') return 2;
229
+ if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
230
+ return 5;
231
+ };
232
+ const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.line);
233
+ // The last interactive nodes on the page are usually its form or its
234
+ // confirm button (a panel after a list). When the list could not be
235
+ // collapsed they would otherwise lose to the list on page order, so the
236
+ // tail window, the final tenth of interactive nodes, ranks above the middle.
237
+ const interactive = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => !entry.marker && rank(entry) < 5);
238
+ const tailSize = Math.max(TAIL_MIN, Math.ceil(interactive.length * TAIL_FRACTION));
239
+ const tail = new Set(interactive.slice(-tailSize).map(({ index }) => index));
240
+ const tierOf = (entry, index) => {
241
+ if (entry.marker) return 0;
242
+ if (tail.has(index)) return 1 + (isNew(entry) ? 0 : 0.5);
243
+ if (rank(entry) < 5) return 2 + rank(entry) * 2 + (isNew(entry) ? 0 : 1);
244
+ return isNew(entry) ? 20 : 21;
245
+ };
246
+ const order = entries
247
+ .map((entry, index) => ({ entry, index, tier: tierOf(entry, index) }))
248
+ .sort((a, b) => a.tier - b.tier || a.index - b.index);
249
+ // Room for the header line, whose figures are not known until the end.
250
+ const budget = max - 80;
251
+ const chosen = new Set();
252
+ let used = 0;
253
+ for (const candidate of order) {
254
+ const cost = candidate.entry.line.length + 1;
255
+ if (used + cost > budget) continue;
256
+ chosen.add(candidate.index);
257
+ used += cost;
87
258
  }
88
- return kept.join('\n');
259
+ const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
260
+ const out = entries.length - kept.length;
261
+ return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines };
89
262
  }
90
263
 
91
264
  /**
@@ -116,6 +289,74 @@ export function unquoteKey(body) {
116
289
  return body;
117
290
  }
118
291
 
292
+ /**
293
+ * The modifier a platform's editing shortcuts use: Command on macOS,
294
+ * Control everywhere else. Chromium follows the host's conventions even
295
+ * headless, so on a Mac `Control+A` in a text field moves the caret to the
296
+ * start of the line instead of selecting the text, and a persona that
297
+ * "selects all and retypes" ends up with two values joined together.
298
+ *
299
+ * @param {string} [platform] a `process.platform` value
300
+ * @returns {'Meta'|'Control'}
301
+ */
302
+ export function platformModifier(platform = process.platform) {
303
+ return platform === 'darwin' ? 'Meta' : 'Control';
304
+ }
305
+
306
+ /** Editing shortcuts (select all, copy, cut, paste, undo, redo) that follow the platform modifier. */
307
+ const EDITING_KEYS = new Set(['a', 'c', 'v', 'x', 'z', 'y']);
308
+
309
+ /** Spellings a model writes for a modifier, mapped to Playwright's names. */
310
+ const MODIFIER_ALIASES = Object.freeze({
311
+ ctrl: 'Control',
312
+ control: 'Control',
313
+ cmd: 'Meta',
314
+ command: 'Meta',
315
+ meta: 'Meta',
316
+ super: 'Meta',
317
+ win: 'Meta',
318
+ mod: 'ControlOrMeta',
319
+ controlormeta: 'ControlOrMeta',
320
+ alt: 'Alt',
321
+ option: 'Alt',
322
+ opt: 'Alt',
323
+ shift: 'Shift'
324
+ });
325
+
326
+ /**
327
+ * Rewrite a key chord the persona asked for into one that does the same
328
+ * thing on this machine.
329
+ *
330
+ * Modifier spellings are normalised (`Ctrl`, `Cmd`, `Option`), and an
331
+ * editing shortcut held with Control or Command (`Control+A`, `Cmd+V`,
332
+ * `Ctrl+Shift+Z`) uses the platform modifier, because the persona means
333
+ * "select all", not "the Control key". Any other chord is left as written:
334
+ * a game that binds Control+S to something of its own wants Control on
335
+ * every platform.
336
+ *
337
+ * @param {string} key
338
+ * @param {'Meta'|'Control'} modifier the platform modifier
339
+ * @returns {string}
340
+ */
341
+ export function normalizeChord(key, modifier) {
342
+ const value = String(key == null ? '' : key);
343
+ // A lone "+" is the plus key, and "Shift++" is Shift and plus.
344
+ const parts = value.length > 1 ? value.split(/\+(?=.)/) : [value];
345
+ if (parts.length < 2) return value;
346
+ const mapped = parts.map((part, index) => {
347
+ if (index === parts.length - 1) return part;
348
+ return MODIFIER_ALIASES[part.trim().toLowerCase()] || part;
349
+ });
350
+ const last = mapped[mapped.length - 1];
351
+ const editing = EDITING_KEYS.has(String(last).toLowerCase());
352
+ return mapped.map((part, index) => {
353
+ if (index === mapped.length - 1) return part;
354
+ if (part === 'ControlOrMeta') return modifier;
355
+ if (editing && (part === 'Control' || part === 'Meta')) return modifier;
356
+ return part;
357
+ }).join('+');
358
+ }
359
+
119
360
  const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
120
361
 
121
362
  /**
@@ -231,9 +472,14 @@ function injectPlaytestIdentity(identity) {
231
472
  * @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
232
473
  * the run's playtest identity, handed to the page so the game's own SDK
233
474
  * session is marked synthetic
475
+ * @param {string} [opts.platform] the host platform, `process.platform` by
476
+ * default; decides the editing modifier
477
+ * @param {'Meta'|'Control'} [opts.modifier] the editing modifier, overriding
478
+ * the one the platform implies
234
479
  * @returns {Object} a driver
235
480
  */
236
- export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
481
+ export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null } = {}) {
482
+ const editModifier = modifier === 'Meta' || modifier === 'Control' ? modifier : platformModifier(platform);
237
483
  let browser = null;
238
484
  let context = null;
239
485
  let page = null;
@@ -244,6 +490,8 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
244
490
  let shots = 0;
245
491
  let lastHash = null;
246
492
  let lastObservedHash = null;
493
+ /** The compacted lines of the last observation, so a cut can prefer what is new. */
494
+ let lastObservedLines = null;
247
495
  const consoleBuffer = [];
248
496
 
249
497
  /** @returns {any} */
@@ -288,17 +536,71 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
288
536
  }
289
537
 
290
538
  /**
291
- * What the model is shown for a raw snapshot. `observe` and `act` both use
292
- * this, so the two hashes compare like with like.
539
+ * What the model is shown for a raw snapshot, and the lines it was built
540
+ * from. A page over the web ceiling is fitted rather than cut at the
541
+ * character limit (see fitAriaSnapshot), so a panel after a long list
542
+ * still reaches the model.
293
543
  * @param {{text: string, aria: boolean}} snap
294
- * @returns {string}
544
+ * @returns {{text: string, lines: Array<string>|null}}
295
545
  */
296
546
  function shownText(snap) {
297
- return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
547
+ if (snap.aria) {
548
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null });
549
+ if (fitted) return fitted;
550
+ }
551
+ return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null };
552
+ }
553
+
554
+ /**
555
+ * What `changed` and `unchanged` compare. For an aria snapshot that is the
556
+ * whole compacted tree, not the fitted text: the fit depends on the
557
+ * previous observation, so hashing it would read the same screen as
558
+ * changed. For the innerText fallback it is the truncated text, so a
559
+ * difference the model never saw is not a change.
560
+ * @param {{text: string, aria: boolean}} snap
561
+ * @param {{text: string, lines: Array<string>|null}} shown
562
+ * @returns {string}
563
+ */
564
+ function hashText(snap, shown) {
565
+ return shown.lines ? shown.lines.join('\n') : shown.text;
566
+ }
567
+
568
+ /**
569
+ * Type, or with `replace` put this text in place of what the field holds.
570
+ *
571
+ * Replacing goes through Playwright's `fill`, which clears the field
572
+ * natively on every platform, rather than a select-all chord: the chord is
573
+ * Command+A on a Mac and Control+A elsewhere, and getting it wrong joins the
574
+ * old value to the new one. A target names the field; without one the
575
+ * focused element is filled. Only an element `fill` refuses (a canvas, a
576
+ * custom widget) falls back to the platform select-all, then typing.
577
+ *
578
+ * @param {any} current the page
579
+ * @param {{text: string, target?: string, replace?: boolean}} action
580
+ */
581
+ async function typeText(current, action) {
582
+ const text = String(action.text == null ? '' : action.text);
583
+ const field = action.target ? resolveLocator(current, action.target) : null;
584
+ if (action.replace) {
585
+ try {
586
+ await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
587
+ return;
588
+ } catch {
589
+ // Not something fill can write to. Focus it if it was named, then
590
+ // clear it the way a player on this machine would.
591
+ if (field) await field.click({ timeout: ACTION_TIMEOUT_MS });
592
+ await current.keyboard.press(`${editModifier}+a`);
593
+ await current.keyboard.press('Backspace');
594
+ }
595
+ } else if (field) {
596
+ await field.click({ timeout: ACTION_TIMEOUT_MS });
597
+ }
598
+ if (text) await current.keyboard.type(text, { delay: 20 });
298
599
  }
299
600
 
300
601
  return {
301
602
  name: 'playwright_web',
603
+ editModifier,
302
604
  keepObservations: WEB_KEEP_OBSERVATIONS,
303
605
 
304
606
  /**
@@ -355,9 +657,10 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
355
657
  step += 1;
356
658
  const snap = await snapshotText();
357
659
  const text = snap.text;
660
+ const shown = shownText(snap);
358
661
  const observation = {
359
662
  step,
360
- text: shownText(snap),
663
+ text: shown.text,
361
664
  url: current.url(),
362
665
  elapsedMs: Date.now() - started
363
666
  };
@@ -384,11 +687,11 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
384
687
  // read it as a blank screen, which is what it is.
385
688
  }
386
689
  }
387
- // Hash what was REPORTED, not the untruncated snapshot: `act` compares
388
- // against this, so two screens that differ only past the truncation
389
- // point would otherwise read as changed to `act` and identical to
390
- // anything reading the observation.
391
- lastHash = observationHash(observation);
690
+ // Hash what hashText says, which `act` hashes the same way: the whole
691
+ // compacted tree for an aria snapshot (a change anywhere in it is shown,
692
+ // because a fit keeps new nodes first), the truncated text otherwise.
693
+ lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
694
+ lastObservedLines = shown.lines;
392
695
  // The same screen twice is sent as a marker rather than in full
393
696
  // (./tools.js), which is most of what a stuck persona would otherwise
394
697
  // pay for.
@@ -407,10 +710,10 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
407
710
  try {
408
711
  switch (action.kind) {
409
712
  case 'key':
410
- await current.keyboard.press(String(action.key), { timeout: ACTION_TIMEOUT_MS });
713
+ await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
411
714
  break;
412
715
  case 'type':
413
- await current.keyboard.type(String(action.text), { delay: 20 });
716
+ await typeText(current, action);
414
717
  if (action.submit) await current.keyboard.press('Enter');
415
718
  break;
416
719
  case 'click': {
@@ -441,10 +744,10 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
441
744
 
442
745
  let changed = false;
443
746
  try {
444
- // The same truncation observe() applies, so the two hashes are
445
- // comparable. Without it a long screen is always "changed".
747
+ // The same basis observe() hashes, so the two are comparable.
748
+ // Without it a long screen is always "changed".
446
749
  const snap = await snapshotText();
447
- const after = { text: shownText(snap) };
750
+ const after = { text: hashText(snap, shownText(snap)) };
448
751
  if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
449
752
  const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
450
753
  after.screenshotBase64 = frame.toString('base64');
@@ -51,6 +51,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
51
51
  }else if(action.kind==='swipe'){
52
52
  const from=point(action.from),to=point(action.to);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...from},{type:'pointerDown',button:0},{type:'pointerMove',duration:Math.min(2000,Math.max(100,Number(action.durationMs)||500)),origin:'viewport',...to},{type:'pointerUp',button:0}],touch);
53
53
  }else if(action.kind==='key'||action.kind==='type'){
54
+ if(action.kind==='type'&&(action.replace||action.target))throw new DriverError('replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus');
54
55
  const chars=action.kind==='type'?Array.from(String(action.text||'').slice(0,2000)):[KEYS[action.key]||action.key];
55
56
  if(chars.some(key=>typeof key!=='string'||Array.from(key).length!==1))throw new DriverError('Unsupported device key.');
56
57
  if(action.submit)chars.push(KEYS.Enter);