ravensight-playtest 0.3.0 → 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 +207 -24
- 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 +264 -31
- package/src/run/aggregate.js +6 -3
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +634 -83
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +658 -26
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +20 -4
- 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
|
@@ -133,6 +133,190 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
|
|
|
133
133
|
return { x: cx, y: cy };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Roles whose current value the snapshot carries, read from the DOM. */
|
|
137
|
+
export const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton', 'menuitemcheckbox', 'menuitemradio']);
|
|
138
|
+
|
|
139
|
+
/** The longest value shown for a control; a longer one is cut with "...". */
|
|
140
|
+
export const MAX_VALUE_CHARS = 80;
|
|
141
|
+
|
|
142
|
+
/** How many controls on one screen have their value read; a typed field past it shows no value at all. */
|
|
143
|
+
export const MAX_VALUE_READS = 60;
|
|
144
|
+
|
|
145
|
+
/** What a password field's value is always shown as, empty or not. */
|
|
146
|
+
export const MASKED_VALUE = '(hidden)';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Roles whose snapshot text can be what the user typed. Their printed value is
|
|
150
|
+
* removed before anything is compacted, and a value is shown only when the DOM
|
|
151
|
+
* answered and said the field is not a password: Playwright's snapshot prints
|
|
152
|
+
* a password field's contents in clear, and 0.3.0 sent that to the model.
|
|
153
|
+
*/
|
|
154
|
+
export const TYPED_VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton']);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A node body with the value the snapshot printed after its name removed:
|
|
158
|
+
* `textbox "Pass" [ref=e6]: hunter22` becomes `textbox "Pass" [ref=e6]`. A
|
|
159
|
+
* trailing `:` (children follow) is kept.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} body
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function withoutPrintedValue(body) {
|
|
165
|
+
const match = String(body).match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(:?)(.*)$/s);
|
|
166
|
+
if (!match) return String(body);
|
|
167
|
+
return match[3].trim() ? match[1] : `${match[1]}${match[2]}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A control's current state as the snapshot shows it: `value="..."` for a
|
|
172
|
+
* field or a slider, `checked` or `not checked` for a checkbox, radio or
|
|
173
|
+
* switch, `selected option="..."` for a select. A password is masked, and a
|
|
174
|
+
* value over {@link MAX_VALUE_CHARS} is cut.
|
|
175
|
+
*
|
|
176
|
+
* @param {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}} state
|
|
177
|
+
* @returns {string|null}
|
|
178
|
+
*/
|
|
179
|
+
export function formatControlState(state) {
|
|
180
|
+
if (!state || typeof state !== 'object') return null;
|
|
181
|
+
const quote = value => {
|
|
182
|
+
let text = String(value == null ? '' : value);
|
|
183
|
+
if (text.length > MAX_VALUE_CHARS) text = `${text.slice(0, MAX_VALUE_CHARS - 3)}...`;
|
|
184
|
+
return JSON.stringify(text);
|
|
185
|
+
};
|
|
186
|
+
if (state.kind === 'check') return state.checked === 'mixed' ? 'partly checked' : state.checked ? 'checked' : 'not checked';
|
|
187
|
+
if (state.kind === 'select') return `selected option=${quote((state.selected || []).join(', '))}`;
|
|
188
|
+
if (state.kind === 'value') return state.password ? `value=${MASKED_VALUE}` : `value=${quote(state.value)}`;
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read a control's state in the page. Runs in the browser, serialised by
|
|
194
|
+
* Playwright, so it must not close over anything.
|
|
195
|
+
*
|
|
196
|
+
* @param {Element} el
|
|
197
|
+
* @returns {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}|null}
|
|
198
|
+
*/
|
|
199
|
+
function readControlState(el) {
|
|
200
|
+
if (!el) return null;
|
|
201
|
+
const tag = String(el.tagName || '').toLowerCase();
|
|
202
|
+
const type = String(el.getAttribute('type') || '').toLowerCase();
|
|
203
|
+
const role = String(el.getAttribute('role') || '').toLowerCase();
|
|
204
|
+
if (tag === 'input' && (type === 'checkbox' || type === 'radio')) return { kind: 'check', checked: el.indeterminate ? 'mixed' : Boolean(el.checked) };
|
|
205
|
+
if (tag === 'select') return { kind: 'select', selected: Array.from(el.selectedOptions || []).map(option => String(option.label || option.textContent || '').trim()) };
|
|
206
|
+
if (tag === 'input' || tag === 'textarea') return { kind: 'value', value: String(el.value == null ? '' : el.value), password: tag === 'input' && type === 'password' };
|
|
207
|
+
const ariaChecked = el.getAttribute('aria-checked');
|
|
208
|
+
if (ariaChecked !== null || ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'].includes(role)) {
|
|
209
|
+
return { kind: 'check', checked: ariaChecked === 'mixed' ? 'mixed' : ariaChecked === 'true' };
|
|
210
|
+
}
|
|
211
|
+
if (el.isContentEditable) return { kind: 'value', value: String(el.innerText || '').replace(/\s+/g, ' ').trim() };
|
|
212
|
+
const now = el.getAttribute('aria-valuetext') || el.getAttribute('aria-valuenow');
|
|
213
|
+
if (now !== null) return { kind: 'value', value: String(now) };
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The refs of the controls in an AI mode snapshot whose value is worth
|
|
219
|
+
* reading, in page order and bounded by {@link MAX_VALUE_READS}.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} text
|
|
222
|
+
* @returns {Array<string>}
|
|
223
|
+
*/
|
|
224
|
+
export function valueRefs(text) {
|
|
225
|
+
const refs = [];
|
|
226
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
227
|
+
const raw = unquoteKey(line.trim().replace(/^- /, ''));
|
|
228
|
+
const role = (raw.match(/^([a-z]+)/) || [])[1] || '';
|
|
229
|
+
if (!VALUE_ROLES.has(role)) continue;
|
|
230
|
+
const ref = (raw.match(REF_ATTRIBUTE) || [])[1];
|
|
231
|
+
if (ref && !refs.includes(ref)) refs.push(ref);
|
|
232
|
+
if (refs.length >= MAX_VALUE_READS) break;
|
|
233
|
+
}
|
|
234
|
+
return refs;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** How long reading one control's value may take before it is skipped. */
|
|
238
|
+
export const VALUE_READ_TIMEOUT_MS = 1000;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the current state of every control in a snapshot from the DOM, by
|
|
242
|
+
* ref. A control that cannot answer (gone, detached, a frame) is left out
|
|
243
|
+
* and shows what the snapshot printed.
|
|
244
|
+
*
|
|
245
|
+
* @param {any} page
|
|
246
|
+
* @param {string} text an AI mode snapshot
|
|
247
|
+
* @returns {Promise<Map<string, Object>>}
|
|
248
|
+
*/
|
|
249
|
+
export async function readControlValues(page, text) {
|
|
250
|
+
const values = new Map();
|
|
251
|
+
const refs = valueRefs(text);
|
|
252
|
+
if (refs.length === 0 || !page || typeof page.locator !== 'function') return values;
|
|
253
|
+
await Promise.all(refs.map(async ref => {
|
|
254
|
+
try {
|
|
255
|
+
const locator = page.locator(`aria-ref=${ref}`);
|
|
256
|
+
if (typeof locator.evaluate !== 'function') return;
|
|
257
|
+
const state = await locator.evaluate(readControlState, null, { timeout: VALUE_READ_TIMEOUT_MS });
|
|
258
|
+
if (state && typeof state === 'object') values.set(ref, state);
|
|
259
|
+
} catch {
|
|
260
|
+
// Not answerable now; the snapshot's own text stands for it.
|
|
261
|
+
}
|
|
262
|
+
}));
|
|
263
|
+
return values;
|
|
264
|
+
}
|
|
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
|
+
|
|
136
320
|
/**
|
|
137
321
|
* The kept nodes of an aria snapshot with the depth each one had, or null for
|
|
138
322
|
* text that is not an aria snapshot. The depth is not shown to the model; it
|
|
@@ -152,7 +336,7 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
|
|
|
152
336
|
* @param {{viewport?: {width: number, height: number}}} [options]
|
|
153
337
|
* @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
338
|
*/
|
|
155
|
-
export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
|
|
339
|
+
export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT, values = null } = {}) {
|
|
156
340
|
const value = String(text == null ? '' : text);
|
|
157
341
|
const lines = value.split('\n');
|
|
158
342
|
if (!lines.some(line => /^\s*- /.test(line))) return null;
|
|
@@ -170,7 +354,9 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
|
|
|
170
354
|
const ref = (raw.match(REF_ATTRIBUTE) || [])[1] || null;
|
|
171
355
|
const boxMatch = raw.match(BOX_ATTRIBUTE);
|
|
172
356
|
const box = boxMatch ? boxMatch.slice(1, 5).map(Number) : null;
|
|
173
|
-
|
|
357
|
+
// A field's printed value goes before anything else reads the line: only
|
|
358
|
+
// a value the DOM confirmed (and never a password) is shown, below.
|
|
359
|
+
const body = TYPED_VALUE_ROLES.has(role) ? withoutPrintedValue(raw.replace(NOISE_ATTRIBUTE, '')) : raw.replace(NOISE_ATTRIBUTE, '');
|
|
174
360
|
ancestors.push({ depth, role, name });
|
|
175
361
|
const named = /^[a-z]+ "[^"]*"/.test(body);
|
|
176
362
|
const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
|
|
@@ -180,11 +366,13 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
|
|
|
180
366
|
const hint = container
|
|
181
367
|
? (container.name ? `${container.name}${LANDMARK_LABELS[container.role] && container.role !== 'region' ? ` ${LANDMARK_LABELS[container.role]}` : ''}` : LANDMARK_LABELS[container.role])
|
|
182
368
|
: null;
|
|
369
|
+
const state = interactive && ref && values && typeof values.get === 'function' && VALUE_ROLES.has(role) ? values.get(ref) : null;
|
|
183
370
|
kept.push({
|
|
184
371
|
depth,
|
|
185
372
|
body,
|
|
186
373
|
role,
|
|
187
374
|
name,
|
|
375
|
+
state: state ? formatControlState(state) : null,
|
|
188
376
|
ref: interactive ? ref : null,
|
|
189
377
|
centre: interactive ? visibleCentre(box, viewport) : null,
|
|
190
378
|
hint,
|
|
@@ -199,8 +387,15 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
|
|
|
199
387
|
for (const node of kept) {
|
|
200
388
|
node.duplicate = Boolean(node.key) && counts.get(node.key) > 1;
|
|
201
389
|
const split = node.body.match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(.*)$/s) || [null, node.body, ''];
|
|
202
|
-
|
|
203
|
-
|
|
390
|
+
let head = split[1];
|
|
391
|
+
let tail = split[2];
|
|
392
|
+
if (node.state) {
|
|
393
|
+
// The value read from the DOM replaces whatever the snapshot printed
|
|
394
|
+
// after the name and its checked attribute, so there is one answer, and
|
|
395
|
+
// it is the one the page holds.
|
|
396
|
+
head = head.replace(/ \[(?:checked|selected)(?:=[^\]]*)?\]/g, '');
|
|
397
|
+
tail = ` ${node.state}`;
|
|
398
|
+
}
|
|
204
399
|
const refPart = node.ref ? ` [${node.ref}]` : '';
|
|
205
400
|
const hintPart = node.duplicate && node.hint ? ` (in ${node.hint})` : '';
|
|
206
401
|
const centrePart = node.centre ? ` @(${node.centre.x},${node.centre.y})` : '';
|
|
@@ -225,6 +420,13 @@ export const COLLAPSE_MIN_RUN = 4;
|
|
|
225
420
|
/** How many of a collapsed run are still shown, so the model sees what the items look like. */
|
|
226
421
|
export const COLLAPSE_KEEP = 2;
|
|
227
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
|
+
|
|
228
430
|
/**
|
|
229
431
|
* The shape of one node, without the words that make it unique: its role,
|
|
230
432
|
* its state attributes, whether it has a name, whether the name carries
|
|
@@ -249,9 +451,15 @@ function nodeShape(body) {
|
|
|
249
451
|
* says how many more there are.
|
|
250
452
|
*
|
|
251
453
|
* @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
|
|
252
|
-
* @
|
|
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
|
|
253
461
|
*/
|
|
254
|
-
export function collapseRepeatedSiblings(nodes) {
|
|
462
|
+
export function collapseRepeatedSiblings(nodes, { minRun = COLLAPSE_MIN_RUN, keepNamedTwins = false } = {}) {
|
|
255
463
|
const keyCounts = new Map();
|
|
256
464
|
for (const node of nodes) if (node.key) keyCounts.set(node.key, (keyCounts.get(node.key) || 0) + 1);
|
|
257
465
|
const unitEnd = start => {
|
|
@@ -262,6 +470,8 @@ export function collapseRepeatedSiblings(nodes) {
|
|
|
262
470
|
const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
|
|
263
471
|
const entries = [];
|
|
264
472
|
let collapsed = 0;
|
|
473
|
+
let longest = nodes.length > 0 ? 1 : 0;
|
|
474
|
+
let longestTwins = false;
|
|
265
475
|
let i = 0;
|
|
266
476
|
while (i < nodes.length) {
|
|
267
477
|
const units = [[i, unitEnd(i)]];
|
|
@@ -273,7 +483,23 @@ export function collapseRepeatedSiblings(nodes) {
|
|
|
273
483
|
units.push([next, end]);
|
|
274
484
|
next = end;
|
|
275
485
|
}
|
|
276
|
-
|
|
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) {
|
|
277
503
|
// A node whose role and name also appear outside this run is never
|
|
278
504
|
// hidden in the count: two buttons called "Run the week", one in the
|
|
279
505
|
// header and one in the main panel, both stay visible with their refs.
|
|
@@ -307,14 +533,17 @@ export function collapseRepeatedSiblings(nodes) {
|
|
|
307
533
|
i += 1;
|
|
308
534
|
}
|
|
309
535
|
}
|
|
310
|
-
return { entries, collapsed };
|
|
536
|
+
return { entries, collapsed, longest, longestTwins };
|
|
311
537
|
}
|
|
312
538
|
|
|
313
539
|
/**
|
|
314
540
|
* Fit a compacted aria snapshot under the web ceiling without losing the
|
|
315
541
|
* part of the page the persona has to act on.
|
|
316
542
|
*
|
|
317
|
-
* 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
|
|
318
547
|
* are what is kept (the last tenth of them on the page first, since that is
|
|
319
548
|
* where a form after a long list sits, then inputs, then buttons and links,
|
|
320
549
|
* then any role with a name); runs of look-alike siblings collapse to their
|
|
@@ -328,20 +557,24 @@ export function collapseRepeatedSiblings(nodes) {
|
|
|
328
557
|
* @param {{max?: number, previous?: Set<string>|null}} [options]
|
|
329
558
|
* @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
|
|
330
559
|
*/
|
|
331
|
-
export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT } = {}) {
|
|
332
|
-
const nodes = compactAriaNodes(text, { viewport });
|
|
560
|
+
export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT, values = null } = {}) {
|
|
561
|
+
const nodes = compactAriaNodes(text, { viewport, values });
|
|
333
562
|
if (nodes === null) return null;
|
|
334
563
|
const lines = nodes.map(node => node.hashLine);
|
|
335
564
|
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
565
|
const header = (left, out) => {
|
|
340
566
|
const parts = [];
|
|
341
567
|
if (left > 0) parts.push(`${left} similar items collapsed`);
|
|
342
568
|
if (out > 0) parts.push(`${out} lines left out to fit`);
|
|
343
569
|
return parts.length > 0 ? `[${parts.join('; ')}]` : '';
|
|
344
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);
|
|
345
578
|
const collapsedText = entries.map(entry => entry.line).join('\n');
|
|
346
579
|
const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
|
|
347
580
|
if (withHeader.length <= max) return { text: withHeader, lines, nodes };
|
|
@@ -397,6 +630,113 @@ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previou
|
|
|
397
630
|
return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines, nodes };
|
|
398
631
|
}
|
|
399
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
|
+
|
|
400
740
|
/**
|
|
401
741
|
* Playwright writes a node key as a single quoted YAML scalar when the name
|
|
402
742
|
* contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
|
|
@@ -628,6 +968,73 @@ export function ambiguityMessage(target, candidates, count = null) {
|
|
|
628
968
|
: `${total} elements match ${JSON.stringify(String(target))}; take a snapshot and use the ref of the one you mean`;
|
|
629
969
|
}
|
|
630
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
|
+
|
|
631
1038
|
/**
|
|
632
1039
|
* Turn what the model wrote into a Playwright locator.
|
|
633
1040
|
*
|
|
@@ -650,11 +1057,19 @@ export function resolveLocator(page, target) {
|
|
|
650
1057
|
// escape when it is spliced back into the `role=` engine. A target whose
|
|
651
1058
|
// name really does contain a quote falls through to the text search below.
|
|
652
1059
|
const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
|
|
653
|
-
|
|
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]}"]`));
|
|
654
1063
|
|
|
655
1064
|
if (/^[#.[]/.test(value) || /^[a-z][a-z0-9-]*(\[|\.|#|:| >)/i.test(value)) return page.locator(value);
|
|
656
1065
|
|
|
657
|
-
|
|
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;
|
|
658
1073
|
}
|
|
659
1074
|
|
|
660
1075
|
/**
|
|
@@ -674,7 +1089,7 @@ function markDocument() {
|
|
|
674
1089
|
*
|
|
675
1090
|
* @param {{token: string, runId: string, jobId: string, persona: string}} identity
|
|
676
1091
|
*/
|
|
677
|
-
function injectPlaytestIdentity(identity) {
|
|
1092
|
+
export function injectPlaytestIdentity(identity) {
|
|
678
1093
|
window.__RAVENSIGHT_PLAYTEST__ = identity;
|
|
679
1094
|
try {
|
|
680
1095
|
localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
|
|
@@ -684,6 +1099,15 @@ function injectPlaytestIdentity(identity) {
|
|
|
684
1099
|
}
|
|
685
1100
|
}
|
|
686
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
|
+
|
|
687
1111
|
/**
|
|
688
1112
|
* @param {Object} opts
|
|
689
1113
|
* @param {(specifier: string) => Promise<any>} [opts.importer] test seam
|
|
@@ -721,6 +1145,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
721
1145
|
// compared with the current one before any ref is used.
|
|
722
1146
|
let lastNodesDocument = null;
|
|
723
1147
|
const consoleBuffer = [];
|
|
1148
|
+
/** Recent failed requests, newest last, bounded: what a console line is matched against. */
|
|
1149
|
+
const networkFailures = [];
|
|
724
1150
|
|
|
725
1151
|
/** @returns {any} */
|
|
726
1152
|
function requirePage() {
|
|
@@ -729,48 +1155,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
729
1155
|
}
|
|
730
1156
|
|
|
731
1157
|
/**
|
|
732
|
-
* @returns {Promise<{text: string, aria: boolean}>}
|
|
733
|
-
* whether it is the aria snapshot (compacted) or the innerText fallback
|
|
734
|
-
* (never compacted: a bullet list in the page is not snapshot structure)
|
|
1158
|
+
* @returns {Promise<{text: string, aria: boolean}>} see {@link readSnapshot}
|
|
735
1159
|
*/
|
|
736
1160
|
async function snapshotText() {
|
|
737
|
-
|
|
738
|
-
let text = '';
|
|
739
|
-
let aria = true;
|
|
740
|
-
const body = current.locator('body');
|
|
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') {
|
|
752
|
-
try {
|
|
753
|
-
text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
|
|
754
|
-
} catch {
|
|
755
|
-
text = '';
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
if (text.trim().length < THIN_OBSERVATION_CHARS) {
|
|
759
|
-
try {
|
|
760
|
-
const inner = await current.evaluate(() => {
|
|
761
|
-
const el = globalThis.document && globalThis.document.body;
|
|
762
|
-
return el ? el.innerText : '';
|
|
763
|
-
});
|
|
764
|
-
if (String(inner).trim().length > text.trim().length) {
|
|
765
|
-
text = String(inner);
|
|
766
|
-
aria = false;
|
|
767
|
-
}
|
|
768
|
-
} catch {
|
|
769
|
-
// A page mid-navigation has no body to read. The thin observation
|
|
770
|
-
// is the honest answer, and the screenshot fallback below covers it.
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
return { text, aria };
|
|
1161
|
+
return readSnapshot(requirePage());
|
|
774
1162
|
}
|
|
775
1163
|
|
|
776
1164
|
/**
|
|
@@ -783,7 +1171,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
783
1171
|
*/
|
|
784
1172
|
function shownText(snap) {
|
|
785
1173
|
if (snap.aria) {
|
|
786
|
-
const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize });
|
|
1174
|
+
const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize, values: snap.values });
|
|
787
1175
|
if (fitted) return fitted;
|
|
788
1176
|
}
|
|
789
1177
|
return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
|
|
@@ -818,11 +1206,19 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
818
1206
|
*/
|
|
819
1207
|
async function typeText(current, action) {
|
|
820
1208
|
const text = String(action.text == null ? '' : action.text);
|
|
821
|
-
|
|
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);
|
|
822
1218
|
if (action.replace) {
|
|
823
1219
|
try {
|
|
824
|
-
await
|
|
825
|
-
return;
|
|
1220
|
+
await subject.fill(text, { timeout: field ? ACTION_TIMEOUT_MS : FOCUSED_FILL_TIMEOUT_MS });
|
|
1221
|
+
return { before, after: await fieldValue(subject) };
|
|
826
1222
|
} catch {
|
|
827
1223
|
// Not something fill can write to. Focus it if it was named, then
|
|
828
1224
|
// clear it the way a player on this machine would.
|
|
@@ -834,29 +1230,51 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
834
1230
|
await field.click({ timeout: ACTION_TIMEOUT_MS });
|
|
835
1231
|
}
|
|
836
1232
|
if (text) await current.keyboard.type(text, { delay: 20 });
|
|
1233
|
+
return { before, after: await fieldValue(subject) };
|
|
837
1234
|
}
|
|
838
1235
|
|
|
839
1236
|
/**
|
|
840
|
-
* The locator
|
|
841
|
-
*
|
|
842
|
-
|
|
843
|
-
|
|
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;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
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.
|
|
844
1260
|
*
|
|
845
1261
|
* @param {any} current
|
|
846
1262
|
* @param {{ref?: string, target?: string}} action
|
|
847
1263
|
*/
|
|
848
1264
|
async function targetFor(current, action) {
|
|
849
|
-
if (action.ref !== undefined && action.ref !== null && action.ref !== '')
|
|
850
|
-
|
|
851
|
-
|
|
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.
|
|
852
1275
|
await assertSameDocument(current);
|
|
853
|
-
|
|
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;
|
|
1276
|
+
return { point: { x: parsed.x, y: parsed.y } };
|
|
858
1277
|
}
|
|
859
|
-
const target = String(action.target == null ? '' : action.target);
|
|
860
1278
|
const candidates = candidatesFor(lastNodes, target);
|
|
861
1279
|
if (candidates.length > 1) throw new TargetError(ambiguityMessage(target, candidates));
|
|
862
1280
|
if (LOCATOR_CODE.test(target)) {
|
|
@@ -868,6 +1286,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
868
1286
|
}
|
|
869
1287
|
throw new TargetError(LOCATOR_CODE_MESSAGE);
|
|
870
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
|
+
}
|
|
871
1299
|
return resolveLocator(current, target);
|
|
872
1300
|
}
|
|
873
1301
|
|
|
@@ -885,10 +1313,24 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
885
1313
|
}
|
|
886
1314
|
}
|
|
887
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
|
+
|
|
888
1325
|
/** Refuse a ref when the document changed since the observation it came from. */
|
|
889
1326
|
async function assertSameDocument(current) {
|
|
890
|
-
|
|
891
|
-
|
|
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;
|
|
892
1334
|
}
|
|
893
1335
|
|
|
894
1336
|
/** The hash of the screen as it is now, on observe()'s basis. */
|
|
@@ -947,9 +1389,36 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
947
1389
|
if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
|
|
948
1390
|
page = await context.newPage();
|
|
949
1391
|
page.on('console', message => {
|
|
950
|
-
if (message.type()
|
|
1392
|
+
if (message.type() !== 'error') return;
|
|
1393
|
+
let location = null;
|
|
1394
|
+
try {
|
|
1395
|
+
location = typeof message.location === 'function' ? message.location() : null;
|
|
1396
|
+
} catch {
|
|
1397
|
+
location = null;
|
|
1398
|
+
}
|
|
1399
|
+
consoleBuffer.push({ type: 'console', text: message.text(), ...(location && location.url ? { locationUrl: String(location.url) } : {}) });
|
|
951
1400
|
});
|
|
952
1401
|
page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
|
|
1402
|
+
// The failing request behind a "Failed to load resource" line, which
|
|
1403
|
+
// the console text alone never names.
|
|
1404
|
+
page.on('response', response => {
|
|
1405
|
+
try {
|
|
1406
|
+
const status = response.status();
|
|
1407
|
+
if (status < 400) return;
|
|
1408
|
+
const request = typeof response.request === 'function' ? response.request() : null;
|
|
1409
|
+
rememberNetworkFailure(networkFailures, { url: response.url(), status, method: request && typeof request.method === 'function' ? request.method() : null });
|
|
1410
|
+
} catch {
|
|
1411
|
+
// A response that cannot be read is not worth a note.
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
page.on('requestfailed', request => {
|
|
1415
|
+
try {
|
|
1416
|
+
const failure = typeof request.failure === 'function' ? request.failure() : null;
|
|
1417
|
+
rememberNetworkFailure(networkFailures, { url: request.url(), status: null, method: typeof request.method === 'function' ? request.method() : null, error: failure && failure.errorText ? String(failure.errorText) : null });
|
|
1418
|
+
} catch {
|
|
1419
|
+
// Same: nothing to record.
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
953
1422
|
|
|
954
1423
|
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
955
1424
|
// A 404 is not something to guess around: the build is not there, and
|
|
@@ -1018,13 +1487,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1018
1487
|
async act(action) {
|
|
1019
1488
|
const current = requirePage();
|
|
1020
1489
|
const before = lastHash;
|
|
1490
|
+
const urlBefore = pageUrl(current);
|
|
1491
|
+
const tokenBefore = await documentToken(current);
|
|
1492
|
+
let typed = null;
|
|
1021
1493
|
try {
|
|
1022
1494
|
switch (action.kind) {
|
|
1023
1495
|
case 'key':
|
|
1024
1496
|
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
1025
1497
|
break;
|
|
1026
1498
|
case 'type':
|
|
1027
|
-
await typeText(current, action);
|
|
1499
|
+
typed = await typeText(current, action);
|
|
1028
1500
|
if (action.submit) await current.keyboard.press('Enter');
|
|
1029
1501
|
break;
|
|
1030
1502
|
case 'click': {
|
|
@@ -1032,7 +1504,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1032
1504
|
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
1033
1505
|
break;
|
|
1034
1506
|
}
|
|
1035
|
-
|
|
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 });
|
|
1036
1510
|
break;
|
|
1037
1511
|
}
|
|
1038
1512
|
case 'navigate': {
|
|
@@ -1083,11 +1557,22 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1083
1557
|
return { ok: false, changed: false, error: text };
|
|
1084
1558
|
}
|
|
1085
1559
|
|
|
1086
|
-
|
|
1087
|
-
|
|
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 };
|
|
1088
1562
|
// The same basis observe() hashes, so the two are comparable.
|
|
1089
1563
|
// Without it a long screen is always "changed".
|
|
1090
|
-
|
|
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
|
+
}
|
|
1091
1576
|
} catch {
|
|
1092
1577
|
changed = true;
|
|
1093
1578
|
}
|
|
@@ -1110,7 +1595,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1110
1595
|
|
|
1111
1596
|
/** @returns {Promise<Array<{type: string, text: string}>>} */
|
|
1112
1597
|
async consoleErrors() {
|
|
1113
|
-
return consoleBuffer.splice(0, consoleBuffer.length);
|
|
1598
|
+
return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
|
|
1114
1599
|
},
|
|
1115
1600
|
|
|
1116
1601
|
/** @returns {Promise<{videoPath: string|null}>} */
|
|
@@ -1143,6 +1628,72 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1143
1628
|
};
|
|
1144
1629
|
}
|
|
1145
1630
|
|
|
1631
|
+
/** How many recent failed requests are kept to match console lines against. */
|
|
1632
|
+
export const MAX_NETWORK_FAILURES = 50;
|
|
1633
|
+
|
|
1634
|
+
/** The longest request URL a note carries. */
|
|
1635
|
+
export const MAX_NETWORK_URL_CHARS = 200;
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* A request URL as a note carries it: no query string and no fragment (they
|
|
1639
|
+
* carry tokens and session ids as often as anything useful), and bounded.
|
|
1640
|
+
*
|
|
1641
|
+
* @param {string} url
|
|
1642
|
+
* @returns {string}
|
|
1643
|
+
*/
|
|
1644
|
+
export function networkUrl(url) {
|
|
1645
|
+
let value = String(url == null ? '' : url);
|
|
1646
|
+
try {
|
|
1647
|
+
const parsed = new URL(value);
|
|
1648
|
+
value = `${parsed.origin === 'null' ? `${parsed.protocol}` : parsed.origin}${parsed.pathname}`;
|
|
1649
|
+
} catch {
|
|
1650
|
+
value = value.split(/[?#]/)[0];
|
|
1651
|
+
}
|
|
1652
|
+
return value.length > MAX_NETWORK_URL_CHARS ? `${value.slice(0, MAX_NETWORK_URL_CHARS - 3)}...` : value;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* @param {Array<Object>} list
|
|
1657
|
+
* @param {{url: string, status: number|null, method?: string|null, error?: string|null}} failure
|
|
1658
|
+
*/
|
|
1659
|
+
export function rememberNetworkFailure(list, failure) {
|
|
1660
|
+
list.push({ url: networkUrl(failure.url), status: Number.isInteger(failure.status) ? failure.status : null, method: failure.method || null, error: failure.error || null, claimed: false });
|
|
1661
|
+
while (list.length > MAX_NETWORK_FAILURES) list.shift();
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** The console line a browser prints for a failed request. */
|
|
1665
|
+
const FAILED_RESOURCE = /^Failed to load resource\b/i;
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Give each "Failed to load resource" console line the request it was about:
|
|
1669
|
+
* the one at the line's own location when the browser gave one, otherwise the
|
|
1670
|
+
* oldest unclaimed failure with the same status (or the same network error).
|
|
1671
|
+
* The line's text gains the method, the URL and the status, so the note says
|
|
1672
|
+
* what failed; `url` and `status` ride along for the report.
|
|
1673
|
+
*
|
|
1674
|
+
* @param {Array<{type: string, text: string, locationUrl?: string}>} entries
|
|
1675
|
+
* @param {Array<Object>} failures the driver's recent failed requests
|
|
1676
|
+
* @returns {Array<Object>}
|
|
1677
|
+
*/
|
|
1678
|
+
export function attachNetworkFailures(entries, failures) {
|
|
1679
|
+
return entries.map(entry => {
|
|
1680
|
+
const { locationUrl, ...rest } = entry;
|
|
1681
|
+
if (!FAILED_RESOURCE.test(String(entry.text || ''))) return rest;
|
|
1682
|
+
const status = Number((String(entry.text).match(/status of (\d{3})/) || [])[1]) || null;
|
|
1683
|
+
const location = locationUrl ? networkUrl(locationUrl) : null;
|
|
1684
|
+
let match = location ? failures.find(failure => !failure.claimed && failure.url === location) : null;
|
|
1685
|
+
if (!match) {
|
|
1686
|
+
match = failures.find(failure => !failure.claimed && (status ? failure.status === status : failure.status === null && failure.error && String(entry.text).includes(failure.error)));
|
|
1687
|
+
}
|
|
1688
|
+
const url = match ? match.url : location;
|
|
1689
|
+
if (!url) return rest;
|
|
1690
|
+
if (match) match.claimed = true;
|
|
1691
|
+
const shownStatus = match && match.status ? match.status : status;
|
|
1692
|
+
const method = match && match.method ? `${match.method} ` : '';
|
|
1693
|
+
return { ...rest, text: `${rest.text} (${method}${url}${shownStatus ? ` answered ${shownStatus}` : ''})`, url, status: shownStatus || null };
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1146
1697
|
/**
|
|
1147
1698
|
* Playwright names its recording with a random id. The upload allowlist and
|
|
1148
1699
|
* the server's 90 day lifecycle rule are written against `session.webm`, so
|