sitelooper 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/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/sitelooper.js +6 -0
- package/dist/agent/llm.js +460 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/loop.js +870 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompt.js +40 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/report.js +545 -0
- package/dist/agent/report.js.map +1 -0
- package/dist/agent/tools.js +1147 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/cli.js +1692 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/browser.js +218 -0
- package/dist/daemon/browser.js.map +1 -0
- package/dist/daemon/codegen.js +241 -0
- package/dist/daemon/codegen.js.map +1 -0
- package/dist/daemon/dialogs.js +57 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/diff.js +198 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/fingerprint.js +98 -0
- package/dist/daemon/fingerprint.js.map +1 -0
- package/dist/daemon/inputs.js +134 -0
- package/dist/daemon/inputs.js.map +1 -0
- package/dist/daemon/recorder.js +1232 -0
- package/dist/daemon/recorder.js.map +1 -0
- package/dist/daemon/refs.js +194 -0
- package/dist/daemon/refs.js.map +1 -0
- package/dist/daemon/server.js +1724 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/state.js +239 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/doctor.js +90 -0
- package/dist/doctor.js.map +1 -0
- package/dist/shared/paths.js +80 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/secrets.js +92 -0
- package/dist/shared/secrets.js.map +1 -0
- package/dist/shared/text.js +39 -0
- package/dist/shared/text.js.map +1 -0
- package/dist/skills/compile.js +1420 -0
- package/dist/skills/compile.js.map +1 -0
- package/dist/skills/components.js +456 -0
- package/dist/skills/components.js.map +1 -0
- package/dist/skills/flow.js +1041 -0
- package/dist/skills/flow.js.map +1 -0
- package/dist/skills/learn.js +406 -0
- package/dist/skills/learn.js.map +1 -0
- package/dist/skills/ledger.js +304 -0
- package/dist/skills/ledger.js.map +1 -0
- package/dist/skills/relabel.js +206 -0
- package/dist/skills/relabel.js.map +1 -0
- package/dist/skills/repair.js +570 -0
- package/dist/skills/repair.js.map +1 -0
- package/dist/skills/replay.js +1281 -0
- package/dist/skills/replay.js.map +1 -0
- package/dist/skills/store.js +147 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/spec/check.js +428 -0
- package/dist/spec/check.js.map +1 -0
- package/dist/spec/diagnostics.js +58 -0
- package/dist/spec/diagnostics.js.map +1 -0
- package/dist/spec/emit.js +2084 -0
- package/dist/spec/emit.js.map +1 -0
- package/dist/spec/index.js +62 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/ir.js +216 -0
- package/dist/spec/ir.js.map +1 -0
- package/dist/spec/lift.js +162 -0
- package/dist/spec/lift.js.map +1 -0
- package/dist/spec/locators.js +270 -0
- package/dist/spec/locators.js.map +1 -0
- package/dist/spec/lower.js +124 -0
- package/dist/spec/lower.js.map +1 -0
- package/dist/spec/repair.js +657 -0
- package/dist/spec/repair.js.map +1 -0
- package/dist/spec/rerecord.js +169 -0
- package/dist/spec/rerecord.js.map +1 -0
- package/dist/spec/rethread.js +120 -0
- package/dist/spec/rethread.js.map +1 -0
- package/package.json +50 -0
- package/skills/sitelooper/SKILL.md +228 -0
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inFlightRequests } from '../daemon/browser.js';
|
|
4
|
+
import { clip } from '../shared/text.js';
|
|
5
|
+
import { captureSignature, diffSignatures } from '../daemon/diff.js';
|
|
6
|
+
import { html5DragDrop, reactSafeFill, reactSafeSelect, selectedOption, syntheticHover } from '../daemon/inputs.js';
|
|
7
|
+
import { tryRecipe } from '../skills/components.js';
|
|
8
|
+
import { resolveSecretsDeep, scrubSecrets, scrubSecretsDeep } from '../shared/secrets.js';
|
|
9
|
+
import { resolveTarget, snapshot, truncate } from '../daemon/refs.js';
|
|
10
|
+
import { fingerprintPage } from '../daemon/fingerprint.js';
|
|
11
|
+
import { isRecordable } from '../daemon/recorder.js';
|
|
12
|
+
import { urlPattern as compiledUrlPattern } from '../skills/compile.js';
|
|
13
|
+
import { renderReplay, replaySkill } from '../skills/replay.js';
|
|
14
|
+
const TARGET = {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Element target: an @ref from the latest snapshot (e.g. "@e12") or a CSS selector.',
|
|
17
|
+
};
|
|
18
|
+
const TOOL_RESULT_BUDGET = 4000;
|
|
19
|
+
/**
|
|
20
|
+
* Tools whose result gets a `[state: …]` summary of what the action changed on
|
|
21
|
+
* the page, so the agent does not spend a turn observing what it just did.
|
|
22
|
+
* goto/back already report the new url and title; read-only tools change
|
|
23
|
+
* nothing worth diffing.
|
|
24
|
+
*/
|
|
25
|
+
const STATE_CHANGING = new Set([
|
|
26
|
+
'click', 'dblclick', 'modifier_click', 'right_click', 'fill', 'type', 'press',
|
|
27
|
+
'select', 'check', 'drag', 'upload',
|
|
28
|
+
]);
|
|
29
|
+
/** Beat given to async renders (React, in-flight fetches) before the after-capture. */
|
|
30
|
+
const SETTLE_MS = 150;
|
|
31
|
+
/** Tools whose effect may be a navigation the app performs on the answer to a request. */
|
|
32
|
+
const NAVIGATING = new Set(['click', 'dblclick', 'press', 'submit', 'select']);
|
|
33
|
+
/** How long a click's late navigation is given before its effect is captured as final. */
|
|
34
|
+
const LATE_NAV_MS = 1_500;
|
|
35
|
+
/** A url that has not moved for this long, after moving, is where the step left the page. */
|
|
36
|
+
const URL_STILL_MS = 500;
|
|
37
|
+
/**
|
|
38
|
+
* How long "no request in flight" must hold before it means "no navigation
|
|
39
|
+
* coming". Zero: the DOM settle that precedes this wait (≥250ms quiet) is the
|
|
40
|
+
* grace, and a request the click started is already counted by then.
|
|
41
|
+
*/
|
|
42
|
+
const LATE_NAV_GRACE_MS = 0;
|
|
43
|
+
const MAX_BATCH_STEPS = 10;
|
|
44
|
+
/**
|
|
45
|
+
* Tools a batch may contain: mechanical actions and cheap checks whose outcome
|
|
46
|
+
* the agent does not need to see before choosing the next step. Everything else
|
|
47
|
+
* (navigation, snapshot/eval/screenshot output, report, nested batch) either
|
|
48
|
+
* feeds a decision or produces output that only makes sense on its own turn.
|
|
49
|
+
*/
|
|
50
|
+
const BATCHABLE = new Set([
|
|
51
|
+
'click', 'dblclick', 'modifier_click', 'right_click', 'fill', 'type', 'press',
|
|
52
|
+
'select', 'check', 'hover', 'scroll_into_view', 'wait_for', 'read', 'read_all',
|
|
53
|
+
'upload', 'dialog_expect',
|
|
54
|
+
]);
|
|
55
|
+
/** Per-step lines stay short so a long batch still reads at a glance. */
|
|
56
|
+
const BATCH_STEP_CHARS = 160;
|
|
57
|
+
const BATCH_STEP_ERROR_CHARS = 300;
|
|
58
|
+
/** A combined diff spans several actions, so it may list more churn than one action's. */
|
|
59
|
+
const BATCH_LINE_BUDGET = 20;
|
|
60
|
+
export const TOOL_DEFS = [
|
|
61
|
+
{
|
|
62
|
+
name: 'snapshot',
|
|
63
|
+
description: 'Accessibility snapshot of the current page with @ref handles for elements. Returns interactive/labelled elements only by default (what you need to pick something to act on); pass full:true for the complete tree including static text nodes. Call after navigation or DOM changes; refs from older snapshots go stale. For a specific value prefer read/read_all over a full tree.',
|
|
64
|
+
parameters: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
full: { type: 'boolean', description: 'Include non-interactive text nodes too (default false = interactive/labelled elements only).' },
|
|
68
|
+
selector: { type: 'string', description: 'Scope the snapshot to this CSS selector.' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'click',
|
|
74
|
+
description: 'Click an element.',
|
|
75
|
+
parameters: { type: 'object', required: ['target'], properties: { target: TARGET } },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'dblclick',
|
|
79
|
+
description: 'Double-click an element.',
|
|
80
|
+
parameters: { type: 'object', required: ['target'], properties: { target: TARGET } },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'modifier_click',
|
|
84
|
+
description: 'Click while holding modifier keys (e.g. Shift/Control-click gestures).',
|
|
85
|
+
parameters: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
required: ['target', 'modifiers'],
|
|
88
|
+
properties: {
|
|
89
|
+
target: TARGET,
|
|
90
|
+
modifiers: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: { type: 'string', enum: ['Shift', 'Control', 'Alt', 'Meta'] },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'right_click',
|
|
99
|
+
description: 'Right-click (context menu) an element.',
|
|
100
|
+
parameters: { type: 'object', required: ['target'], properties: { target: TARGET } },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'fill',
|
|
104
|
+
description: 'Set the full value of an input/textarea. React-safe: works on controlled components and number inputs (clears first). Use for text fields; use select for <select>. A {{env:NAME}} secret marker in value is resolved at execution time — pass it through verbatim.',
|
|
105
|
+
parameters: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
required: ['target', 'value'],
|
|
108
|
+
properties: { target: TARGET, value: { type: 'string' } },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'type',
|
|
113
|
+
description: 'Type text key-by-key into an element (triggers per-keystroke handlers, e.g. autocomplete). A {{env:NAME}} secret marker in text is resolved at execution time — pass it through verbatim.',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
required: ['target', 'text'],
|
|
117
|
+
properties: {
|
|
118
|
+
target: TARGET,
|
|
119
|
+
text: { type: 'string' },
|
|
120
|
+
delay_ms: { type: 'number', description: 'Delay between keystrokes (default 20).' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'press',
|
|
126
|
+
description: 'Press a key or chord (e.g. "Enter", "Escape", "Control+a") on an element or the page.',
|
|
127
|
+
parameters: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
required: ['key'],
|
|
130
|
+
properties: { key: { type: 'string' }, target: { ...TARGET, description: TARGET.description + ' Optional; defaults to the focused element.' } },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'select',
|
|
135
|
+
description: 'Choose an option in a <select>, matching by visible label first, then by value.',
|
|
136
|
+
parameters: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
required: ['target', 'option'],
|
|
139
|
+
properties: { target: TARGET, option: { type: 'string' } },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'check',
|
|
144
|
+
description: 'Set a checkbox/radio to checked or unchecked.',
|
|
145
|
+
parameters: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
required: ['target'],
|
|
148
|
+
properties: { target: TARGET, checked: { type: 'boolean', description: 'Default true.' } },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'hover',
|
|
153
|
+
description: 'Hover an element (also dispatches synthetic mouseover/enter for JS-driven menus).',
|
|
154
|
+
parameters: { type: 'object', required: ['target'], properties: { target: TARGET } },
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'scroll_into_view',
|
|
158
|
+
description: 'Scroll an element into view.',
|
|
159
|
+
parameters: { type: 'object', required: ['target'], properties: { target: TARGET } },
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'drag',
|
|
163
|
+
description: 'Drag one element onto another. Tries a real mouse drag, then falls back to synthetic HTML5 drag events (dragstart/dragover/drop with a DataTransfer).',
|
|
164
|
+
parameters: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
required: ['source', 'target'],
|
|
167
|
+
properties: {
|
|
168
|
+
source: { ...TARGET, description: 'Element to drag. ' + TARGET.description },
|
|
169
|
+
target: { ...TARGET, description: 'Drop target. ' + TARGET.description },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'wait_for',
|
|
175
|
+
description: 'Wait for a condition on a selector: visible, hidden, text_equals, text_contains, or count. Use this instead of sleeping or polling. Returns immediately if the condition already holds. Use count/text only for a value you expect to CHANGE — counting rendered rows is unreliable on virtualised lists (only visible rows exist in the DOM), so wait on a stable indicator instead.',
|
|
176
|
+
parameters: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
required: ['target', 'state'],
|
|
179
|
+
properties: {
|
|
180
|
+
target: TARGET,
|
|
181
|
+
state: { type: 'string', enum: ['visible', 'hidden', 'text_equals', 'text_contains', 'count'] },
|
|
182
|
+
text: { type: 'string', description: 'Expected text for text_equals/text_contains.' },
|
|
183
|
+
count: { type: 'number', description: 'Expected element count for count.' },
|
|
184
|
+
timeout_ms: { type: 'number', description: 'Default 10000.' },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'read',
|
|
190
|
+
description: 'Read text/value/attribute from ONE element — much cheaper than a full snapshot for spot checks. ' +
|
|
191
|
+
'The target must match exactly one element, or the read fails: a snapshot ref (@e123) always does, ' +
|
|
192
|
+
'and a bare tag like "h1" usually does not. Use read_all to read every match, or what=count to count them. ' +
|
|
193
|
+
'If the value matters beyond this glance — a reference, id, name, or total the task or a later step will use — ' +
|
|
194
|
+
'pass `label` NOW: a labelled value is published under that name automatically; an unlabelled one stays anonymous. ' +
|
|
195
|
+
'what=url reads the current page URL (no target) — the way to report where a record lives.',
|
|
196
|
+
parameters: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
required: ['what'],
|
|
199
|
+
properties: {
|
|
200
|
+
target: TARGET,
|
|
201
|
+
what: { type: 'string', enum: ['text', 'value', 'attr', 'count', 'url'] },
|
|
202
|
+
attr: { type: 'string', description: 'Attribute name when what=attr.' },
|
|
203
|
+
label: {
|
|
204
|
+
type: 'string',
|
|
205
|
+
description: 'Name for this value, the way a person would say it: order_reference, unit_price, customer_name. Later steps address the value by this name.',
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'read_all',
|
|
212
|
+
description: 'Like read, but returns a JSON array of the value across EVERY element matching the selector — read a whole list of rows/cells in one call instead of many. what: text (visible text of each), value (input value of each), attr (an attribute of each), or count.',
|
|
213
|
+
parameters: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
required: ['target', 'what'],
|
|
216
|
+
properties: {
|
|
217
|
+
target: TARGET,
|
|
218
|
+
what: { type: 'string', enum: ['text', 'value', 'attr', 'count'] },
|
|
219
|
+
attr: { type: 'string', description: 'Attribute name when what=attr.' },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'eval',
|
|
225
|
+
description: 'Escape hatch: run a JavaScript expression in the page and return its JSON-serialised result. Prefer the dedicated tools.',
|
|
226
|
+
parameters: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
required: ['expression'],
|
|
229
|
+
properties: { expression: { type: 'string', description: 'JS expression or IIFE body, e.g. "document.title".' } },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'fetch_source',
|
|
234
|
+
description: "Fetch the raw HTTP response body for a URL (default: the current page's URL) using the browser's cookies, WITHOUT executing JavaScript. This is the SERVER-RENDERED source — every other tool shows the live post-hydration DOM instead. Use it before making any claim about what the server sent, and to tell an SSR bug (element missing from the source) apart from a hydration bug (present in the source, absent live).",
|
|
235
|
+
parameters: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
properties: {
|
|
238
|
+
url: { type: 'string', description: 'Absolute or page-relative URL; defaults to the current page URL.' },
|
|
239
|
+
contains: {
|
|
240
|
+
type: 'string',
|
|
241
|
+
description: 'Return only the lines containing this substring (plus a match count) instead of the whole body — use it on large documents to check for a specific element.',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'goto',
|
|
248
|
+
description: 'Navigate the current tab to a URL and wait for load.',
|
|
249
|
+
parameters: { type: 'object', required: ['url'], properties: { url: { type: 'string' } } },
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'back',
|
|
253
|
+
description: 'Go back one history entry.',
|
|
254
|
+
parameters: { type: 'object', properties: {} },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'tabs',
|
|
258
|
+
description: 'List open tabs, or switch the active tab by index.',
|
|
259
|
+
parameters: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: { switch_to: { type: 'number', description: 'Tab index to make active; omit to just list.' } },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'upload',
|
|
266
|
+
description: 'Set files on a file input.',
|
|
267
|
+
parameters: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
required: ['target', 'paths'],
|
|
270
|
+
properties: { target: TARGET, paths: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths.' } },
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'download',
|
|
275
|
+
description: 'Click an element and capture the download it triggers; saves to the session downloads dir (or save_path).',
|
|
276
|
+
parameters: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
required: ['target'],
|
|
279
|
+
properties: { target: TARGET, save_path: { type: 'string' } },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'set_viewport',
|
|
284
|
+
description: 'Resize the viewport.',
|
|
285
|
+
parameters: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
required: ['width', 'height'],
|
|
288
|
+
properties: { width: { type: 'number' }, height: { type: 'number' } },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'set_offline',
|
|
293
|
+
description: 'Toggle network offline mode.',
|
|
294
|
+
parameters: { type: 'object', required: ['offline'], properties: { offline: { type: 'boolean' } } },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'screenshot',
|
|
298
|
+
description: 'Save a screenshot to disk and return its path (for evidence; you cannot see images).',
|
|
299
|
+
parameters: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: { path: { type: 'string' }, full_page: { type: 'boolean' } },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'dialog_expect',
|
|
306
|
+
description: 'Arm handling for native dialogs (alert/confirm/prompt) triggered by your NEXT action: accept or dismiss, with optional prompt text. Call BEFORE the click that opens the dialog. Captured dialog messages are returned in that action\'s result.',
|
|
307
|
+
parameters: {
|
|
308
|
+
type: 'object',
|
|
309
|
+
required: ['action'],
|
|
310
|
+
properties: {
|
|
311
|
+
action: { type: 'string', enum: ['accept', 'dismiss'] },
|
|
312
|
+
prompt_text: { type: 'string', description: 'Text to enter if the dialog is a prompt().' },
|
|
313
|
+
count: { type: 'number', description: 'How many dialogs to cover (default 1).' },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'batch',
|
|
319
|
+
description: 'Execute several actions in ONE call when you already know each next step with certainty (e.g. filling a form you have just seen, then submitting it). Steps run in order and stop at the first error; the result lists each step\'s outcome, which steps did not run, and ONE combined [state: …] summary. Do not batch across a judgment point — anything whose outcome you must see before deciding the next action.',
|
|
320
|
+
parameters: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
required: ['steps'],
|
|
323
|
+
properties: {
|
|
324
|
+
steps: {
|
|
325
|
+
type: 'array',
|
|
326
|
+
minItems: 2,
|
|
327
|
+
description: `Ordered steps, 2-${MAX_BATCH_STEPS}. A single-step batch is pointless — call the tool directly instead.`,
|
|
328
|
+
items: {
|
|
329
|
+
type: 'object',
|
|
330
|
+
required: ['tool', 'args'],
|
|
331
|
+
properties: {
|
|
332
|
+
tool: { type: 'string', enum: [...BATCHABLE] },
|
|
333
|
+
args: { type: 'object', description: "That tool's own arguments." },
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: 'run_skill',
|
|
342
|
+
description: 'Replay a stored procedure listed under [skills] in the instruction, deterministically and without further reasoning: every recorded step runs in order with its parameters filled in, stopping at the first step that no longer works. Returns each step\'s outcome and every value read back from the live page. If it stops part-way, the steps that ran HAVE changed the page — observe, then continue from there yourself. Call it as your FIRST action when a listed procedure matches the instruction.',
|
|
343
|
+
parameters: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
required: ['id', 'params'],
|
|
346
|
+
properties: {
|
|
347
|
+
id: { type: 'string', description: 'The skill id shown in the [skills] list, e.g. "s_9f2a1b".' },
|
|
348
|
+
params: {
|
|
349
|
+
type: 'object',
|
|
350
|
+
description: 'Values for every {{vN}} slot in the skill template, taken from the instruction (e.g. {"v1": "x7 RD Part A", "v2": "100"}).',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'report',
|
|
357
|
+
description: 'REQUIRED final call: report the outcome of the instruction. Nothing after this is executed. Keep summary to one short paragraph.',
|
|
358
|
+
parameters: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
required: ['status', 'summary'],
|
|
361
|
+
properties: {
|
|
362
|
+
status: { type: 'string', enum: ['success', 'failure', 'blocked'] },
|
|
363
|
+
summary: { type: 'string', description: 'One concise paragraph: what happened and what was verified.' },
|
|
364
|
+
details: { type: 'string', description: 'Optional extra detail (errors seen, workaround used).' },
|
|
365
|
+
evidence: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: {
|
|
368
|
+
url: { type: 'string' },
|
|
369
|
+
capturedDialogs: { type: 'array', items: { type: 'string' } },
|
|
370
|
+
values: {
|
|
371
|
+
type: 'object',
|
|
372
|
+
description: 'Every concrete value you read off the page, as name -> value. ' +
|
|
373
|
+
'ALWAYS include any reference the APP assigned to a record you created or opened ' +
|
|
374
|
+
'(an order number, ticket ref, uid, generated id) — later work addresses that record by it, ' +
|
|
375
|
+
'and a value left only in the summary prose cannot be used. Names should be ones a person would ' +
|
|
376
|
+
'write (order_reference, unit_price), not selector fragments.',
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
/** Tool definitions for a session: run_skill only exists when a skill store is attached. */
|
|
385
|
+
export function toolDefsFor(session) {
|
|
386
|
+
return session.learn ? TOOL_DEFS : TOOL_DEFS.filter((t) => t.name !== 'run_skill');
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Execute one tool call against the live browser session. Always returns a
|
|
390
|
+
* string result (errors included) so the loop can hand it back to the model.
|
|
391
|
+
* Captured native dialogs are appended to whichever tool result follows them.
|
|
392
|
+
*/
|
|
393
|
+
export async function executeTool(session, name, args, screenshotDir,
|
|
394
|
+
/** Cancels cooperative waits (wait_for polling) when the caller's deadline expires. */
|
|
395
|
+
signal) {
|
|
396
|
+
try {
|
|
397
|
+
// Inside the guard: a dead browser (getPage throwing) must come back as
|
|
398
|
+
// an error result the loop can report, never a rejection that ends the
|
|
399
|
+
// instruction with no report and a dangling user message.
|
|
400
|
+
if (name === 'batch')
|
|
401
|
+
return await executeBatch(session, args, screenshotDir, signal);
|
|
402
|
+
if (name === 'run_skill')
|
|
403
|
+
return await executeSkill(session, args, screenshotDir, signal);
|
|
404
|
+
const diffing = STATE_CHANGING.has(name) ? await session.getPage().catch(() => null) : null;
|
|
405
|
+
const before = diffing ? await captureSignature(diffing) : null;
|
|
406
|
+
const { result } = await runStep(session, name, args, screenshotDir, signal, { before });
|
|
407
|
+
const stateNote = diffing && before ? scrubSecrets(await stateDiff(diffing, before)) : '';
|
|
408
|
+
return {
|
|
409
|
+
result: truncate(result + stateNote + dialogNote(session), TOOL_RESULT_BUDGET + 8200),
|
|
410
|
+
isError: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
return { result: truncate(`ERROR: ${explainError(err, args)}`, TOOL_RESULT_BUDGET), isError: true };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Replay a stored skill as one tool call. The replay goes through runStep
|
|
419
|
+
* for every step, so each replayed action is recorded exactly like an
|
|
420
|
+
* agent-chosen one — which is what lets a replay-then-repair be compiled into
|
|
421
|
+
* a variant afterwards. A refusal (wrong page, missing params) is an error
|
|
422
|
+
* result; a part-way stop is not, since the page has changed.
|
|
423
|
+
*/
|
|
424
|
+
async function executeSkill(session, args, screenshotDir, signal) {
|
|
425
|
+
const store = session.learn;
|
|
426
|
+
if (!store)
|
|
427
|
+
return { result: 'ERROR: no skill store is attached to this session.', isError: true };
|
|
428
|
+
const id = String(args.id ?? '').trim();
|
|
429
|
+
const skill = id ? store.get(id) : null;
|
|
430
|
+
if (!skill)
|
|
431
|
+
return { result: `ERROR: unknown skill ${JSON.stringify(id)} — use an id from the [skills] list.`, isError: true };
|
|
432
|
+
const rawParams = args.params && typeof args.params === 'object' && !Array.isArray(args.params) ? args.params : {};
|
|
433
|
+
const params = Object.fromEntries(Object.entries(rawParams).map(([k, v]) => [k, String(v ?? '')]));
|
|
434
|
+
const page = await session.getPage();
|
|
435
|
+
const before = await captureSignature(page);
|
|
436
|
+
// The replay stays on its page: a replayed click that opens a tab (a
|
|
437
|
+
// recorded stray click on a target=_blank link) must not move it.
|
|
438
|
+
const replay = await session.withPinnedPage(page, () => replaySkill(skill, params, {
|
|
439
|
+
page,
|
|
440
|
+
signal,
|
|
441
|
+
exec: async (tool, stepArgs, resolved, via) => runStep(session, tool, stepArgs, screenshotDir, signal, { resolved, via }),
|
|
442
|
+
}));
|
|
443
|
+
// Mechanism 2 (PLAN-replay-v2): a url segment that soft-matched and was
|
|
444
|
+
// then walked PAST has demonstrated volatility — generalise exactly that
|
|
445
|
+
// segment in the stored pattern, permanently. Segments that never vary stay
|
|
446
|
+
// exact. A soft match the replay did NOT get past stays unconfirmed.
|
|
447
|
+
const confirmed = replay.generalisations.filter((g) => g.kind === 'precondition' ? replay.stepsRun >= 1 : replay.ok || (g.step !== undefined && replay.stepsRun > g.step));
|
|
448
|
+
if (confirmed.length) {
|
|
449
|
+
const fresh = store.get(skill.id);
|
|
450
|
+
if (fresh) {
|
|
451
|
+
let changed = false;
|
|
452
|
+
for (const g of confirmed) {
|
|
453
|
+
if (g.kind === 'precondition') {
|
|
454
|
+
fresh.preconditions.urlPattern = g.pattern;
|
|
455
|
+
changed = true;
|
|
456
|
+
}
|
|
457
|
+
else if (g.step !== undefined) {
|
|
458
|
+
const st = fresh.steps[g.step - 1];
|
|
459
|
+
if (st && st.tool !== 'loop' && st.expect?.urlPattern) {
|
|
460
|
+
st.expect.urlPattern = g.pattern;
|
|
461
|
+
changed = true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (changed)
|
|
466
|
+
store.put(fresh);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const stateNote = before && replay.stepsRun ? scrubSecrets(await stateDiff(page, before, BATCH_LINE_BUDGET)) : '';
|
|
470
|
+
const body = scrubSecrets(renderReplay(skill, replay)) + stateNote + dialogNote(session);
|
|
471
|
+
return { result: truncate(body, TOOL_RESULT_BUDGET + 8200), isError: Boolean(replay.refused), replay };
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* One tool call with its recording, and nothing else: no dialog drain. Shared
|
|
475
|
+
* by the single-tool path, by every step of a batch, and by skill replay.
|
|
476
|
+
* In learning mode a per-step page diff is captured around state-changing
|
|
477
|
+
* steps and stored with the recording — that is what becomes a replayed
|
|
478
|
+
* step's expectation.
|
|
479
|
+
*/
|
|
480
|
+
async function runStep(session, name, args, screenshotDir, signal, opts = {}) {
|
|
481
|
+
// Describe the targets BEFORE acting: a click can navigate or unmount the
|
|
482
|
+
// element, and a recorder that runs afterwards has nothing left to describe.
|
|
483
|
+
// Recording never fails a run — a broken capture just means a missing step.
|
|
484
|
+
const recorder = session.script;
|
|
485
|
+
const page = recorder || session.learn ? await session.getPage() : null;
|
|
486
|
+
const pending = recorder && page && isRecordable(name)
|
|
487
|
+
? await recorder.prepare(page, name, args, opts.resolved).catch(() => null)
|
|
488
|
+
: null;
|
|
489
|
+
const wantDiff = Boolean(session.learn) && page && STATE_CHANGING.has(name);
|
|
490
|
+
const before = wantDiff ? (opts.before ?? (await captureSignature(page))) : null;
|
|
491
|
+
// Secrets ({{env:NAME}}) resolve HERE and only here — after the recorder
|
|
492
|
+
// captured the marker-bearing args above, immediately before the browser
|
|
493
|
+
// needs the real value. Everything persisted or shown to the model keeps
|
|
494
|
+
// the marker; scrubbing below catches values the page echoes back.
|
|
495
|
+
const result = scrubSecrets(await dispatch(session, name, resolveSecretsDeep(args), screenshotDir, signal, opts.resolved));
|
|
496
|
+
let diff;
|
|
497
|
+
let fingerprintAfter;
|
|
498
|
+
if (wantDiff && before) {
|
|
499
|
+
let after = await settledSignature(page);
|
|
500
|
+
// A click that starts a request and routes on its answer looks finished
|
|
501
|
+
// while the request is in flight: the DOM is quiet and the url is still
|
|
502
|
+
// the old one. fwat2's sign-in was recorded that way — expected url "/"
|
|
503
|
+
// and an added "Logging in..." button — and the replay, which arrived at
|
|
504
|
+
// the landing page, could match neither. Give a late navigation a moment
|
|
505
|
+
// before the effect is taken as final; a step that changed the url
|
|
506
|
+
// already, or changes nothing, pays nothing.
|
|
507
|
+
// The same in the other direction: a click whose url changed once and
|
|
508
|
+
// then again (Odoo autosaves the record, then opens the catalogue) was
|
|
509
|
+
// captured between the two, and the compiler cut a segment boundary at
|
|
510
|
+
// a page the procedure was only passing through. So: wait until the url
|
|
511
|
+
// has held still, whether it has moved yet or not.
|
|
512
|
+
if (after && NAVIGATING.has(name)) {
|
|
513
|
+
const seen = await urlHeldStill(page, before.url, () => inFlightRequests(page));
|
|
514
|
+
if (seen !== after.url)
|
|
515
|
+
after = await settledSignature(page);
|
|
516
|
+
}
|
|
517
|
+
if (after) {
|
|
518
|
+
diff = scrubSecretsDeep({
|
|
519
|
+
url: after.url,
|
|
520
|
+
alerts: after.alerts.filter((a) => !before.alerts.includes(a)),
|
|
521
|
+
added: after.lines.filter((l) => !before.lines.includes(l)).slice(0, 20),
|
|
522
|
+
});
|
|
523
|
+
// The step crossed a page-template seam (its url pattern changed):
|
|
524
|
+
// fingerprint the new page so compile can split a skill here and gate
|
|
525
|
+
// the next segment on the page it actually runs on.
|
|
526
|
+
if (compiledUrlPattern(after.url) !== compiledUrlPattern(before.url)) {
|
|
527
|
+
fingerprintAfter = (await fingerprintPage(page)) ?? undefined;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
recorder?.commit(pending, result, { diff, via: opts.via, fingerprintAfter });
|
|
532
|
+
return { result, diff };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Where a navigating tool left the url once it has held still. A late
|
|
536
|
+
* navigation rides on a request the tool started, so a page with no request
|
|
537
|
+
* in flight and the url it began on is not going anywhere: the wait ends
|
|
538
|
+
* there rather than at the deadline. Set 30's zero-model replays ran at
|
|
539
|
+
* twice set 28's wall clock because every non-navigating click sat out the
|
|
540
|
+
* full LATE_NAV_MS (78 actions, ~30s of nothing on repairdesk).
|
|
541
|
+
*/
|
|
542
|
+
export async function urlHeldStill(page, beforeUrl, inFlight, timing = {}) {
|
|
543
|
+
const lateNavMs = timing.lateNavMs ?? LATE_NAV_MS;
|
|
544
|
+
const stillMs = timing.stillMs ?? URL_STILL_MS;
|
|
545
|
+
const graceMs = timing.graceMs ?? LATE_NAV_GRACE_MS;
|
|
546
|
+
const pollMs = timing.pollMs ?? 100;
|
|
547
|
+
const start = Date.now();
|
|
548
|
+
const deadline = start + lateNavMs;
|
|
549
|
+
let seen = page.url();
|
|
550
|
+
let stillSince = start;
|
|
551
|
+
// Check first, sleep after: the caller has already let the DOM settle, so
|
|
552
|
+
// a request the click started is registered by now, and the common case
|
|
553
|
+
// (a click that navigates nowhere) should cost nothing here.
|
|
554
|
+
for (;;) {
|
|
555
|
+
const now = page.url();
|
|
556
|
+
if (now !== seen) {
|
|
557
|
+
seen = now;
|
|
558
|
+
stillSince = Date.now();
|
|
559
|
+
}
|
|
560
|
+
else if (now !== beforeUrl) {
|
|
561
|
+
if (Date.now() - stillSince >= stillMs)
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
else if (Date.now() - start >= graceMs && inFlight() === 0) {
|
|
565
|
+
break; // nothing asked of the server, so nothing to route on
|
|
566
|
+
}
|
|
567
|
+
if (Date.now() >= deadline)
|
|
568
|
+
break;
|
|
569
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
570
|
+
}
|
|
571
|
+
return seen;
|
|
572
|
+
}
|
|
573
|
+
async function settledSignature(page) {
|
|
574
|
+
try {
|
|
575
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
576
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
577
|
+
return await captureSignature(page);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function explainError(err, args) {
|
|
584
|
+
const message = err instanceof Error ? err.message.split('\nCall log:')[0] : String(err);
|
|
585
|
+
if (/strict mode violation/i.test(message)) {
|
|
586
|
+
// Playwright's raw strict-mode error dumps every matched element; replace
|
|
587
|
+
// it with a concise, one-step-fixable hint so the agent disambiguates
|
|
588
|
+
// instead of burning a turn discovering the syntax.
|
|
589
|
+
const n = /resolved to (\d+) elements/.exec(message)?.[1] ?? 'multiple';
|
|
590
|
+
const sel = JSON.stringify(args.target ?? args.source ?? '');
|
|
591
|
+
return `selector ${sel} matched ${n} elements — refine it, or append " >> nth=0" (nth=N for another) to target exactly one.`;
|
|
592
|
+
}
|
|
593
|
+
return message;
|
|
594
|
+
}
|
|
595
|
+
/** Dialogs captured since the last drain, as a trailing note (or ''). */
|
|
596
|
+
function dialogNote(session) {
|
|
597
|
+
const dialogs = session.dialogs.drain();
|
|
598
|
+
return dialogs.length
|
|
599
|
+
? '\n[native dialogs: ' +
|
|
600
|
+
dialogs.map((d) => `${d.type}(${JSON.stringify(d.message)}) → ${d.action}`).join('; ') +
|
|
601
|
+
']'
|
|
602
|
+
: '';
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Run an ordered list of known-next actions in one turn, stopping at the first
|
|
606
|
+
* error, with a single combined state diff. Validation is total and happens
|
|
607
|
+
* before anything runs, so a typo in step 4 cannot leave steps 1-3 applied.
|
|
608
|
+
*
|
|
609
|
+
* isError is true only when NOTHING ran: a batch that got partway through has
|
|
610
|
+
* changed the page, and flagging that as an error would read to the agent as
|
|
611
|
+
* "no effect" — the per-step lines carry the partial outcome instead.
|
|
612
|
+
*/
|
|
613
|
+
async function executeBatch(session, args, screenshotDir, signal) {
|
|
614
|
+
const fail = (message) => ({
|
|
615
|
+
result: truncate(`ERROR: ${message}`, TOOL_RESULT_BUDGET),
|
|
616
|
+
isError: true,
|
|
617
|
+
});
|
|
618
|
+
const raw = args.steps;
|
|
619
|
+
if (!Array.isArray(raw))
|
|
620
|
+
return fail('batch requires a "steps" array.');
|
|
621
|
+
if (raw.length < 2) {
|
|
622
|
+
return fail('batch requires at least 2 steps — for a single action, call that tool directly.');
|
|
623
|
+
}
|
|
624
|
+
if (raw.length > MAX_BATCH_STEPS) {
|
|
625
|
+
return fail(`batch accepts at most ${MAX_BATCH_STEPS} steps (got ${raw.length}) — split it into several batches. Nothing was executed.`);
|
|
626
|
+
}
|
|
627
|
+
const steps = [];
|
|
628
|
+
for (const [i, entry] of raw.entries()) {
|
|
629
|
+
const step = entry;
|
|
630
|
+
const tool = typeof step?.tool === 'string' ? step.tool : '';
|
|
631
|
+
if (!BATCHABLE.has(tool)) {
|
|
632
|
+
return fail(`step ${i + 1}: ${tool ? `"${tool}" cannot be used inside a batch` : 'missing "tool"'} — allowed tools are ${[...BATCHABLE].join(', ')}. Nothing was executed; re-issue without that step.`);
|
|
633
|
+
}
|
|
634
|
+
const stepArgs = step.args;
|
|
635
|
+
if (stepArgs !== undefined && (typeof stepArgs !== 'object' || stepArgs === null || Array.isArray(stepArgs))) {
|
|
636
|
+
return fail(`step ${i + 1}: "args" must be an object. Nothing was executed.`);
|
|
637
|
+
}
|
|
638
|
+
steps.push({ tool, args: (stepArgs ?? {}) });
|
|
639
|
+
}
|
|
640
|
+
const page = await session.getPage().catch(() => null);
|
|
641
|
+
const before = page ? await captureSignature(page) : null;
|
|
642
|
+
const lines = [];
|
|
643
|
+
const notes = [];
|
|
644
|
+
let ran = 0;
|
|
645
|
+
let failedAt = -1;
|
|
646
|
+
for (const [i, step] of steps.entries()) {
|
|
647
|
+
if (signal?.aborted) {
|
|
648
|
+
notes.push(`[batch stopped: instruction budget exhausted; ${notRun(i, steps.length)}]`);
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
const head = `${i + 1}. ${step.tool} ${summarize(step.args)} → `;
|
|
652
|
+
try {
|
|
653
|
+
const { result } = await runStep(session, step.tool, step.args, screenshotDir, signal);
|
|
654
|
+
lines.push(head + clip(result, BATCH_STEP_CHARS) + dialogNote(session).replace(/^\n/, ' '));
|
|
655
|
+
ran++;
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
lines.push(head + 'ERROR: ' + clip(explainError(err, step.args), BATCH_STEP_ERROR_CHARS) +
|
|
659
|
+
dialogNote(session).replace(/^\n/, ' '));
|
|
660
|
+
failedAt = i;
|
|
661
|
+
const remaining = notRun(i + 1, steps.length);
|
|
662
|
+
notes.push(`[stopped at step ${i + 1}${remaining ? `; ${remaining}` : ''}]`);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const stateNote = page && before && (ran || failedAt >= 0) ? scrubSecrets(await stateDiff(page, before, BATCH_LINE_BUDGET)) : '';
|
|
667
|
+
const body = [...lines, ...notes].join('\n') + stateNote;
|
|
668
|
+
// Nothing ran at all — either the first step failed or the budget expired
|
|
669
|
+
// before it started; that IS an error result.
|
|
670
|
+
if (!ran)
|
|
671
|
+
return { result: truncate(body || 'ERROR: batch ran no steps.', TOOL_RESULT_BUDGET + 8200), isError: true };
|
|
672
|
+
return { result: truncate(body, TOOL_RESULT_BUDGET + 8200), isError: false };
|
|
673
|
+
}
|
|
674
|
+
/** "steps 4-5 not run" for the tail starting at index `from`, or '' if none. */
|
|
675
|
+
function notRun(from, total) {
|
|
676
|
+
if (from >= total)
|
|
677
|
+
return '';
|
|
678
|
+
return from === total - 1 ? `step ${total} not run` : `steps ${from + 1}-${total} not run`;
|
|
679
|
+
}
|
|
680
|
+
function summarize(args) {
|
|
681
|
+
return clip(JSON.stringify(args), 80);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Summary of what the just-executed action changed, as `\n[state: …]`, or ''
|
|
685
|
+
* if it could not be determined. The action already succeeded by the time this
|
|
686
|
+
* runs, so nothing here may throw — a missing diff is the failure mode.
|
|
687
|
+
*/
|
|
688
|
+
async function stateDiff(page, before, lineBudget) {
|
|
689
|
+
try {
|
|
690
|
+
// One settle beat: DOM updates are usually async. Genuinely slow updates
|
|
691
|
+
// are still wait_for's job — the diff is a hint, not proof.
|
|
692
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
693
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
694
|
+
const after = await captureSignature(page);
|
|
695
|
+
return after ? `\n[state: ${diffSignatures(before, after, lineBudget)}]` : '';
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return '';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function dispatch(session, name, args, screenshotDir, signal,
|
|
702
|
+
/** Replay: pre-resolved locators that override args.target / args.source. */
|
|
703
|
+
resolved) {
|
|
704
|
+
if (signal?.aborted)
|
|
705
|
+
throw new Error('cancelled before starting: instruction budget exhausted');
|
|
706
|
+
const page = await session.getPage();
|
|
707
|
+
const t = (key = 'target') => resolved?.[key] ?? resolveTarget(page, String(args[key]));
|
|
708
|
+
const timeout = 10_000;
|
|
709
|
+
switch (name) {
|
|
710
|
+
case 'snapshot':
|
|
711
|
+
return snapshot(page, {
|
|
712
|
+
interactiveOnly: args.full !== true,
|
|
713
|
+
selector: args.selector ? String(args.selector) : undefined,
|
|
714
|
+
});
|
|
715
|
+
case 'click':
|
|
716
|
+
return robustClick(t(), { timeout });
|
|
717
|
+
case 'dblclick':
|
|
718
|
+
return robustClick(t(), { timeout, dbl: true });
|
|
719
|
+
case 'modifier_click': {
|
|
720
|
+
// Validate BEFORE clicking: a missing list used to click plainly and
|
|
721
|
+
// then throw on the result, so the model repeated a click that landed.
|
|
722
|
+
const modifiers = args.modifiers;
|
|
723
|
+
if (!Array.isArray(modifiers) || !modifiers.length)
|
|
724
|
+
throw new Error('modifier_click needs a non-empty modifiers list (Shift, Control, Alt, Meta); use click for a plain click');
|
|
725
|
+
await t().click({ timeout, modifiers: modifiers });
|
|
726
|
+
return `clicked with ${modifiers.join('+')}`;
|
|
727
|
+
}
|
|
728
|
+
case 'right_click':
|
|
729
|
+
await t().click({ timeout, button: 'right' });
|
|
730
|
+
return 'right-clicked';
|
|
731
|
+
case 'fill': {
|
|
732
|
+
// Component recipes (PLAN-component-recipes): a target inside a
|
|
733
|
+
// recognized widget (monaco, CodeMirror, contenteditable, ...) gets the
|
|
734
|
+
// family's stored, self-verifying recipe instead of the naive fill —
|
|
735
|
+
// which is known to lie on these widgets. Falls back to the naive path
|
|
736
|
+
// when nothing is recognized or the recipe cannot verify its effect.
|
|
737
|
+
const viaRecipe = await tryRecipe(page, t(), 'set-value', String(args.value ?? ''));
|
|
738
|
+
if (viaRecipe)
|
|
739
|
+
return viaRecipe;
|
|
740
|
+
await reactSafeFill(t(), String(args.value ?? ''));
|
|
741
|
+
return 'filled';
|
|
742
|
+
}
|
|
743
|
+
case 'type': {
|
|
744
|
+
const viaRecipe = await tryRecipe(page, t(), 'set-value', String(args.text ?? ''));
|
|
745
|
+
if (viaRecipe)
|
|
746
|
+
return viaRecipe;
|
|
747
|
+
await t().pressSequentially(String(args.text ?? ''), {
|
|
748
|
+
timeout,
|
|
749
|
+
delay: typeof args.delay_ms === 'number' ? args.delay_ms : 20,
|
|
750
|
+
});
|
|
751
|
+
return 'typed';
|
|
752
|
+
}
|
|
753
|
+
case 'press':
|
|
754
|
+
if (args.target)
|
|
755
|
+
await t().press(String(args.key), { timeout });
|
|
756
|
+
else
|
|
757
|
+
await page.keyboard.press(String(args.key));
|
|
758
|
+
return `pressed ${args.key}`;
|
|
759
|
+
case 'select': {
|
|
760
|
+
const viaRecipe = await tryRecipe(page, t(), 'select-option', String(args.option ?? ''));
|
|
761
|
+
if (viaRecipe)
|
|
762
|
+
return viaRecipe;
|
|
763
|
+
// The label is what the procedure MEANS ("the project I just created");
|
|
764
|
+
// the value is whatever the app keys that option by, minted per record
|
|
765
|
+
// as often as not (fwat3 03-add selected the project by its id and both
|
|
766
|
+
// replays timed out looking for it). A compiled step carries the label
|
|
767
|
+
// as `option` and the recorded value only as `optionValue`, the last
|
|
768
|
+
// resort when the label form finds nothing.
|
|
769
|
+
const fallbackValue = typeof args.optionValue === 'string' && args.optionValue ? args.optionValue : undefined;
|
|
770
|
+
const selected = await reactSafeSelect(t(), String(args.option), fallbackValue);
|
|
771
|
+
const chosen = await selectedOption(t());
|
|
772
|
+
return `selected ${JSON.stringify(selected)}${chosen ? ` label=${JSON.stringify(chosen.label)}` : ''}`;
|
|
773
|
+
}
|
|
774
|
+
case 'check':
|
|
775
|
+
if (args.checked === false)
|
|
776
|
+
await t().uncheck({ timeout });
|
|
777
|
+
else
|
|
778
|
+
await t().check({ timeout });
|
|
779
|
+
return args.checked === false ? 'unchecked' : 'checked';
|
|
780
|
+
case 'hover':
|
|
781
|
+
await syntheticHover(t());
|
|
782
|
+
return 'hovered';
|
|
783
|
+
case 'scroll_into_view':
|
|
784
|
+
await t().scrollIntoViewIfNeeded({ timeout });
|
|
785
|
+
return 'scrolled into view';
|
|
786
|
+
case 'drag': {
|
|
787
|
+
const source = t('source');
|
|
788
|
+
const target = t('target');
|
|
789
|
+
try {
|
|
790
|
+
await source.dragTo(target, { timeout });
|
|
791
|
+
return 'dragged (mouse)';
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
await html5DragDrop(source, target);
|
|
795
|
+
return 'dragged (synthetic HTML5 drag events fallback)';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
case 'wait_for':
|
|
799
|
+
return waitFor(page, args, signal);
|
|
800
|
+
case 'read': {
|
|
801
|
+
// The page URL is an observation with no element behind it: a record's
|
|
802
|
+
// address is often the only durable handle on it (the grafana flow
|
|
803
|
+
// could name every panel yet had no way to publish the dashboard uid).
|
|
804
|
+
if (args.what === 'url')
|
|
805
|
+
return JSON.stringify(page.url());
|
|
806
|
+
if (typeof args.target !== 'string' || !args.target.trim())
|
|
807
|
+
throw new Error(`read ${String(args.what)} needs a target (only what=url reads without one)`);
|
|
808
|
+
const loc = t();
|
|
809
|
+
// `count` asks HOW MANY, so plural is the answer, not an error.
|
|
810
|
+
if (args.what === 'count')
|
|
811
|
+
return String(await loc.count());
|
|
812
|
+
// Every ACTION already insists on a unique target: click and fill hand
|
|
813
|
+
// the locator to Playwright, whose strict mode throws on an ambiguous
|
|
814
|
+
// match, and the agent answers that by naming something specific. A
|
|
815
|
+
// singular read was the one exception — it took `.first()` of however
|
|
816
|
+
// many matched, silently.
|
|
817
|
+
//
|
|
818
|
+
// That is the fwod24 defect end to end. `read text h1` matched three
|
|
819
|
+
// headings on Odoo's form; we returned the first, and the recorder,
|
|
820
|
+
// seeing a count that was not 1, stored the locator with NO alternates
|
|
821
|
+
// (describeTarget bails before it derives any). The flow then threaded
|
|
822
|
+
// that value through eleven references. On both replays resolveChain
|
|
823
|
+
// met the same ambiguity, refused to guess which heading — correctly,
|
|
824
|
+
// since picking wrong reads another record's number — and had no
|
|
825
|
+
// fallback to try, so four of seven steps dropped to the model.
|
|
826
|
+
//
|
|
827
|
+
// Recording was accepting exactly what replay would refuse. Closing
|
|
828
|
+
// that costs about two turns per recording (9 of 54 singular reads
|
|
829
|
+
// across five recorded runs were ambiguous, three of them this bug),
|
|
830
|
+
// and the agent already has a unique-by-construction answer it reaches
|
|
831
|
+
// for unprompted in half of all reads: a snapshot ref.
|
|
832
|
+
//
|
|
833
|
+
// read_all stays plural. Reading every price in a table is the point,
|
|
834
|
+
// and 14 of its 16 uses matched many by design.
|
|
835
|
+
const n = await loc.count();
|
|
836
|
+
if (n > 1) {
|
|
837
|
+
throw new Error(`read matched ${n} elements for ${JSON.stringify(String(args.target))} — a read must name exactly one. ` +
|
|
838
|
+
`Use a snapshot ref (@e123) for the one you mean, or a more specific selector; use read_all to read all ${n}.`);
|
|
839
|
+
}
|
|
840
|
+
switch (args.what) {
|
|
841
|
+
case 'text':
|
|
842
|
+
return JSON.stringify(await loc.innerText({ timeout }));
|
|
843
|
+
case 'value':
|
|
844
|
+
return JSON.stringify(await loc.inputValue({ timeout }));
|
|
845
|
+
case 'attr':
|
|
846
|
+
return JSON.stringify(await loc.getAttribute(String(args.attr), { timeout }));
|
|
847
|
+
default:
|
|
848
|
+
throw new Error(`unknown read kind: ${args.what}`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
case 'read_all': {
|
|
852
|
+
const loc = t();
|
|
853
|
+
switch (args.what) {
|
|
854
|
+
case 'text':
|
|
855
|
+
return JSON.stringify(await loc.allInnerTexts());
|
|
856
|
+
case 'value':
|
|
857
|
+
return JSON.stringify(await loc.evaluateAll((els) => els.map((e) => e.value ?? null)));
|
|
858
|
+
case 'attr':
|
|
859
|
+
return JSON.stringify(await loc.evaluateAll((els, a) => els.map((e) => e.getAttribute(a)), String(args.attr)));
|
|
860
|
+
case 'count':
|
|
861
|
+
return String(await loc.count());
|
|
862
|
+
default:
|
|
863
|
+
throw new Error(`unknown read_all kind: ${args.what}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
case 'eval': {
|
|
867
|
+
const expression = String(args.expression ?? '');
|
|
868
|
+
const mutation = evalMutation(expression);
|
|
869
|
+
if (mutation) {
|
|
870
|
+
throw new Error(`eval is read-only: the expression ${mutation}. That would run, but could never be replayed — only the dedicated tools are recorded. Use click / fill / press / select / goto instead (locate the element first if you only know its text).`);
|
|
871
|
+
}
|
|
872
|
+
const value = await page.evaluate((expr) => {
|
|
873
|
+
// eslint-disable-next-line no-eval
|
|
874
|
+
return (0, eval)(expr);
|
|
875
|
+
}, expression);
|
|
876
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
877
|
+
}
|
|
878
|
+
case 'fetch_source': {
|
|
879
|
+
const url = new URL(args.url ? String(args.url) : page.url(), page.url()).toString();
|
|
880
|
+
const res = await page.request.fetch(url, { timeout: 15_000 });
|
|
881
|
+
const body = await res.text();
|
|
882
|
+
const header = `HTTP ${res.status()} ${res.headers()['content-type'] ?? ''} — RAW SERVER RESPONSE for ${url} (${body.length} chars, no JavaScript executed; this is NOT the live DOM)`;
|
|
883
|
+
if (args.contains) {
|
|
884
|
+
const needle = String(args.contains);
|
|
885
|
+
const hits = body.split('\n').filter((line) => line.includes(needle));
|
|
886
|
+
return `${header}\n${hits.length} line(s) contain ${JSON.stringify(needle)}${hits.length ? ':\n' + hits.join('\n') : ''}`;
|
|
887
|
+
}
|
|
888
|
+
return `${header}\n${body}`;
|
|
889
|
+
}
|
|
890
|
+
case 'goto':
|
|
891
|
+
await page.goto(String(args.url), { waitUntil: 'load', timeout: 30_000 });
|
|
892
|
+
return `at ${page.url()} — "${await page.title()}"`;
|
|
893
|
+
case 'back':
|
|
894
|
+
await page.goBack({ timeout: 15_000 });
|
|
895
|
+
return `at ${page.url()}`;
|
|
896
|
+
case 'tabs': {
|
|
897
|
+
if (typeof args.switch_to === 'number') {
|
|
898
|
+
const switched = await session.switchToPage(args.switch_to);
|
|
899
|
+
return `switched to tab ${args.switch_to}: ${switched.url()}`;
|
|
900
|
+
}
|
|
901
|
+
const pages = await session.listPages();
|
|
902
|
+
const lines = await Promise.all(pages.map(async (p, i) => `${i}${p === page ? '*' : ''}: ${await p.title().catch(() => '?')} — ${p.url()}`));
|
|
903
|
+
return lines.join('\n') || '(no tabs)';
|
|
904
|
+
}
|
|
905
|
+
case 'upload':
|
|
906
|
+
await t().setInputFiles(args.paths.map((p) => path.resolve(p)));
|
|
907
|
+
return 'files set';
|
|
908
|
+
case 'download': {
|
|
909
|
+
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
|
|
910
|
+
// If the click throws, nothing awaits this promise and its own timeout
|
|
911
|
+
// would surface 30s later as an unhandled rejection that kills the daemon.
|
|
912
|
+
downloadPromise.catch(() => { });
|
|
913
|
+
await t().click({ timeout });
|
|
914
|
+
const download = await downloadPromise;
|
|
915
|
+
const savePath = args.save_path
|
|
916
|
+
? path.resolve(String(args.save_path))
|
|
917
|
+
: path.join(screenshotDir, download.suggestedFilename() || 'download.bin');
|
|
918
|
+
await download.saveAs(savePath);
|
|
919
|
+
return `downloaded to ${savePath}`;
|
|
920
|
+
}
|
|
921
|
+
case 'set_viewport':
|
|
922
|
+
await page.setViewportSize({ width: Number(args.width), height: Number(args.height) });
|
|
923
|
+
return 'viewport set';
|
|
924
|
+
case 'set_offline':
|
|
925
|
+
await page.context().setOffline(Boolean(args.offline));
|
|
926
|
+
return args.offline ? 'offline' : 'online';
|
|
927
|
+
case 'screenshot': {
|
|
928
|
+
// Always encode as JPEG, regardless of what extension args.path uses —
|
|
929
|
+
// callers that attach these to a vision model typically assume a fixed
|
|
930
|
+
// image/jpeg media type, and Playwright infers encoding from the path
|
|
931
|
+
// extension unless `type` is given explicitly, so a model choosing its
|
|
932
|
+
// own filename (e.g. "confirmation.png") would otherwise silently write
|
|
933
|
+
// real PNG bytes under a caller-controlled name.
|
|
934
|
+
const file = args.path
|
|
935
|
+
? path.resolve(String(args.path))
|
|
936
|
+
: path.join(screenshotDir, `shot-${Date.now()}.jpg`);
|
|
937
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
938
|
+
await page.screenshot({ path: file, type: 'jpeg', fullPage: Boolean(args.full_page) });
|
|
939
|
+
return `screenshot saved: ${file}`;
|
|
940
|
+
}
|
|
941
|
+
case 'dialog_expect':
|
|
942
|
+
session.dialogs.arm({
|
|
943
|
+
action: args.action === 'accept' ? 'accept' : 'dismiss',
|
|
944
|
+
promptText: args.prompt_text ? String(args.prompt_text) : undefined,
|
|
945
|
+
remaining: typeof args.count === 'number' ? args.count : 1,
|
|
946
|
+
});
|
|
947
|
+
return `armed: will ${args.action} the next ${typeof args.count === 'number' ? args.count : 1} dialog(s)`;
|
|
948
|
+
default:
|
|
949
|
+
throw new Error(`unknown tool: ${name}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Click that recovers from a covered/marginally-actionable element (friction
|
|
954
|
+
* that otherwise sends the agent to a raw eval): normal click → scroll + force
|
|
955
|
+
* → dispatched DOM event, reporting which path worked. A strict-mode violation
|
|
956
|
+
* (selector matched many elements) is NOT swallowed — it is rethrown so the
|
|
957
|
+
* caller gets the disambiguation hint rather than silently acting on .first().
|
|
958
|
+
*/
|
|
959
|
+
/**
|
|
960
|
+
* Why an eval expression is refused: the page-mutating call it makes, or null.
|
|
961
|
+
*
|
|
962
|
+
* fwgr19 recorded its dashboard save as `btn.click()` inside an eval. The
|
|
963
|
+
* step ran fine, but eval steps do not compile into skills — only the
|
|
964
|
+
* dedicated tools carry locators — so the pinned skill had fill-title → wait
|
|
965
|
+
* for dialog hidden with no save between them, failed there on every replay,
|
|
966
|
+
* and paid 66 recovery turns per run forever. An unreplayable mutation is a
|
|
967
|
+
* hole in the recording, so it is refused at the source, naming the tool to
|
|
968
|
+
* use instead. Read expressions are untouched: a comparison (`el.value ===
|
|
969
|
+
* x`) is not an assignment, and querying, filtering and serialising are fine.
|
|
970
|
+
*/
|
|
971
|
+
export function evalMutation(expression) {
|
|
972
|
+
const patterns = [
|
|
973
|
+
[/\.(click|submit|requestSubmit)\s*\(/, 'calls .$1()'],
|
|
974
|
+
[/\.dispatchEvent\s*\(/, 'dispatches a synthetic event'],
|
|
975
|
+
[/\.(value|checked|selectedIndex)\s*=(?!=)/, 'assigns .$1'],
|
|
976
|
+
[/\.(innerHTML|outerHTML|textContent|innerText)\s*=(?!=)/, 'assigns .$1'],
|
|
977
|
+
[/\blocation\.(href|hash)\s*=(?!=)/, 'assigns location.$1'],
|
|
978
|
+
[/\blocation\.(assign|replace|reload)\s*\(/, 'navigates via location.$1()'],
|
|
979
|
+
[/\bhistory\.(pushState|replaceState|back|forward|go)\s*\(/, 'navigates via history.$1()'],
|
|
980
|
+
[/\.(remove|removeChild|appendChild|insertBefore|replaceChild|replaceWith)\s*\(/, 'edits the DOM with .$1()'],
|
|
981
|
+
[/\.(setAttribute|removeAttribute)\s*\(/, 'edits the DOM with .$1()'],
|
|
982
|
+
[/\b(localStorage|sessionStorage)\.(setItem|removeItem|clear)\s*\(/, 'writes $1'],
|
|
983
|
+
];
|
|
984
|
+
for (const [re, why] of patterns) {
|
|
985
|
+
const m = re.exec(expression);
|
|
986
|
+
if (m)
|
|
987
|
+
return why.replace('$1', m[1] ?? '');
|
|
988
|
+
}
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The tiers a click falls through, in order. Each is tried when the one
|
|
993
|
+
* before it failed; the window tier (fireWhenAttached) comes last and its own
|
|
994
|
+
* error is what the agent sees, because it is the only tier that can explain
|
|
995
|
+
* WHY nothing landed.
|
|
996
|
+
*/
|
|
997
|
+
const CLICK_TIERS = [
|
|
998
|
+
// Playwright's own click, actionability checks and all.
|
|
999
|
+
{ note: '', run: (_loc, opts, act) => act({ timeout: opts.timeout }) },
|
|
1000
|
+
// Scroll into view and skip the checks: a control under a sticky header,
|
|
1001
|
+
// or one an overlay covers in a way the app treats as fine.
|
|
1002
|
+
{
|
|
1003
|
+
note: ' (forced past actionability checks)',
|
|
1004
|
+
run: async (loc, opts, act) => {
|
|
1005
|
+
await loc.scrollIntoViewIfNeeded({ timeout: opts.timeout }).catch(() => { });
|
|
1006
|
+
await act({ timeout: opts.timeout, force: true });
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
// A synthetic event straight at the element: React's delegated handlers see
|
|
1010
|
+
// it even when the element is not "clickable" by Playwright's rules.
|
|
1011
|
+
{ note: ' (dispatched DOM event — element was not normally clickable)', run: (loc, opts) => loc.first().evaluate(fireClick, Boolean(opts.dbl)) },
|
|
1012
|
+
];
|
|
1013
|
+
async function robustClick(loc, opts) {
|
|
1014
|
+
const label = opts.dbl ? 'double-clicked' : 'clicked';
|
|
1015
|
+
const act = (o) => (opts.dbl ? loc.dblclick(o) : loc.click(o));
|
|
1016
|
+
let firstFailure = '';
|
|
1017
|
+
for (const tier of CLICK_TIERS) {
|
|
1018
|
+
try {
|
|
1019
|
+
await tier.run(loc, opts, act);
|
|
1020
|
+
return `${label}${tier.note}`;
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
if (firstFailure)
|
|
1024
|
+
continue;
|
|
1025
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
1026
|
+
// Two or more matches is the agent's problem to fix, not a tier's.
|
|
1027
|
+
if (/strict mode violation/i.test(firstFailure))
|
|
1028
|
+
throw err;
|
|
1029
|
+
// A control the app re-mounts on every render never passes the
|
|
1030
|
+
// attached→visible→stable check, and a forced click needs it attached
|
|
1031
|
+
// at the instant of the action just the same — so both tiers lose the
|
|
1032
|
+
// race and burn their full timeout (rpgr4-r2 spent 74 turns on
|
|
1033
|
+
// grafana's viz-picker toggle this way). Only Playwright's own detach
|
|
1034
|
+
// evidence sends a click straight to the window tier: its generic
|
|
1035
|
+
// timeout log reads "waiting for element to be visible, enabled and
|
|
1036
|
+
// stable" for EVERY stalled click, and matching on that routed 15
|
|
1037
|
+
// ordinary replay clicks per run past the tiers that had been landing
|
|
1038
|
+
// them (rpgr5).
|
|
1039
|
+
if (DETACHED.test(firstFailure))
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return fireWhenAttached(loc, opts, label, firstFailure);
|
|
1044
|
+
}
|
|
1045
|
+
/** Playwright's own words for an element that left the DOM mid-action — never its generic actionability wording. */
|
|
1046
|
+
const DETACHED = /element was detached|not attached to the DOM|element is not attached|element is not stable/i;
|
|
1047
|
+
/** Runs in the page: a synthetic click (React's delegated handlers see it). */
|
|
1048
|
+
function fireClick(el, dbl) {
|
|
1049
|
+
const fire = (type) => el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
|
|
1050
|
+
fire('click');
|
|
1051
|
+
if (dbl) {
|
|
1052
|
+
fire('click');
|
|
1053
|
+
fire('dblclick');
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Click a control that keeps re-mounting. Polls for the element and, the
|
|
1058
|
+
* moment a handle resolves, dispatches the click in the same tick — no
|
|
1059
|
+
* actionability wait at all. A flickering element is attached for a good
|
|
1060
|
+
* fraction of every cycle; the normal tiers never act inside that window.
|
|
1061
|
+
* If no window is found within the budget, the error says what the agent is
|
|
1062
|
+
* fighting and what to try instead of the same click again.
|
|
1063
|
+
*/
|
|
1064
|
+
export async function fireWhenAttached(loc, opts, label = 'clicked', because = '') {
|
|
1065
|
+
// The first line of the failure that sent us here rides along in the
|
|
1066
|
+
// result, so a post-mortem can see WHY a click took this route.
|
|
1067
|
+
const cause = because ? ` after: ${because.split('\n')[0].slice(0, 120)}` : '';
|
|
1068
|
+
const deadline = Date.now() + opts.timeout;
|
|
1069
|
+
let polls = 0;
|
|
1070
|
+
let attached = 0;
|
|
1071
|
+
while (Date.now() < deadline) {
|
|
1072
|
+
polls++;
|
|
1073
|
+
const handle = await loc.first().elementHandle({ timeout: 100 }).catch(() => null);
|
|
1074
|
+
if (handle) {
|
|
1075
|
+
attached++;
|
|
1076
|
+
try {
|
|
1077
|
+
await handle.evaluate(fireClick, Boolean(opts.dbl));
|
|
1078
|
+
return `${label} (dispatched during a re-render window${cause} — the element re-mounts continuously, so a normal click could not land; if the app did not respond, it may need a keyboard route or a wait_for on the state that settles it)`;
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
// gone again between resolve and fire — next window
|
|
1082
|
+
}
|
|
1083
|
+
finally {
|
|
1084
|
+
await handle.dispose().catch(() => { });
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1088
|
+
}
|
|
1089
|
+
throw new Error(attached
|
|
1090
|
+
? `target re-rendered continuously: attached on ${attached} of ${polls} polls but never long enough to click. The app re-mounts it on every render. Do not repeat this click — wait_for an element that appears once the state settles, or drive it by keyboard (focus a stable neighbour, Tab to it, press Enter).`
|
|
1091
|
+
: `target was never attached during ${Math.round(opts.timeout / 1000)}s of polling after an initial detach — it was removed by a re-render. Re-snapshot and locate it afresh rather than repeating this click.`);
|
|
1092
|
+
}
|
|
1093
|
+
async function waitFor(page, args, signal) {
|
|
1094
|
+
const loc = resolveTarget(page, String(args.target));
|
|
1095
|
+
const timeout = typeof args.timeout_ms === 'number' ? args.timeout_ms : 10_000;
|
|
1096
|
+
const state = String(args.state);
|
|
1097
|
+
if (state === 'visible' || state === 'hidden') {
|
|
1098
|
+
await loc.first().waitFor({ state, timeout });
|
|
1099
|
+
return `condition met: ${state}`;
|
|
1100
|
+
}
|
|
1101
|
+
const cancelled = () => {
|
|
1102
|
+
throw new Error('wait_for cancelled: instruction budget exhausted');
|
|
1103
|
+
};
|
|
1104
|
+
const deadline = Date.now() + timeout;
|
|
1105
|
+
let last = '';
|
|
1106
|
+
let firstObserved = null;
|
|
1107
|
+
while (Date.now() < deadline) {
|
|
1108
|
+
if (signal?.aborted)
|
|
1109
|
+
cancelled();
|
|
1110
|
+
if (state === 'count') {
|
|
1111
|
+
const count = await loc.count();
|
|
1112
|
+
last = `count=${count}`;
|
|
1113
|
+
if (count === Number(args.count))
|
|
1114
|
+
return `condition met: ${last}`;
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
const text = (await loc.first().innerText({ timeout: 1000 }).catch(() => null)) ?? '';
|
|
1118
|
+
last = `text=${JSON.stringify(text.slice(0, 200))}`;
|
|
1119
|
+
if (state === 'text_equals' && text.trim() === String(args.text).trim())
|
|
1120
|
+
return `condition met: ${last}`;
|
|
1121
|
+
if (state === 'text_contains' && text.includes(String(args.text)))
|
|
1122
|
+
return `condition met: ${last}`;
|
|
1123
|
+
}
|
|
1124
|
+
if (firstObserved === null)
|
|
1125
|
+
firstObserved = last;
|
|
1126
|
+
// Wake early on cancellation so an abandoned wait stops polling the page
|
|
1127
|
+
// instead of ticking on in the background for the rest of its own timeout.
|
|
1128
|
+
await new Promise((resolve) => {
|
|
1129
|
+
const timer = setTimeout(done, 250);
|
|
1130
|
+
function done() {
|
|
1131
|
+
clearTimeout(timer);
|
|
1132
|
+
signal?.removeEventListener('abort', done);
|
|
1133
|
+
resolve();
|
|
1134
|
+
}
|
|
1135
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
if (signal?.aborted)
|
|
1139
|
+
cancelled();
|
|
1140
|
+
// If the observed value never budged, the condition is likely unsatisfiable
|
|
1141
|
+
// (e.g. a count wait against a virtualised list) rather than merely slow.
|
|
1142
|
+
const stableHint = firstObserved !== null && firstObserved === last
|
|
1143
|
+
? ` — value never changed from ${last}, so this condition may be unsatisfiable (e.g. count against a virtualised list renders only visible rows); assert on a stable indicator instead`
|
|
1144
|
+
: '';
|
|
1145
|
+
throw new Error(`wait_for ${state} timed out after ${timeout}ms (last: ${last})${stableHint}`);
|
|
1146
|
+
}
|
|
1147
|
+
//# sourceMappingURL=tools.js.map
|