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