ravensight-playtest 0.2.8 → 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.
- package/README.md +98 -9
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +104 -8
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +559 -34
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +326 -62
- package/src/run/regenerate.js +24 -4
- package/src/run/report.js +93 -2
- package/src/run/resume.js +16 -1
- package/src/run/state.js +4 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
package/src/run/drivers/web.js
CHANGED
|
@@ -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,35 +64,337 @@ 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
|
|
73
|
-
* by depth. Text that is not an aria snapshot (the innerText
|
|
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) {
|
|
112
|
+
export function compactAriaSnapshot(text, options = {}) {
|
|
113
|
+
const nodes = compactAriaNodes(text, options);
|
|
114
|
+
return nodes === null ? String(text == null ? '' : text) : nodes.map(node => node.line).join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The centre of a box, or null when the node is not visible in the viewport.
|
|
119
|
+
* Rounded, and bounded by the viewport, so a coordinate the persona copies is
|
|
120
|
+
* always one the mouse can reach.
|
|
121
|
+
*
|
|
122
|
+
* @param {Array<number>|null} box [x, y, width, height]
|
|
123
|
+
* @param {{width: number, height: number}} viewport
|
|
124
|
+
* @returns {{x: number, y: number}|null}
|
|
125
|
+
*/
|
|
126
|
+
export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
|
|
127
|
+
if (!box) return null;
|
|
128
|
+
const [x, y, width, height] = box;
|
|
129
|
+
if (!(width > 0 && height > 0)) return null;
|
|
130
|
+
const cx = Math.round(x + width / 2);
|
|
131
|
+
const cy = Math.round(y + height / 2);
|
|
132
|
+
if (cx < 0 || cy < 0 || cx > viewport.width || cy > viewport.height) return null;
|
|
133
|
+
return { x: cx, y: cy };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The kept nodes of an aria snapshot with the depth each one had, or null for
|
|
138
|
+
* text that is not an aria snapshot. The depth is not shown to the model; it
|
|
139
|
+
* is what tells repeated siblings apart from a panel that follows them.
|
|
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
|
+
*
|
|
151
|
+
* @param {string} text
|
|
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}
|
|
154
|
+
*/
|
|
155
|
+
export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
|
|
80
156
|
const value = String(text == null ? '' : text);
|
|
81
157
|
const lines = value.split('\n');
|
|
82
|
-
if (!lines.some(line => /^\s*- /.test(line))) return
|
|
158
|
+
if (!lines.some(line => /^\s*- /.test(line))) return null;
|
|
83
159
|
const kept = [];
|
|
160
|
+
const ancestors = [];
|
|
84
161
|
for (const line of lines) {
|
|
85
162
|
const trimmed = line.trim();
|
|
86
163
|
if (!trimmed.startsWith('- ')) continue;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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 });
|
|
90
175
|
const named = /^[a-z]+ "[^"]*"/.test(body);
|
|
91
176
|
const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
|
|
92
|
-
|
|
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
|
+
});
|
|
93
196
|
}
|
|
94
|
-
|
|
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}`;
|
|
209
|
+
}
|
|
210
|
+
return kept;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Roles a player types into or sets, kept first when an observation has to be cut. */
|
|
214
|
+
const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'listbox']);
|
|
215
|
+
|
|
216
|
+
/** The share of interactive nodes, counted from the end of the page, kept ahead of the middle when a page is cut. */
|
|
217
|
+
export const TAIL_FRACTION = 0.1;
|
|
218
|
+
|
|
219
|
+
/** The tail window is never smaller than this many interactive nodes. */
|
|
220
|
+
const TAIL_MIN = 3;
|
|
221
|
+
|
|
222
|
+
/** A run of this many look-alike siblings or more is collapsed when the page is over the cap. */
|
|
223
|
+
export const COLLAPSE_MIN_RUN = 4;
|
|
224
|
+
|
|
225
|
+
/** How many of a collapsed run are still shown, so the model sees what the items look like. */
|
|
226
|
+
export const COLLAPSE_KEEP = 2;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The shape of one node, without the words that make it unique: its role,
|
|
230
|
+
* its state attributes, whether it has a name, whether the name carries
|
|
231
|
+
* digits and how many separators. Two school cards share a shape; the card
|
|
232
|
+
* list and a "TAKE THE JOB" button after it do not.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} body
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function nodeShape(body) {
|
|
238
|
+
const role = (body.match(/^([a-z]+)/) || [])[1] || '';
|
|
239
|
+
const name = (body.match(/^[a-z]+ "([^"]*)"/) || [])[1];
|
|
240
|
+
const attributes = [...body.matchAll(/\[([a-z-]+)(?:=[^\]]*)?\]/g)].map(match => match[1]).filter(key => key !== 'ref').join(',');
|
|
241
|
+
const nameShape = name === undefined ? '' : `n${/\d/.test(name) ? 'd' : ''}${(name.match(/[·•|/]/g) || []).length}`;
|
|
242
|
+
const inline = /: \S/.test(body.replace(/^[a-z]+(?: "[^"]*")?/, '')) ? 't' : '';
|
|
243
|
+
return `${role}|${nameShape}|${attributes}|${inline}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Collapse runs of look-alike siblings (a node and its kept descendants,
|
|
248
|
+
* compared by shape) into the first {@link COLLAPSE_KEEP} plus one line that
|
|
249
|
+
* says how many more there are.
|
|
250
|
+
*
|
|
251
|
+
* @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
|
|
252
|
+
* @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
|
|
253
|
+
*/
|
|
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);
|
|
257
|
+
const unitEnd = start => {
|
|
258
|
+
let end = start + 1;
|
|
259
|
+
while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
|
|
260
|
+
return end;
|
|
261
|
+
};
|
|
262
|
+
const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
|
|
263
|
+
const entries = [];
|
|
264
|
+
let collapsed = 0;
|
|
265
|
+
let i = 0;
|
|
266
|
+
while (i < nodes.length) {
|
|
267
|
+
const units = [[i, unitEnd(i)]];
|
|
268
|
+
const sig = signature(i, units[0][1]);
|
|
269
|
+
let next = units[0][1];
|
|
270
|
+
while (next < nodes.length && nodes[next].depth === nodes[i].depth) {
|
|
271
|
+
const end = unitEnd(next);
|
|
272
|
+
if (signature(next, end) !== sig) break;
|
|
273
|
+
units.push([next, end]);
|
|
274
|
+
next = end;
|
|
275
|
+
}
|
|
276
|
+
if (units.length >= COLLAPSE_MIN_RUN) {
|
|
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 });
|
|
303
|
+
}
|
|
304
|
+
i = next;
|
|
305
|
+
} else {
|
|
306
|
+
entries.push({ line: nodes[i].line, hashLine: nodes[i].hashLine, role: nodes[i].role, key: nodes[i].key });
|
|
307
|
+
i += 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { entries, collapsed };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Fit a compacted aria snapshot under the web ceiling without losing the
|
|
315
|
+
* part of the page the persona has to act on.
|
|
316
|
+
*
|
|
317
|
+
* Under the cap nothing changes. Over it, in this order: interactive nodes
|
|
318
|
+
* are what is kept (the last tenth of them on the page first, since that is
|
|
319
|
+
* where a form after a long list sits, then inputs, then buttons and links,
|
|
320
|
+
* then any role with a name); runs of look-alike siblings collapse to their
|
|
321
|
+
* first two and a count; nodes that are new since the previous observation
|
|
322
|
+
* come next; then
|
|
323
|
+
* the rest, and only what still does not fit is trimmed. Kept lines stay in
|
|
324
|
+
* page order, and a first line says how many items were collapsed and how
|
|
325
|
+
* many lines were left out, so the model knows the screen was cut.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} text the raw aria snapshot
|
|
328
|
+
* @param {{max?: number, previous?: Set<string>|null}} [options]
|
|
329
|
+
* @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
|
|
330
|
+
*/
|
|
331
|
+
export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT } = {}) {
|
|
332
|
+
const nodes = compactAriaNodes(text, { viewport });
|
|
333
|
+
if (nodes === null) return null;
|
|
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 };
|
|
337
|
+
|
|
338
|
+
const { entries, collapsed } = collapseRepeatedSiblings(nodes);
|
|
339
|
+
const header = (left, out) => {
|
|
340
|
+
const parts = [];
|
|
341
|
+
if (left > 0) parts.push(`${left} similar items collapsed`);
|
|
342
|
+
if (out > 0) parts.push(`${out} lines left out to fit`);
|
|
343
|
+
return parts.length > 0 ? `[${parts.join('; ')}]` : '';
|
|
344
|
+
};
|
|
345
|
+
const collapsedText = entries.map(entry => entry.line).join('\n');
|
|
346
|
+
const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
|
|
347
|
+
if (withHeader.length <= max) return { text: withHeader, lines, nodes };
|
|
348
|
+
|
|
349
|
+
const rank = entry => {
|
|
350
|
+
if (entry.marker) return 0;
|
|
351
|
+
if (INPUT_ROLES.has(entry.role)) return 1;
|
|
352
|
+
if (entry.role === 'button' || entry.role === 'link') return 2;
|
|
353
|
+
if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
|
|
354
|
+
return 5;
|
|
355
|
+
};
|
|
356
|
+
const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.hashLine || entry.line);
|
|
357
|
+
// The last interactive nodes on the page are usually its form or its
|
|
358
|
+
// confirm button (a panel after a list). When the list could not be
|
|
359
|
+
// collapsed they would otherwise lose to the list on page order, so the
|
|
360
|
+
// tail window, the final tenth of interactive nodes, ranks above the middle.
|
|
361
|
+
const interactive = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => !entry.marker && rank(entry) < 5);
|
|
362
|
+
const tailSize = Math.max(TAIL_MIN, Math.ceil(interactive.length * TAIL_FRACTION));
|
|
363
|
+
const tail = new Set(interactive.slice(-tailSize).map(({ index }) => index));
|
|
364
|
+
const tierOf = (entry, index) => {
|
|
365
|
+
if (entry.marker) return 0;
|
|
366
|
+
if (tail.has(index)) return 1 + (isNew(entry) ? 0 : 0.5);
|
|
367
|
+
if (rank(entry) < 5) return 2 + rank(entry) * 2 + (isNew(entry) ? 0 : 1);
|
|
368
|
+
return isNew(entry) ? 20 : 21;
|
|
369
|
+
};
|
|
370
|
+
const order = entries
|
|
371
|
+
.map((entry, index) => ({ entry, index, tier: tierOf(entry, index) }))
|
|
372
|
+
.sort((a, b) => a.tier - b.tier || a.index - b.index);
|
|
373
|
+
// Room for the header line, whose figures are not known until the end.
|
|
374
|
+
const budget = max - 80;
|
|
375
|
+
const chosen = new Set();
|
|
376
|
+
let used = 0;
|
|
377
|
+
for (const candidate of order) {
|
|
378
|
+
const cost = candidate.entry.line.length + 1;
|
|
379
|
+
if (used + cost > budget) continue;
|
|
380
|
+
chosen.add(candidate.index);
|
|
381
|
+
used += cost;
|
|
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
|
+
});
|
|
395
|
+
const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
|
|
396
|
+
const out = entries.length - kept.length;
|
|
397
|
+
return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
|
|
95
398
|
}
|
|
96
399
|
|
|
97
400
|
/**
|
|
@@ -253,6 +556,78 @@ export function isLoopback(hostname) {
|
|
|
253
556
|
return LOOPBACK_HOSTS.includes(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
254
557
|
}
|
|
255
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
|
+
|
|
256
631
|
/**
|
|
257
632
|
* Turn what the model wrote into a Playwright locator.
|
|
258
633
|
*
|
|
@@ -268,6 +643,7 @@ export function isLoopback(hostname) {
|
|
|
268
643
|
*/
|
|
269
644
|
export function resolveLocator(page, target) {
|
|
270
645
|
const value = String(target).trim();
|
|
646
|
+
if (LOCATOR_CODE.test(value)) throw new TargetError(LOCATOR_CODE_MESSAGE);
|
|
271
647
|
if (KNOWN_SELECTOR_ENGINES.test(value)) return page.locator(value);
|
|
272
648
|
|
|
273
649
|
// The name group cannot contain a quote (`[^"]*`), so there is nothing to
|
|
@@ -281,6 +657,16 @@ export function resolveLocator(page, target) {
|
|
|
281
657
|
return page.getByText(value, { exact: false }).first();
|
|
282
658
|
}
|
|
283
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
|
+
|
|
284
670
|
/**
|
|
285
671
|
* Hand the run's playtest identity to the page. Runs in the browser, before
|
|
286
672
|
* any of the game's own scripts, so the Ravensight JS SDK finds it at
|
|
@@ -323,6 +709,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
323
709
|
let shots = 0;
|
|
324
710
|
let lastHash = null;
|
|
325
711
|
let lastObservedHash = null;
|
|
712
|
+
/** The compacted lines of the last observation, so a cut can prefer what is new. */
|
|
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;
|
|
326
723
|
const consoleBuffer = [];
|
|
327
724
|
|
|
328
725
|
/** @returns {any} */
|
|
@@ -341,7 +738,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
341
738
|
let text = '';
|
|
342
739
|
let aria = true;
|
|
343
740
|
const body = current.locator('body');
|
|
344
|
-
|
|
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') {
|
|
345
752
|
try {
|
|
346
753
|
text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
|
|
347
754
|
} catch {
|
|
@@ -367,13 +774,33 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
367
774
|
}
|
|
368
775
|
|
|
369
776
|
/**
|
|
370
|
-
* What the model is shown for a raw snapshot
|
|
371
|
-
*
|
|
777
|
+
* What the model is shown for a raw snapshot, and the lines it was built
|
|
778
|
+
* from. A page over the web ceiling is fitted rather than cut at the
|
|
779
|
+
* character limit (see fitAriaSnapshot), so a panel after a long list
|
|
780
|
+
* still reaches the model.
|
|
372
781
|
* @param {{text: string, aria: boolean}} snap
|
|
373
|
-
* @returns {string}
|
|
782
|
+
* @returns {{text: string, lines: Array<string>|null}}
|
|
374
783
|
*/
|
|
375
784
|
function shownText(snap) {
|
|
376
|
-
|
|
785
|
+
if (snap.aria) {
|
|
786
|
+
const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize });
|
|
787
|
+
if (fitted) return fitted;
|
|
788
|
+
}
|
|
789
|
+
return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* What `changed` and `unchanged` compare. For an aria snapshot that is the
|
|
794
|
+
* whole compacted tree, not the fitted text: the fit depends on the
|
|
795
|
+
* previous observation, so hashing it would read the same screen as
|
|
796
|
+
* changed. For the innerText fallback it is the truncated text, so a
|
|
797
|
+
* difference the model never saw is not a change.
|
|
798
|
+
* @param {{text: string, aria: boolean}} snap
|
|
799
|
+
* @param {{text: string, lines: Array<string>|null}} shown
|
|
800
|
+
* @returns {string}
|
|
801
|
+
*/
|
|
802
|
+
function hashText(snap, shown) {
|
|
803
|
+
return shown.lines ? shown.lines.join('\n') : shown.text;
|
|
377
804
|
}
|
|
378
805
|
|
|
379
806
|
/**
|
|
@@ -391,7 +818,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
391
818
|
*/
|
|
392
819
|
async function typeText(current, action) {
|
|
393
820
|
const text = String(action.text == null ? '' : action.text);
|
|
394
|
-
const field = action.target ?
|
|
821
|
+
const field = action.target || action.ref ? await targetFor(current, action) : null;
|
|
395
822
|
if (action.replace) {
|
|
396
823
|
try {
|
|
397
824
|
await (field || current.locator('*:focus')).fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
|
|
@@ -409,8 +836,75 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
409
836
|
if (text) await current.keyboard.type(text, { delay: 20 });
|
|
410
837
|
}
|
|
411
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
|
+
|
|
412
905
|
return {
|
|
413
906
|
name: 'playwright_web',
|
|
907
|
+
supportsRefs: true,
|
|
414
908
|
editModifier,
|
|
415
909
|
keepObservations: WEB_KEEP_OBSERVATIONS,
|
|
416
910
|
|
|
@@ -424,6 +918,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
424
918
|
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
|
|
425
919
|
if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
|
|
426
920
|
target = url;
|
|
921
|
+
viewportSize = viewport || DEFAULT_VIEWPORT;
|
|
427
922
|
runDirectory = runDir;
|
|
428
923
|
videoRequested = Boolean(recordVideo);
|
|
429
924
|
|
|
@@ -447,6 +942,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
447
942
|
persona: playtest.persona || null
|
|
448
943
|
});
|
|
449
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);
|
|
450
948
|
page = await context.newPage();
|
|
451
949
|
page.on('console', message => {
|
|
452
950
|
if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
|
|
@@ -468,9 +966,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
468
966
|
step += 1;
|
|
469
967
|
const snap = await snapshotText();
|
|
470
968
|
const text = snap.text;
|
|
969
|
+
const shown = shownText(snap);
|
|
471
970
|
const observation = {
|
|
472
971
|
step,
|
|
473
|
-
text:
|
|
972
|
+
text: shown.text,
|
|
474
973
|
url: current.url(),
|
|
475
974
|
elapsedMs: Date.now() - started
|
|
476
975
|
};
|
|
@@ -497,11 +996,13 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
497
996
|
// read it as a blank screen, which is what it is.
|
|
498
997
|
}
|
|
499
998
|
}
|
|
500
|
-
// Hash what
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
999
|
+
// Hash what hashText says, which `act` hashes the same way: the whole
|
|
1000
|
+
// compacted tree for an aria snapshot (a change anywhere in it is shown,
|
|
1001
|
+
// because a fit keeps new nodes first), the truncated text otherwise.
|
|
1002
|
+
lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
|
|
1003
|
+
lastObservedLines = shown.lines;
|
|
1004
|
+
lastNodes = shown.nodes || null;
|
|
1005
|
+
lastNodesDocument = await documentToken(current);
|
|
505
1006
|
// The same screen twice is sent as a marker rather than in full
|
|
506
1007
|
// (./tools.js), which is most of what a stuck persona would otherwise
|
|
507
1008
|
// pay for.
|
|
@@ -527,11 +1028,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
527
1028
|
if (action.submit) await current.keyboard.press('Enter');
|
|
528
1029
|
break;
|
|
529
1030
|
case 'click': {
|
|
530
|
-
if (action.target && typeof action.target === 'object') {
|
|
1031
|
+
if (!action.ref && action.target && typeof action.target === 'object') {
|
|
531
1032
|
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
532
1033
|
break;
|
|
533
1034
|
}
|
|
534
|
-
await
|
|
1035
|
+
await (await targetFor(current, action)).click({ timeout: ACTION_TIMEOUT_MS });
|
|
535
1036
|
break;
|
|
536
1037
|
}
|
|
537
1038
|
case 'navigate': {
|
|
@@ -549,20 +1050,44 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
549
1050
|
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
550
1051
|
}
|
|
551
1052
|
} catch (error) {
|
|
552
|
-
|
|
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 };
|
|
553
1084
|
}
|
|
554
1085
|
|
|
555
1086
|
let changed = false;
|
|
556
1087
|
try {
|
|
557
|
-
// The same
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
const after = { text: shownText(snap) };
|
|
561
|
-
if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
562
|
-
const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
563
|
-
after.screenshotBase64 = frame.toString('base64');
|
|
564
|
-
}
|
|
565
|
-
changed = observationHash(after) !== before;
|
|
1088
|
+
// The same basis observe() hashes, so the two are comparable.
|
|
1089
|
+
// Without it a long screen is always "changed".
|
|
1090
|
+
changed = (await currentHash(current)) !== before;
|
|
566
1091
|
} catch {
|
|
567
1092
|
changed = true;
|
|
568
1093
|
}
|