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,1232 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureSessionDir } from '../shared/paths.js';
|
|
4
|
+
import { volatileMatcher } from '../shared/text.js';
|
|
5
|
+
import { isRefTarget, refHint, resolveTarget } from './refs.js';
|
|
6
|
+
import { tagComponent } from '../skills/components.js';
|
|
7
|
+
/** Rebuild a candidate into a live Locator. Shared by recording and replay. */
|
|
8
|
+
export function makeLocator(page, c) {
|
|
9
|
+
let loc;
|
|
10
|
+
switch (c.kind) {
|
|
11
|
+
case 'testid':
|
|
12
|
+
loc = c.attr === 'data-testid' ? page.getByTestId(c.value) : page.locator(`[${c.attr}=${JSON.stringify(c.value)}]`);
|
|
13
|
+
break;
|
|
14
|
+
case 'role':
|
|
15
|
+
// exact: Playwright's default name match is a case-insensitive substring,
|
|
16
|
+
// so a recorded 'Edit' also matches a sibling 'Exit edit' — which is how
|
|
17
|
+
// rpgr2-r2 left edit mode instead of entering it and halted the flow.
|
|
18
|
+
// A name carrying a clock or calendar token ("Due date: 12/31/2026
|
|
19
|
+
// 07:40") is matched with that token wildcarded — see volatileMatcher —
|
|
20
|
+
// so a recording's minute does not push the step onto a positional path.
|
|
21
|
+
loc = page.getByRole(c.role, { name: volatileMatcher(c.name), exact: true });
|
|
22
|
+
break;
|
|
23
|
+
case 'label':
|
|
24
|
+
loc = page.getByLabel(volatileMatcher(c.label));
|
|
25
|
+
break;
|
|
26
|
+
case 'placeholder':
|
|
27
|
+
loc = page.getByPlaceholder(volatileMatcher(c.placeholder));
|
|
28
|
+
break;
|
|
29
|
+
case 'text':
|
|
30
|
+
loc = page.getByText(volatileMatcher(c.text), { exact: true });
|
|
31
|
+
break;
|
|
32
|
+
case 'id':
|
|
33
|
+
case 'css':
|
|
34
|
+
loc = page.locator(c.selector);
|
|
35
|
+
break;
|
|
36
|
+
case 'scoped': {
|
|
37
|
+
const within = page.locator(c.container, { hasText: c.hasText });
|
|
38
|
+
loc = c.selector ? within.locator(c.selector) : within;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case 'point':
|
|
42
|
+
// Resolved in two moves: markPoint() finds the element under the
|
|
43
|
+
// recorded point and tags it; this locator then names the tag.
|
|
44
|
+
loc = page.locator(`[${POINT_MARK}=${JSON.stringify(pointToken(c))}]`);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return c.nth !== undefined ? loc.nth(c.nth) : loc;
|
|
48
|
+
}
|
|
49
|
+
/** The attribute markPoint leaves on the element it found, so a sync Locator can name it. */
|
|
50
|
+
export const POINT_MARK = 'data-sitelooper-point';
|
|
51
|
+
export function pointToken(c) {
|
|
52
|
+
return `${c.x},${c.y}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Find the element under a recorded point, walk up to its actionable
|
|
56
|
+
* ancestor, and tag it for makeLocator — but only when it is the KIND of
|
|
57
|
+
* thing recorded (same role, or same tag when the recording had no role).
|
|
58
|
+
* Returns what it found, or null when nothing of that kind is there. Scrolls
|
|
59
|
+
* the window so the point is on screen first; a point in an inner scroller
|
|
60
|
+
* is found only when that scroller sits where it was recorded.
|
|
61
|
+
*/
|
|
62
|
+
export async function markPoint(page, c) {
|
|
63
|
+
try {
|
|
64
|
+
return await page.evaluate(({ x, y, role, tag, mark, token }) => {
|
|
65
|
+
const ACTIONABLE = 'button,a[href],input,select,textarea,summary,[role],[tabindex],label';
|
|
66
|
+
const targetY = y - window.innerHeight / 2;
|
|
67
|
+
if (Math.abs(window.scrollY - targetY) > window.innerHeight / 2 || x - window.scrollX > window.innerWidth) {
|
|
68
|
+
window.scrollTo(Math.max(0, x - window.innerWidth / 2), Math.max(0, targetY));
|
|
69
|
+
}
|
|
70
|
+
const hit = document.elementFromPoint(x - window.scrollX, y - window.scrollY);
|
|
71
|
+
if (!hit)
|
|
72
|
+
return null;
|
|
73
|
+
const kindOf = (el) => {
|
|
74
|
+
const tagOf = el.tagName.toLowerCase();
|
|
75
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
76
|
+
const implicit = () => {
|
|
77
|
+
if (tagOf === 'button')
|
|
78
|
+
return 'button';
|
|
79
|
+
if (tagOf === 'a')
|
|
80
|
+
return el.hasAttribute('href') ? 'link' : null;
|
|
81
|
+
if (tagOf === 'select')
|
|
82
|
+
return el.hasAttribute('multiple') ? 'listbox' : 'combobox';
|
|
83
|
+
if (tagOf === 'textarea')
|
|
84
|
+
return 'textbox';
|
|
85
|
+
if (tagOf === 'img')
|
|
86
|
+
return 'img';
|
|
87
|
+
if (/^h[1-6]$/.test(tagOf))
|
|
88
|
+
return 'heading';
|
|
89
|
+
if (tagOf === 'input') {
|
|
90
|
+
if (type === 'checkbox')
|
|
91
|
+
return 'checkbox';
|
|
92
|
+
if (type === 'radio')
|
|
93
|
+
return 'radio';
|
|
94
|
+
if (type === 'submit' || type === 'button' || type === 'reset')
|
|
95
|
+
return 'button';
|
|
96
|
+
if (type === 'search')
|
|
97
|
+
return 'searchbox';
|
|
98
|
+
if (type === 'number')
|
|
99
|
+
return 'spinbutton';
|
|
100
|
+
if (['text', 'email', 'tel', 'url', 'password', ''].includes(type))
|
|
101
|
+
return 'textbox';
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
return { role: el.getAttribute('role') || implicit(), tag: tagOf };
|
|
107
|
+
};
|
|
108
|
+
// The point lands on whatever is painted there — a heading's text
|
|
109
|
+
// span, a button's icon. Walk up a few ancestors for the recorded
|
|
110
|
+
// KIND (fwgr27: every heading point missed because the hit was the
|
|
111
|
+
// title's inner span); failing that, the nearest actionable ancestor.
|
|
112
|
+
let el = null;
|
|
113
|
+
for (let cur = hit, hops = 0; cur && hops < 6; cur = cur.parentElement, hops++) {
|
|
114
|
+
const k = kindOf(cur);
|
|
115
|
+
if (role ? k.role === role : k.tag === tag) {
|
|
116
|
+
el = cur;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
el ??= hit.closest(ACTIONABLE) ?? hit;
|
|
121
|
+
const { role: roleOf, tag: tagOf } = kindOf(el);
|
|
122
|
+
const same = role ? roleOf === role : tagOf === tag;
|
|
123
|
+
if (!same)
|
|
124
|
+
return null;
|
|
125
|
+
for (const old of Array.from(document.querySelectorAll(`[${mark}]`)))
|
|
126
|
+
old.removeAttribute(mark);
|
|
127
|
+
el.setAttribute(mark, token);
|
|
128
|
+
return { role: roleOf, tag: tagOf };
|
|
129
|
+
}, { x: c.x, y: c.y, role: c.role, tag: c.tag, mark: POINT_MARK, token: pointToken(c) });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Source text for a candidate, e.g. `page.getByRole('button', { name: 'Save' })`. */
|
|
136
|
+
export function candidateExpr(c) {
|
|
137
|
+
let expr;
|
|
138
|
+
switch (c.kind) {
|
|
139
|
+
case 'testid':
|
|
140
|
+
expr = c.attr === 'data-testid' ? `page.getByTestId(${q(c.value)})` : `page.locator(${q(`[${c.attr}=${JSON.stringify(c.value)}]`)})`;
|
|
141
|
+
break;
|
|
142
|
+
case 'role':
|
|
143
|
+
expr = `page.getByRole(${q(c.role)}, { name: ${q(c.name)}, exact: true })`;
|
|
144
|
+
break;
|
|
145
|
+
case 'label':
|
|
146
|
+
expr = `page.getByLabel(${q(c.label)})`;
|
|
147
|
+
break;
|
|
148
|
+
case 'placeholder':
|
|
149
|
+
expr = `page.getByPlaceholder(${q(c.placeholder)})`;
|
|
150
|
+
break;
|
|
151
|
+
case 'text':
|
|
152
|
+
expr = `page.getByText(${q(c.text)}, { exact: true })`;
|
|
153
|
+
break;
|
|
154
|
+
case 'id':
|
|
155
|
+
case 'css':
|
|
156
|
+
expr = `page.locator(${q(c.selector)})`;
|
|
157
|
+
break;
|
|
158
|
+
case 'scoped':
|
|
159
|
+
expr = `page.locator(${q(c.container)}, { hasText: ${q(c.hasText)} })` + (c.selector ? `.locator(${q(c.selector)})` : '');
|
|
160
|
+
break;
|
|
161
|
+
case 'point':
|
|
162
|
+
expr = `elementAt(${c.x}, ${c.y}) /* ${c.role ?? c.tag} */`;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
return c.nth !== undefined ? `${expr}.nth(${c.nth})` : expr;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* candidateExpr's judgement at the EXPRESSION level, for consumers that only
|
|
169
|
+
* have the string (drift tickets, verify-artifacts, repair triage): does this
|
|
170
|
+
* locator find its element by where it sits rather than by what it is? One
|
|
171
|
+
* function, because replay, repair triage and the artifact gate disagreeing
|
|
172
|
+
* on what "positional" means is how a repair promotes what the gate flags.
|
|
173
|
+
*
|
|
174
|
+
* An identity-scoped expression is NOT positional even when it ends in a
|
|
175
|
+
* positional cell selector: `locator('#rows tr', { hasText: 'x7' })
|
|
176
|
+
* .locator('td:nth-of-type(2)')` names the record first.
|
|
177
|
+
*/
|
|
178
|
+
export function positionalExpr(expr) {
|
|
179
|
+
if (/hasText:/.test(expr))
|
|
180
|
+
return false;
|
|
181
|
+
return /^elementAt\(/.test(expr) || /nth-of-type|nth-child|>>\s*nth=|\.nth\(/.test(expr) || (expr.match(/>/g) ?? []).length > 2;
|
|
182
|
+
}
|
|
183
|
+
/** Click tools: their target may be a table row whose durable locator is the record link inside it. */
|
|
184
|
+
const CLICK_TOOLS = new Set(['click', 'dblclick', 'modifier_click', 'right_click']);
|
|
185
|
+
/** Tools whose target is worth tagging with its component family (recipe compilation). */
|
|
186
|
+
const COMPONENT_TOOLS = new Set(['click', 'dblclick', 'fill', 'type', 'press']);
|
|
187
|
+
/** Tools that map onto Playwright script lines; everything else is agent-only scaffolding. */
|
|
188
|
+
/** Args whose typed value identifies a record (see addIdentityHint). */
|
|
189
|
+
const VALUE_ARG_KEYS = ['value', 'text', 'option'];
|
|
190
|
+
const RECORDABLE = new Set([
|
|
191
|
+
'click', 'dblclick', 'right_click', 'modifier_click', 'fill', 'type', 'press', 'select',
|
|
192
|
+
'check', 'hover', 'scroll_into_view', 'drag', 'wait_for', 'read', 'read_all', 'eval',
|
|
193
|
+
'goto', 'back', 'upload', 'download', 'set_viewport', 'set_offline', 'screenshot',
|
|
194
|
+
'dialog_expect', 'tabs',
|
|
195
|
+
]);
|
|
196
|
+
/** Tools whose observed result is turned into a (commented) assertion. */
|
|
197
|
+
const RESULT_TOOLS = new Set(['read', 'read_all']);
|
|
198
|
+
export function isRecordable(tool) {
|
|
199
|
+
return RECORDABLE.has(tool);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Captures the actions an instruction takes as replayable Playwright steps.
|
|
203
|
+
*
|
|
204
|
+
* The agent drives the page through `@ref` handles, which are snapshot-scoped
|
|
205
|
+
* and meaningless in a standalone test, so every target is re-described against
|
|
206
|
+
* the live DOM *before* the action runs (afterwards the element may be gone),
|
|
207
|
+
* and the resulting expression is replayed to confirm it still resolves to that
|
|
208
|
+
* exact element. Entries are appended to `script.jsonl` in the session dir as
|
|
209
|
+
* they happen, so a recording survives a daemon restart or a hard kill.
|
|
210
|
+
*/
|
|
211
|
+
export class ScriptRecorder {
|
|
212
|
+
session;
|
|
213
|
+
entries = [];
|
|
214
|
+
/**
|
|
215
|
+
* How many entries were already on disk when this daemon started — a
|
|
216
|
+
* PREVIOUS take under the same session name. They are kept (a daemon that
|
|
217
|
+
* crashed mid-instruction should not lose the run's history) but they are
|
|
218
|
+
* not part of this take: fwrd16's container restarted, the runner cleared
|
|
219
|
+
* bench/results and re-recorded, and because the session dir survived, the
|
|
220
|
+
* exported flow contained the task TWICE — nine steps from the killed take
|
|
221
|
+
* followed by eight from the re-run. Both replays dutifully did the whole
|
|
222
|
+
* lifecycle twice and the verifier still scored them 6/6.
|
|
223
|
+
*/
|
|
224
|
+
priorCount = 0;
|
|
225
|
+
get priorEntries() {
|
|
226
|
+
return this.priorCount;
|
|
227
|
+
}
|
|
228
|
+
constructor(session) {
|
|
229
|
+
this.session = session;
|
|
230
|
+
this.load();
|
|
231
|
+
this.priorCount = this.entries.length;
|
|
232
|
+
}
|
|
233
|
+
/** Entries recorded by THIS take — what a flow export may build from. */
|
|
234
|
+
entriesThisTake() {
|
|
235
|
+
return this.entries.slice(this.priorEntries);
|
|
236
|
+
}
|
|
237
|
+
file() {
|
|
238
|
+
return path.join(ensureSessionDir(this.session), 'script.jsonl');
|
|
239
|
+
}
|
|
240
|
+
load() {
|
|
241
|
+
let raw;
|
|
242
|
+
try {
|
|
243
|
+
raw = fs.readFileSync(this.file(), 'utf8');
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return; // nothing recorded yet for this session
|
|
247
|
+
}
|
|
248
|
+
let torn = !raw.endsWith('\n');
|
|
249
|
+
for (const line of raw.split('\n')) {
|
|
250
|
+
if (!line.trim())
|
|
251
|
+
continue;
|
|
252
|
+
try {
|
|
253
|
+
this.entries.push(JSON.parse(line));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
torn = true; // a partially written last line after a kill — drop it, keep the rest
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Make the file canonical before the first append: appending after a
|
|
260
|
+
// torn last line glued the next entry onto the fragment, and the NEXT
|
|
261
|
+
// load lost that entry too.
|
|
262
|
+
if (torn)
|
|
263
|
+
this.rewrite();
|
|
264
|
+
}
|
|
265
|
+
append(entry) {
|
|
266
|
+
this.entries.push(entry);
|
|
267
|
+
try {
|
|
268
|
+
fs.appendFileSync(this.file(), JSON.stringify(entry) + '\n');
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// recording must never break the run it is observing
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Mark the start of one `do` instruction; becomes a test.step in the script. */
|
|
275
|
+
beginInstruction(text, context = {}) {
|
|
276
|
+
this.append({ k: 'instruction', text, ...context });
|
|
277
|
+
}
|
|
278
|
+
/** Close the current instruction with its outcome (learning mode; flows are built from these). */
|
|
279
|
+
endInstruction(report) {
|
|
280
|
+
this.append({ k: 'report', ...report, ...(this.pendingAsk ? { namingAsk: this.pendingAsk } : {}) });
|
|
281
|
+
this.pendingAsk = undefined;
|
|
282
|
+
}
|
|
283
|
+
/** Values the loop is holding this instruction's report to name — see RecordedReport.namingAsk. */
|
|
284
|
+
pendingAsk;
|
|
285
|
+
/** Record that the loop asked for names; call again with the outcome once the retry lands. */
|
|
286
|
+
noteNamingAsk(asked) {
|
|
287
|
+
this.pendingAsk = { asked, named: false };
|
|
288
|
+
}
|
|
289
|
+
/** Mark the held report as having come back with names. */
|
|
290
|
+
noteNamingAnswered() {
|
|
291
|
+
if (this.pendingAsk)
|
|
292
|
+
this.pendingAsk.named = true;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Pin the skill this instruction produced onto its report entry, after
|
|
296
|
+
* compilation (which happens once the report is already recorded). Rewrites
|
|
297
|
+
* the last report entry in memory and in script.jsonl so a flow exported
|
|
298
|
+
* later has the skill to replay.
|
|
299
|
+
*/
|
|
300
|
+
pinSkill(skill) {
|
|
301
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
302
|
+
const e = this.entries[i];
|
|
303
|
+
if (e.k === 'report') {
|
|
304
|
+
if (!e.skill)
|
|
305
|
+
e.skill = skill;
|
|
306
|
+
this.rewrite();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (e.k === 'instruction')
|
|
310
|
+
return; // no report for this instruction
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Rewrite script.jsonl after in-place entry edits (post-session relabel). */
|
|
314
|
+
persist() {
|
|
315
|
+
this.rewrite();
|
|
316
|
+
}
|
|
317
|
+
rewrite() {
|
|
318
|
+
try {
|
|
319
|
+
fs.writeFileSync(this.file(), this.entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// recording must never break the run it observes
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Append a synthetic step (a read-back captured at report time). */
|
|
326
|
+
addStep(step) {
|
|
327
|
+
this.append(step);
|
|
328
|
+
}
|
|
329
|
+
/** Values already read via a read step since the last instruction began. */
|
|
330
|
+
readResultsThisInstruction() {
|
|
331
|
+
const out = new Set();
|
|
332
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
333
|
+
const e = this.entries[i];
|
|
334
|
+
if (e.k === 'instruction')
|
|
335
|
+
break;
|
|
336
|
+
if (e.k === 'step' && (e.tool === 'read' || e.tool === 'read_all') && typeof e.result === 'string') {
|
|
337
|
+
try {
|
|
338
|
+
const v = JSON.parse(e.result);
|
|
339
|
+
if (typeof v === 'string')
|
|
340
|
+
out.add(v);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
out.add(e.result);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The current instruction's real reads with their parsed values — target
|
|
351
|
+
* label included, read_all arrays expanded — for report-time promotion of
|
|
352
|
+
* prose-cited values into evidence.values. Synthetic read-backs excluded.
|
|
353
|
+
*/
|
|
354
|
+
readsThisInstruction() {
|
|
355
|
+
const out = [];
|
|
356
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
357
|
+
const e = this.entries[i];
|
|
358
|
+
if (e.k === 'instruction')
|
|
359
|
+
break;
|
|
360
|
+
if (e.k !== 'step' || (e.tool !== 'read' && e.tool !== 'read_all') || typeof e.result !== 'string')
|
|
361
|
+
continue;
|
|
362
|
+
if (e.args.target === '(read-back)')
|
|
363
|
+
continue;
|
|
364
|
+
let parsed;
|
|
365
|
+
try {
|
|
366
|
+
parsed = JSON.parse(e.result);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
parsed = e.result;
|
|
370
|
+
}
|
|
371
|
+
const values = (Array.isArray(parsed) ? parsed : [parsed]).filter((v) => typeof v === 'string');
|
|
372
|
+
const label = typeof e.args.label === 'string' && e.args.label.trim() ? e.args.label.trim() : undefined;
|
|
373
|
+
if (values.length)
|
|
374
|
+
out.unshift({ target: String(e.args.target ?? ''), values, ...(label ? { label } : {}) });
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
/** Index just past the last entry — pass to entriesSince() to read back one instruction. */
|
|
379
|
+
mark() {
|
|
380
|
+
return this.entries.length;
|
|
381
|
+
}
|
|
382
|
+
entriesSince(mark) {
|
|
383
|
+
return this.entries.slice(mark);
|
|
384
|
+
}
|
|
385
|
+
clear() {
|
|
386
|
+
this.entries.length = 0;
|
|
387
|
+
this.priorCount = 0; // a cleared recording has no previous take to skip
|
|
388
|
+
try {
|
|
389
|
+
fs.rmSync(this.file(), { force: true });
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// best effort
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Describe a step's targets against the live page. Called BEFORE the action,
|
|
397
|
+
* because a click can navigate or unmount the very element being described.
|
|
398
|
+
* Returns null for tools that do not map onto a script line.
|
|
399
|
+
*/
|
|
400
|
+
async prepare(page, tool, args,
|
|
401
|
+
/** Pre-resolved locators (replay): described from the element itself, not from args. */
|
|
402
|
+
resolved) {
|
|
403
|
+
if (!RECORDABLE.has(tool))
|
|
404
|
+
return null;
|
|
405
|
+
// What the agent types names what it creates: the ticket title typed here
|
|
406
|
+
// is how every later read in this instruction can be anchored to the row
|
|
407
|
+
// it belongs to rather than to a row number.
|
|
408
|
+
for (const key of VALUE_ARG_KEYS) {
|
|
409
|
+
const v = args[key];
|
|
410
|
+
if (typeof v === 'string')
|
|
411
|
+
addIdentityHint(v);
|
|
412
|
+
}
|
|
413
|
+
const locators = {};
|
|
414
|
+
for (const key of ['target', 'source']) {
|
|
415
|
+
const raw = args[key];
|
|
416
|
+
const retarget = key === 'target' && CLICK_TOOLS.has(tool);
|
|
417
|
+
if (resolved?.[key]) {
|
|
418
|
+
const rawText = typeof raw === 'string' ? raw : '';
|
|
419
|
+
locators[key] = await describeLocator(page, resolved[key], rawText, retarget).catch(() => ({
|
|
420
|
+
expr: '',
|
|
421
|
+
verified: false,
|
|
422
|
+
raw: rawText,
|
|
423
|
+
}));
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
427
|
+
continue;
|
|
428
|
+
locators[key] = await describeTarget(page, raw, retarget).catch(() => ({ expr: '', verified: false, raw }));
|
|
429
|
+
}
|
|
430
|
+
// Component tagging (PLAN-component-recipes): note which recognized
|
|
431
|
+
// widget family the target sits inside, so a successful agent-driven
|
|
432
|
+
// interaction with a hard component can later compile into a recipe.
|
|
433
|
+
// Best effort like everything else here — a missing tag just means no
|
|
434
|
+
// recipe is learned from this step.
|
|
435
|
+
let component;
|
|
436
|
+
if (COMPONENT_TOOLS.has(tool) && typeof args.target === 'string' && args.target.trim()) {
|
|
437
|
+
const target = resolved?.target ?? resolveTarget(page, args.target);
|
|
438
|
+
component = (await tagComponent(target).catch(() => null)) ?? undefined;
|
|
439
|
+
}
|
|
440
|
+
return { k: 'step', tool, args, locators, ...(component ? { component } : {}) };
|
|
441
|
+
}
|
|
442
|
+
/** Commit a prepared step once the action succeeded. Failed actions are dropped. */
|
|
443
|
+
commit(step, result, extra = {}) {
|
|
444
|
+
if (!step)
|
|
445
|
+
return;
|
|
446
|
+
// A select is recorded by the option's visible LABEL whatever the caller
|
|
447
|
+
// passed: the label is the term the procedure has provenance for (it is
|
|
448
|
+
// what the instruction names, what an earlier step minted), while the
|
|
449
|
+
// value is the app's key for that option and carries none. The value the
|
|
450
|
+
// step actually selected is kept as the fallback `optionValue`.
|
|
451
|
+
let args = step.args;
|
|
452
|
+
const label = step.tool === 'select' ? /\blabel=("(?:[^"\\]|\\.)*")$/.exec(result)?.[1] : undefined;
|
|
453
|
+
if (label) {
|
|
454
|
+
const shown = JSON.parse(label);
|
|
455
|
+
if (shown && shown !== args.option)
|
|
456
|
+
args = { ...args, option: shown, optionValue: String(args.option ?? '') };
|
|
457
|
+
}
|
|
458
|
+
const entry = {
|
|
459
|
+
...step,
|
|
460
|
+
args,
|
|
461
|
+
...(extra.diff ? { diff: extra.diff } : {}),
|
|
462
|
+
...(extra.via ? { via: extra.via } : {}),
|
|
463
|
+
...(extra.fingerprintAfter ? { fingerprintAfter: extra.fingerprintAfter } : {}),
|
|
464
|
+
};
|
|
465
|
+
this.append(RESULT_TOOLS.has(step.tool) ? { ...entry, result } : entry);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Turn one agent-supplied target into a durable locator expression. Raw CSS
|
|
470
|
+
* selectors pass through as-is (the agent already chose something stable);
|
|
471
|
+
* `@ref` handles are re-derived from the element's own attributes, preferring
|
|
472
|
+
* test ids and roles over structural paths.
|
|
473
|
+
*/
|
|
474
|
+
/**
|
|
475
|
+
* How to record a raw target the agent typed. Almost everything is an opaque
|
|
476
|
+
* selector string and stays `css` — but `text="..."` is Playwright's TEXT
|
|
477
|
+
* engine, not CSS, and typing it as css cost us the whole identity guard:
|
|
478
|
+
* identityOfPrimary reads name/text/label/hasText and deliberately skips css
|
|
479
|
+
* ("a slot inside a selector is an address, not a name"), so a primary that
|
|
480
|
+
* named the record by its title advertised NO identity, and every fallback —
|
|
481
|
+
* including `tr:nth-of-type(1)` — was waved through unchecked.
|
|
482
|
+
*
|
|
483
|
+
* fwrd19l 01-open and 02-open, on every replay: the row was not painted yet
|
|
484
|
+
* (repair-desk defers its list refetch BY DESIGN), all three text-bearing
|
|
485
|
+
* candidates missed, and the positional one resolved instantly against
|
|
486
|
+
* whatever sat in row 1. It passed only because a new ticket sorts to the top.
|
|
487
|
+
*
|
|
488
|
+
* Typing it correctly re-arms the guard, which rejects the positional
|
|
489
|
+
* fallback, which makes the walk return nothing — which is what lets
|
|
490
|
+
* resolveChain's wait run at all, so the anchor wins once the row lands.
|
|
491
|
+
*
|
|
492
|
+
* Only the quoted form maps cleanly: `text="X"` is exact and whitespace
|
|
493
|
+
* -trimmed, which is what getByText(X, { exact: true }) does. Unquoted
|
|
494
|
+
* (substring, case-insensitive) and regex forms have no equivalent, so they
|
|
495
|
+
* stay css rather than being silently narrowed.
|
|
496
|
+
*/
|
|
497
|
+
export function primaryFor(raw) {
|
|
498
|
+
const m = /^text=(?:"([^"]*)"|'([^']*)')$/.exec(raw.trim());
|
|
499
|
+
const text = m ? (m[1] ?? m[2]) : undefined;
|
|
500
|
+
return text ? { kind: 'text', text } : { kind: 'css', selector: raw };
|
|
501
|
+
}
|
|
502
|
+
export async function describeTarget(page, raw, retarget = false) {
|
|
503
|
+
if (!isRefTarget(raw)) {
|
|
504
|
+
// A raw selector the agent chose: keep it as the primary, but still
|
|
505
|
+
// describe the element it hit so replay has attribute-based fallbacks.
|
|
506
|
+
const loc = page.locator(raw);
|
|
507
|
+
const count = await loc.count().catch(() => 0);
|
|
508
|
+
const primary = primaryFor(raw);
|
|
509
|
+
if (count !== 1)
|
|
510
|
+
return { expr: candidateExpr(primary), verified: false, raw, chain: [primary] };
|
|
511
|
+
const handle = await loc.elementHandle({ timeout: 2_000 }).catch(() => null);
|
|
512
|
+
if (!handle)
|
|
513
|
+
return { expr: candidateExpr(primary), verified: true, raw, chain: [primary] };
|
|
514
|
+
try {
|
|
515
|
+
const info = (await handle.evaluate(describeInPage));
|
|
516
|
+
// Dedupe: a `text="X"` primary is now the same candidate the described
|
|
517
|
+
// element yields, and carrying it twice only shortens the useful chain.
|
|
518
|
+
const rest = (await verifiedChain(page, info, handle)).chain.filter((c) => candidateExpr(c) !== candidateExpr(primary));
|
|
519
|
+
return { expr: candidateExpr(primary), verified: true, raw, chain: [primary, ...rest] };
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
await handle.dispose().catch(() => { });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const ref = raw.trim().replace(/^@/, '');
|
|
526
|
+
const handle = await page
|
|
527
|
+
.locator(`aria-ref=${ref}`)
|
|
528
|
+
.first()
|
|
529
|
+
.elementHandle({ timeout: 2_000 })
|
|
530
|
+
.catch(() => null);
|
|
531
|
+
if (!handle) {
|
|
532
|
+
// The element is already gone (re-rendered list, closed picker). The
|
|
533
|
+
// snapshot that minted the ref still says what it was, and a role+name
|
|
534
|
+
// locator beats no locator: unverified, but it can resolve on the next
|
|
535
|
+
// run where "(none recorded)" never can — fwgr20's 02-create died on
|
|
536
|
+
// exactly such a step, a data-source picker item, on every replay.
|
|
537
|
+
const hint = refHint(page, ref);
|
|
538
|
+
if (hint?.name) {
|
|
539
|
+
const c = { kind: 'role', role: hint.role, name: hint.name };
|
|
540
|
+
return { expr: candidateExpr(c), verified: false, raw, chain: [c] };
|
|
541
|
+
}
|
|
542
|
+
return { expr: '', verified: false, raw };
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return await describeHandle(page, handle, raw, retarget);
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
await handle.dispose().catch(() => { });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The identifying string a candidate matches on — the thing that would make it
|
|
553
|
+
* a *circular* locator if it equals the value we are trying to re-read. A price
|
|
554
|
+
* cell must not be located by "125.00"; it is located by its testid or its
|
|
555
|
+
* structural path instead.
|
|
556
|
+
*/
|
|
557
|
+
function candidateIdentity(c) {
|
|
558
|
+
switch (c.kind) {
|
|
559
|
+
case 'role':
|
|
560
|
+
return c.name;
|
|
561
|
+
case 'text':
|
|
562
|
+
return c.text;
|
|
563
|
+
case 'label':
|
|
564
|
+
return c.label;
|
|
565
|
+
case 'placeholder':
|
|
566
|
+
return c.placeholder;
|
|
567
|
+
case 'testid':
|
|
568
|
+
return c.value;
|
|
569
|
+
case 'scoped':
|
|
570
|
+
// Anchoring a read to the very value it reads would re-read whatever
|
|
571
|
+
// the next run happens to show there — the circularity this guards.
|
|
572
|
+
return c.hasText;
|
|
573
|
+
default:
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Record-time read-back synthesis (progressive automation option (c)): given a
|
|
579
|
+
* value the agent just reported, find the live element showing it and derive a
|
|
580
|
+
* durable, NON-value locator for it, so the same value can be re-read on a
|
|
581
|
+
* later replay instead of being reported from memory. Returns a synthetic
|
|
582
|
+
* `read` step, or null when the value cannot be pinned to a single element or
|
|
583
|
+
* only a value-based (circular) locator would resolve — in which case the
|
|
584
|
+
* value stays un-threadable and the caller falls back to recovery.
|
|
585
|
+
*/
|
|
586
|
+
export async function captureReadBack(page, value, label) {
|
|
587
|
+
const v = value.trim();
|
|
588
|
+
if (v.length < 2 || v.length > 80)
|
|
589
|
+
return null; // too short to be distinctive, or prose
|
|
590
|
+
const loc = page.getByText(v, { exact: true });
|
|
591
|
+
const count = await loc.count().catch(() => 0);
|
|
592
|
+
// Ambiguity is acceptable for a READ-BACK only when something ELSE names the
|
|
593
|
+
// record. Within one page state, two matches of the same string do both read
|
|
594
|
+
// that string — which is what made this look safe. Across RUNS it is not:
|
|
595
|
+
// odoo keeps every run's records (no rollback, writes are runid-scoped), so
|
|
596
|
+
// by run 2 the page holds n1's customer as well as n2's and `.first()` is
|
|
597
|
+
// the wrong one. fwod9 replayed step 1 at tier A and published
|
|
598
|
+
// "fwod9-n1 Bench Customer" as run n2's observation; 1/6 objectives passed.
|
|
599
|
+
//
|
|
600
|
+
// So an ambiguous match must resolve through a row anchor, which carries the
|
|
601
|
+
// record's own identity and re-binds per run. Repairdesk never showed this
|
|
602
|
+
// because the harness resets it between runs — there was no earlier record
|
|
603
|
+
// to find.
|
|
604
|
+
if (count >= 1) {
|
|
605
|
+
const handle = await loc.first().elementHandle({ timeout: 1_000 }).catch(() => null);
|
|
606
|
+
if (handle) {
|
|
607
|
+
try {
|
|
608
|
+
const step = await readBackFromHandle(page, handle, v);
|
|
609
|
+
const winner = step?.locators.target.chain?.[0];
|
|
610
|
+
if (step && (count === 1 || winner?.kind === 'scoped'))
|
|
611
|
+
return label ? { ...step, label } : step;
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
await handle.dispose().catch(() => { });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Ambiguous by text, but shown in exactly one HEADING. The row-anchor rule
|
|
619
|
+
// above guards against LIST pages, where a matching string may belong to an
|
|
620
|
+
// EARLIER run's record (fwod9 republished n1's customer as n2's
|
|
621
|
+
// observation). A heading is the opposite case: it names the record THIS
|
|
622
|
+
// PAGE displays, and a replay reaches this page by its own navigation, so
|
|
623
|
+
// the heading shows the replay's own value. fwod26 is what refusing this
|
|
624
|
+
// costs: S00021 sat in both the breadcrumb and the form's <h1>, the unique-
|
|
625
|
+
// text pin bailed, the run's one record reference never became a replayable
|
|
626
|
+
// read, and five later steps fell back with `unresolved reference(s)` on
|
|
627
|
+
// BOTH replays — for a value that was on screen, correctly named, the
|
|
628
|
+
// whole time.
|
|
629
|
+
if (count > 1) {
|
|
630
|
+
const inHeading = page.locator('h1, h2, h3').getByText(v, { exact: true });
|
|
631
|
+
if ((await inHeading.count().catch(() => 0)) === 1) {
|
|
632
|
+
const handle = await inHeading.elementHandle({ timeout: 1_000 }).catch(() => null);
|
|
633
|
+
if (handle) {
|
|
634
|
+
try {
|
|
635
|
+
const step = await readBackFromHandle(page, handle, v);
|
|
636
|
+
if (step)
|
|
637
|
+
return label ? { ...step, label } : step;
|
|
638
|
+
}
|
|
639
|
+
finally {
|
|
640
|
+
await handle.dispose().catch(() => { });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Not in a text node — try the form controls. An app that edits records
|
|
646
|
+
// in-place holds its values in `input.value`, which getByText cannot see:
|
|
647
|
+
// odoo reported six values from its order form and this pinned NONE of
|
|
648
|
+
// them, so every later step referencing one lost its zero-model path. The
|
|
649
|
+
// read is stored with what:'value' so the replay re-reads the control
|
|
650
|
+
// rather than its label.
|
|
651
|
+
const form = await captureFormValue(page, v);
|
|
652
|
+
return form && label ? { ...form, label } : form;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The single form control whose value IS this string.
|
|
656
|
+
*
|
|
657
|
+
* One round trip: comparing values element-by-element from here would be a
|
|
658
|
+
* round trip each, and a record form can hold dozens.
|
|
659
|
+
*/
|
|
660
|
+
async function captureFormValue(page, v) {
|
|
661
|
+
const controls = page.locator('input, textarea, select');
|
|
662
|
+
let hits;
|
|
663
|
+
try {
|
|
664
|
+
hits = await controls.evaluateAll((els, want) => els.map((el, i) => ((el.value ?? '').trim() === want ? i : -1)).filter((i) => i >= 0), v);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
if (hits.length !== 1)
|
|
670
|
+
return null; // ambiguous or absent — a form control has no row anchor to fall back on
|
|
671
|
+
const handle = await controls.nth(hits[0]).elementHandle({ timeout: 1_000 }).catch(() => null);
|
|
672
|
+
if (!handle)
|
|
673
|
+
return null;
|
|
674
|
+
try {
|
|
675
|
+
return await readBackFromHandle(page, handle, v, 'value');
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
await handle.dispose().catch(() => { });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Read-back from a selector the MODEL supplied (the verified-fallback path,
|
|
683
|
+
* for values captureReadBack could not pin by text — e.g. a value that is not
|
|
684
|
+
* unique). The selector is trusted only after it resolves to exactly one
|
|
685
|
+
* element whose text actually IS the value; otherwise null and the value stays
|
|
686
|
+
* un-threadable.
|
|
687
|
+
*/
|
|
688
|
+
export async function captureReadBackAt(page, value, selector) {
|
|
689
|
+
const v = value.trim();
|
|
690
|
+
if (!selector.trim() || v.length < 2 || v.length > 80)
|
|
691
|
+
return null;
|
|
692
|
+
let loc;
|
|
693
|
+
try {
|
|
694
|
+
loc = resolveTarget(page, selector);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
const count = await loc.count().catch(() => 0);
|
|
700
|
+
if (count !== 1)
|
|
701
|
+
return null; // must be unambiguous
|
|
702
|
+
const handle = await loc.first().elementHandle({ timeout: 1_000 }).catch(() => null);
|
|
703
|
+
if (!handle)
|
|
704
|
+
return null;
|
|
705
|
+
try {
|
|
706
|
+
const raw = await handle
|
|
707
|
+
.evaluate((el) => (el.innerText ?? el.value ?? '').trim())
|
|
708
|
+
.catch(() => '');
|
|
709
|
+
if (raw !== v && !raw.includes(v))
|
|
710
|
+
return null; // the model pointed at the wrong element
|
|
711
|
+
return await readBackFromHandle(page, handle, v);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
await handle.dispose().catch(() => { });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Derive a durable, non-circular read step for `value` from a live element. */
|
|
718
|
+
async function readBackFromHandle(page, handle, v, what = 'text') {
|
|
719
|
+
const info = (await handle.evaluate(describeInPage));
|
|
720
|
+
const chain = [];
|
|
721
|
+
let winner = null;
|
|
722
|
+
for (const candidate of candidatesFor(info)) {
|
|
723
|
+
// Skip any candidate whose identity IS the value — locating the price by
|
|
724
|
+
// "125.00" would never match a different price on the next run.
|
|
725
|
+
if (candidateIdentity(candidate.spec) === v)
|
|
726
|
+
continue;
|
|
727
|
+
// Same uniqueness rule as verifiedChain: an ambiguous anchor is not identity.
|
|
728
|
+
if (candidate.spec.kind === 'scoped' && (await candidate.make(page).count().catch(() => 0)) !== 1)
|
|
729
|
+
continue;
|
|
730
|
+
if (candidate.spec.kind === 'point') {
|
|
731
|
+
chain.push(candidate.spec);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const loc = candidate.make(page);
|
|
735
|
+
const match = await matchIndex(loc, handle);
|
|
736
|
+
if (match === null)
|
|
737
|
+
continue;
|
|
738
|
+
// Same rule as verifiedChain: an AMBIGUOUS candidate needs its index, and
|
|
739
|
+
// `match === 0` is not the same as "unique". This copy was missed when
|
|
740
|
+
// that was fixed, so a read-back pinned to an ambiguous locator could not
|
|
741
|
+
// be re-resolved.
|
|
742
|
+
const spec = match.count > 1 || match.index !== 0 ? { ...candidate.spec, nth: match.index } : candidate.spec;
|
|
743
|
+
if (!winner)
|
|
744
|
+
winner = spec;
|
|
745
|
+
chain.push(spec);
|
|
746
|
+
}
|
|
747
|
+
if (!winner)
|
|
748
|
+
return null; // only a circular locator resolved — cannot re-read stably
|
|
749
|
+
return {
|
|
750
|
+
k: 'step',
|
|
751
|
+
tool: 'read',
|
|
752
|
+
args: { target: '(read-back)', what },
|
|
753
|
+
locators: { target: { expr: candidateExpr(winner), verified: true, raw: '(read-back)', chain } },
|
|
754
|
+
result: JSON.stringify(v),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/** Describe the element a live Locator resolves to (replay path). */
|
|
758
|
+
export async function describeLocator(page, locator, raw, retarget = false) {
|
|
759
|
+
const handle = await locator.elementHandle({ timeout: 2_000 }).catch(() => null);
|
|
760
|
+
if (!handle)
|
|
761
|
+
return { expr: '', verified: false, raw };
|
|
762
|
+
try {
|
|
763
|
+
return await describeHandle(page, handle, raw, retarget);
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
await handle.dispose().catch(() => { });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function describeHandle(page, handle, raw, retarget = false) {
|
|
770
|
+
// A click is recorded against the element that best survives the app
|
|
771
|
+
// restyling itself — see CLICK_RETARGETS. The element the agent actually
|
|
772
|
+
// clicked keeps its structural path as the chain's last fallback.
|
|
773
|
+
if (retarget) {
|
|
774
|
+
for (const better of CLICK_RETARGETS) {
|
|
775
|
+
const el = await better(handle).catch(() => null);
|
|
776
|
+
if (!el)
|
|
777
|
+
continue;
|
|
778
|
+
try {
|
|
779
|
+
const info = (await el.evaluate(describeInPage));
|
|
780
|
+
const { winner, chain } = await verifiedChain(page, info, el);
|
|
781
|
+
if (winner) {
|
|
782
|
+
const selfInfo = (await handle.evaluate(describeInPage));
|
|
783
|
+
const fallback = { kind: 'css', selector: selfInfo.cssPath };
|
|
784
|
+
return { expr: candidateExpr(winner), verified: true, raw, chain: [...chain, fallback] };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
await el.dispose().catch(() => { });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const info = (await handle.evaluate(describeInPage));
|
|
793
|
+
const { winner, chain } = await verifiedChain(page, info, handle);
|
|
794
|
+
if (winner)
|
|
795
|
+
return { expr: candidateExpr(winner), verified: true, raw, chain };
|
|
796
|
+
// Nothing resolved back to this element — hand over the structural path and
|
|
797
|
+
// let the generated script flag it, rather than inventing something clean.
|
|
798
|
+
return { expr: `page.locator(${q(info.cssPath)})`, verified: false, raw, chain };
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Elements a click is better recorded against than the one the agent hit, in
|
|
802
|
+
* preference order. Each returns a handle to the better target or null.
|
|
803
|
+
*
|
|
804
|
+
* 1. A click on a table ROW opening a record is more durably located by the
|
|
805
|
+
* record's own link inside it (name = the ref, which parameterises) than
|
|
806
|
+
* by the row (name = the whole volatile row text; a positional css
|
|
807
|
+
* otherwise).
|
|
808
|
+
* 2. A click on an INERT element inside a control belongs to the control.
|
|
809
|
+
* The agent clicks whatever the snapshot handed it — often a text span
|
|
810
|
+
* inside a button — and the span's inner structure is the most volatile
|
|
811
|
+
* DOM in the app: fwgr18 recorded grafana's time-picker as
|
|
812
|
+
* `[testid] span > span`, the span nesting changed once a range was set,
|
|
813
|
+
* and every replay fell to a bare structural path (4 of the sweep's 11
|
|
814
|
+
* fallthroughs). The BUTTON has the testid and the stable identity; a
|
|
815
|
+
* click on it lands the same.
|
|
816
|
+
*/
|
|
817
|
+
const CLICK_RETARGETS = [recordLinkOf, interactiveAncestorOf];
|
|
818
|
+
/**
|
|
819
|
+
* If `handle` is an inert presentational element (a span, an icon) sitting
|
|
820
|
+
* inside an interactive control a few hops up, return the control — the
|
|
821
|
+
* element whose identity (testid, role, accessible name) survives the app
|
|
822
|
+
* restyling its innards. Null when the element is itself interactive or no
|
|
823
|
+
* control encloses it.
|
|
824
|
+
*/
|
|
825
|
+
async function interactiveAncestorOf(handle) {
|
|
826
|
+
const found = await handle.evaluateHandle((el) => {
|
|
827
|
+
const node = el;
|
|
828
|
+
// SVG-namespace elements report a lowercase tagName ("svg", "path"), so
|
|
829
|
+
// an icon click never matched the inert list until this upper-cased.
|
|
830
|
+
const tag = (n) => n.tagName.toUpperCase();
|
|
831
|
+
const interactive = (n) => {
|
|
832
|
+
if (/^(BUTTON|A|INPUT|SELECT|TEXTAREA|SUMMARY|LABEL)$/.test(tag(n)))
|
|
833
|
+
return true;
|
|
834
|
+
const role = n.getAttribute('role');
|
|
835
|
+
if (role && /^(button|link|menuitem|menuitemcheckbox|menuitemradio|tab|option|checkbox|radio|switch)$/.test(role))
|
|
836
|
+
return true;
|
|
837
|
+
return n.hasAttribute('tabindex') && n.getAttribute('tabindex') !== '-1';
|
|
838
|
+
};
|
|
839
|
+
if (!/^(SPAN|I|EM|B|STRONG|SVG|PATH|USE|IMG|SMALL|SUP|SUB)$/.test(tag(node)) || interactive(node))
|
|
840
|
+
return null;
|
|
841
|
+
for (let cur = node.parentElement, hops = 0; cur && hops < 4; cur = cur.parentElement, hops++) {
|
|
842
|
+
if (interactive(cur))
|
|
843
|
+
return cur;
|
|
844
|
+
}
|
|
845
|
+
return null;
|
|
846
|
+
});
|
|
847
|
+
const el = found.asElement();
|
|
848
|
+
if (!el) {
|
|
849
|
+
await found.dispose().catch(() => { });
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
return el;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* If `handle` is a container (a table row, list item, card) that wraps exactly
|
|
856
|
+
* one hyperlink, return a handle to that link — the durable, often
|
|
857
|
+
* parameterisable target for a navigation click. Null otherwise, including
|
|
858
|
+
* when the element already IS the link or has several links (ambiguous).
|
|
859
|
+
*/
|
|
860
|
+
async function recordLinkOf(handle) {
|
|
861
|
+
const found = await handle.evaluateHandle((el) => {
|
|
862
|
+
const node = el;
|
|
863
|
+
if (node.tagName === 'A')
|
|
864
|
+
return null; // already a link
|
|
865
|
+
const container = /^(TR|LI|TD|TH|DIV|SECTION|ARTICLE)$/.test(node.tagName) || node.getAttribute('role') === 'row' || node.getAttribute('role') === 'listitem';
|
|
866
|
+
if (!container)
|
|
867
|
+
return null;
|
|
868
|
+
const links = Array.from(node.querySelectorAll('a[href]')).filter((a) => a.offsetParent !== null || a.getClientRects().length > 0);
|
|
869
|
+
return links.length === 1 ? links[0] : null;
|
|
870
|
+
});
|
|
871
|
+
const el = found.asElement();
|
|
872
|
+
if (!el) {
|
|
873
|
+
await found.dispose().catch(() => { });
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
return el;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* All candidates for an element, the first that resolves back to it marked
|
|
880
|
+
* with its index. Later candidates are kept unindexed as replay fallbacks —
|
|
881
|
+
* checking each costs round trips, and a fallback that resolves to exactly
|
|
882
|
+
* one element needs no index anyway.
|
|
883
|
+
*/
|
|
884
|
+
async function verifiedChain(page, info, handle) {
|
|
885
|
+
const chain = [];
|
|
886
|
+
let winner = null;
|
|
887
|
+
for (const candidate of candidatesFor(info)) {
|
|
888
|
+
// An identity anchor that matches several elements is not identity. It
|
|
889
|
+
// would record clean (the handle is simply match 0) and then be discarded
|
|
890
|
+
// at replay, where ambiguity in the primary reads as drift — so prove it
|
|
891
|
+
// singles the record out HERE, while the page that produced it is live.
|
|
892
|
+
if (candidate.spec.kind === 'scoped' && (await candidate.make(page).count().catch(() => 0)) !== 1)
|
|
893
|
+
continue;
|
|
894
|
+
// The point is where the element IS, so it always "matches"; it is never
|
|
895
|
+
// the winner because it names no element, only a place.
|
|
896
|
+
if (winner || candidate.spec.kind === 'point') {
|
|
897
|
+
chain.push(candidate.spec);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const loc = candidate.make(page);
|
|
901
|
+
const match = await matchIndex(loc, handle);
|
|
902
|
+
if (match === null)
|
|
903
|
+
continue;
|
|
904
|
+
// Record the index whenever the locator is AMBIGUOUS, including index 0.
|
|
905
|
+
// `match === 0` used to mean "no nth needed", conflating "unique" with
|
|
906
|
+
// "first of several": every part row carries an Edit button, so the
|
|
907
|
+
// recorded getByRole('button', { name: 'Edit' }) stored no index, and at
|
|
908
|
+
// replay it matched two elements, read as drift, and fell through to
|
|
909
|
+
// `tr:nth-of-type(1) > td:nth-of-type(7)` — a structural path onto a
|
|
910
|
+
// record row, which is the shape behind every wrong-record bug this plan
|
|
911
|
+
// exists to stop. An ambiguous candidate needs its index to be
|
|
912
|
+
// reproducible, exactly as an ambiguous anchor needs to be unique.
|
|
913
|
+
winner = match.count > 1 || match.index !== 0 ? { ...candidate.spec, nth: match.index } : candidate.spec;
|
|
914
|
+
chain.push(winner);
|
|
915
|
+
}
|
|
916
|
+
return { winner, chain };
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Index of `handle` within `locator`'s matches, or null if it is not among the
|
|
920
|
+
* first few. Identity (not text equality) is the test: two buttons can share a
|
|
921
|
+
* label, and only the one the agent actually used is the right recording.
|
|
922
|
+
*/
|
|
923
|
+
async function matchIndex(locator, handle) {
|
|
924
|
+
// One round trip for the index AND the match count, instead of a count
|
|
925
|
+
// plus one evaluate per candidate element (up to eleven per describe).
|
|
926
|
+
try {
|
|
927
|
+
const r = await locator.evaluateAll((els, other) => ({ index: els.indexOf(other), count: els.length }), handle);
|
|
928
|
+
return r.count === 0 || r.index < 0 || r.index >= MATCH_INDEX_LIMIT ? null : r;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return null; // detached page or malformed selector
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/** How far into a locator's matches the recorded element may sit and still be indexed. */
|
|
935
|
+
const MATCH_INDEX_LIMIT = 10;
|
|
936
|
+
function candidatesFor(info) {
|
|
937
|
+
const out = [];
|
|
938
|
+
// Identity first, when the element sits in a record's row that shows a
|
|
939
|
+
// value the caller vouched for: that locator names the RECORD, so it is the
|
|
940
|
+
// only candidate here that survives the record moving, being renumbered, or
|
|
941
|
+
// another record sorting above it.
|
|
942
|
+
const anchor = identityAnchor(info);
|
|
943
|
+
if (anchor)
|
|
944
|
+
out.push(cand(anchor));
|
|
945
|
+
if (info.testid) {
|
|
946
|
+
const { attr, value } = info.testid;
|
|
947
|
+
out.push(cand({ kind: 'testid', attr, value }));
|
|
948
|
+
}
|
|
949
|
+
if (info.role && info.name)
|
|
950
|
+
out.push(cand({ kind: 'role', role: info.role, name: info.name }));
|
|
951
|
+
if (info.label)
|
|
952
|
+
out.push(cand({ kind: 'label', label: info.label }));
|
|
953
|
+
if (info.placeholder)
|
|
954
|
+
out.push(cand({ kind: 'placeholder', placeholder: info.placeholder }));
|
|
955
|
+
if (info.id && isStableId(info.id)) {
|
|
956
|
+
const sel = /^[A-Za-z][\w-]*$/.test(info.id) ? `#${info.id}` : `[id=${JSON.stringify(info.id)}]`;
|
|
957
|
+
out.push(cand({ kind: 'id', selector: sel }));
|
|
958
|
+
}
|
|
959
|
+
if (info.text && !info.role)
|
|
960
|
+
out.push(cand({ kind: 'text', text: info.text }));
|
|
961
|
+
// The anchored rung between the element's own semantics and the bare
|
|
962
|
+
// positional path: `[ancestor-testid] input` names a region and then the
|
|
963
|
+
// element's kind within it. The chain walker verifies it against the live
|
|
964
|
+
// element and adds `nth` only when the region holds several — which
|
|
965
|
+
// structural() then honestly reports as positional. Without this rung a
|
|
966
|
+
// testid-rich app whose input's own semantics drift falls straight to
|
|
967
|
+
// position from the document root (fwgr17-n3's panel-title fill).
|
|
968
|
+
if (info.anchor) {
|
|
969
|
+
out.push(cand({ kind: 'css', selector: `[${info.anchor.attr}=${JSON.stringify(info.anchor.value)}] ${info.tag}` }));
|
|
970
|
+
}
|
|
971
|
+
out.push(cand({ kind: 'css', selector: info.cssPath }));
|
|
972
|
+
// Where it was, last of all — see LocatorCandidate 'point'.
|
|
973
|
+
if (info.box) {
|
|
974
|
+
const { x, y, w, h } = info.box;
|
|
975
|
+
out.push(cand({ kind: 'point', x: Math.round(x + w / 2), y: Math.round(y + h / 2), w, h, role: info.role, tag: info.tag, vw: info.viewport.w, vh: info.viewport.h }));
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Values that IDENTIFY the record being worked on this instruction: the
|
|
981
|
+
* caller's declared variables (a runid) plus anything typed during the
|
|
982
|
+
* instruction (the title of the thing just created). Set by the agent loop
|
|
983
|
+
* around each instruction; used only to prefer a record-anchored locator over
|
|
984
|
+
* a positional one, so a stale or empty list costs nothing but the old
|
|
985
|
+
* behaviour.
|
|
986
|
+
*/
|
|
987
|
+
let identityHints = [];
|
|
988
|
+
export function setIdentityHints(values) {
|
|
989
|
+
identityHints = values.map((v) => String(v ?? '').trim()).filter((v) => v.length >= MIN_HINT_LEN && v.length <= 120);
|
|
990
|
+
}
|
|
991
|
+
export function addIdentityHint(value) {
|
|
992
|
+
const v = String(value ?? '').trim();
|
|
993
|
+
if (v.length >= MIN_HINT_LEN && v.length <= 120 && !identityHints.includes(v))
|
|
994
|
+
identityHints.push(v);
|
|
995
|
+
}
|
|
996
|
+
const MIN_HINT_LEN = 4;
|
|
997
|
+
/** The scoped candidate for this element, when its row shows an identity hint. */
|
|
998
|
+
function identityAnchor(info) {
|
|
999
|
+
const row = info.row;
|
|
1000
|
+
if (!row || !identityHints.length)
|
|
1001
|
+
return null;
|
|
1002
|
+
// Longest match wins: a part's full name is a sharper anchor than the runid
|
|
1003
|
+
// it starts with, and the runid alone would match every row of this run.
|
|
1004
|
+
const hit = identityHints.filter((h) => row.text.includes(h)).sort((a, b) => b.length - a.length)[0];
|
|
1005
|
+
if (!hit)
|
|
1006
|
+
return null;
|
|
1007
|
+
// A hint can be true of many rows at once: every part created this run is
|
|
1008
|
+
// named "<runid> RD Part X", so `hasText: runid` matches them all and replay
|
|
1009
|
+
// reads that ambiguity as drift (fwrd11l 03-add/04-edit/06-remove). Narrow
|
|
1010
|
+
// it to the shortest CELL containing the hint — that cell names this record
|
|
1011
|
+
// and still carries the hint, so compile slots the known value inside it.
|
|
1012
|
+
const narrowed = row.cells.filter((c) => c.includes(hit)).sort((a, b) => a.length - b.length)[0];
|
|
1013
|
+
const hasText = narrowed && narrowed.length <= 120 ? narrowed : hit;
|
|
1014
|
+
return { kind: 'scoped', container: row.container, hasText, ...(row.inner ? { selector: row.inner } : {}) };
|
|
1015
|
+
}
|
|
1016
|
+
function cand(spec) {
|
|
1017
|
+
return { spec, expr: candidateExpr(spec), make: (p) => makeLocator(p, spec) };
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Framework-generated ids (React's `:r3:`, hash suffixes, bare counters) are
|
|
1021
|
+
* re-minted on the next run, so they are worse than the structural path.
|
|
1022
|
+
*/
|
|
1023
|
+
export function isStableId(id) {
|
|
1024
|
+
if (!id || id.length > 64)
|
|
1025
|
+
return false;
|
|
1026
|
+
if (/^[:\d]/.test(id))
|
|
1027
|
+
return false;
|
|
1028
|
+
if (/[0-9a-f]{8,}/i.test(id))
|
|
1029
|
+
return false;
|
|
1030
|
+
// React's useId with the colons swapped for underscores (grafana does
|
|
1031
|
+
// this): `_rgl_`, `_r2u_`. Re-minted every render pass, so a primary built
|
|
1032
|
+
// on one misses on every replay — fwgr18 recorded `[id="_rgl_"]` and
|
|
1033
|
+
// `[id="_r2u_"]` as primaries and both were dead chains at replay time.
|
|
1034
|
+
if (/^_r[0-9a-z]{1,4}_$/i.test(id))
|
|
1035
|
+
return false;
|
|
1036
|
+
return !/^(radix|headlessui|mui|react-aria)[-:]/i.test(id);
|
|
1037
|
+
}
|
|
1038
|
+
/** Runs in the page: everything a locator can be built from, in one round trip. */
|
|
1039
|
+
function describeInPage(node) {
|
|
1040
|
+
const el = node;
|
|
1041
|
+
const attr = (name) => el.getAttribute(name) || null;
|
|
1042
|
+
// The same judgement as isStableId (which this page-side code cannot
|
|
1043
|
+
// call): a framework-minted id (React's `_r8b_`, radix, a hash) anchoring
|
|
1044
|
+
// the structural path or a row container is dead on the next load.
|
|
1045
|
+
const stableId = (id) => Boolean(id) &&
|
|
1046
|
+
id.length <= 64 &&
|
|
1047
|
+
!/^[:\d]/.test(id) &&
|
|
1048
|
+
!/[0-9a-f]{8,}/i.test(id) &&
|
|
1049
|
+
!/^_r[0-9a-z]{1,4}_$/i.test(id) &&
|
|
1050
|
+
!/^(radix|headlessui|mui|react-aria)[-:]/i.test(id);
|
|
1051
|
+
const clean = (s) => {
|
|
1052
|
+
const t = (s ?? '').replace(/\s+/g, ' ').trim();
|
|
1053
|
+
return t && t.length <= 80 ? t : null;
|
|
1054
|
+
};
|
|
1055
|
+
const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy'];
|
|
1056
|
+
const testidAttr = TESTID_ATTRS.find((a) => el.getAttribute(a));
|
|
1057
|
+
// Nearest testid-carrying ANCESTOR — see ElementInfo.anchor.
|
|
1058
|
+
const anchorOf = () => {
|
|
1059
|
+
for (let cur = el.parentElement, hops = 0; cur && hops < 10; cur = cur.parentElement, hops++) {
|
|
1060
|
+
const a = TESTID_ATTRS.find((x) => cur.getAttribute(x));
|
|
1061
|
+
if (a)
|
|
1062
|
+
return { attr: a, value: cur.getAttribute(a) };
|
|
1063
|
+
}
|
|
1064
|
+
return null;
|
|
1065
|
+
};
|
|
1066
|
+
const tag = el.tagName.toLowerCase();
|
|
1067
|
+
const type = (attr('type') || '').toLowerCase();
|
|
1068
|
+
const implicitRole = () => {
|
|
1069
|
+
if (tag === 'button')
|
|
1070
|
+
return 'button';
|
|
1071
|
+
if (tag === 'a')
|
|
1072
|
+
return el.hasAttribute('href') ? 'link' : null;
|
|
1073
|
+
if (tag === 'select')
|
|
1074
|
+
return el.hasAttribute('multiple') ? 'listbox' : 'combobox';
|
|
1075
|
+
if (tag === 'textarea')
|
|
1076
|
+
return 'textbox';
|
|
1077
|
+
if (tag === 'img')
|
|
1078
|
+
return 'img';
|
|
1079
|
+
if (/^h[1-6]$/.test(tag))
|
|
1080
|
+
return 'heading';
|
|
1081
|
+
if (tag === 'input') {
|
|
1082
|
+
if (type === 'checkbox')
|
|
1083
|
+
return 'checkbox';
|
|
1084
|
+
if (type === 'radio')
|
|
1085
|
+
return 'radio';
|
|
1086
|
+
if (type === 'submit' || type === 'button' || type === 'reset')
|
|
1087
|
+
return 'button';
|
|
1088
|
+
if (type === 'search')
|
|
1089
|
+
return 'searchbox';
|
|
1090
|
+
if (type === 'number')
|
|
1091
|
+
return 'spinbutton';
|
|
1092
|
+
if (['text', 'email', 'tel', 'url', 'password', ''].includes(type))
|
|
1093
|
+
return 'textbox';
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
return null;
|
|
1097
|
+
};
|
|
1098
|
+
const labelText = () => {
|
|
1099
|
+
const labelledBy = attr('aria-labelledby');
|
|
1100
|
+
if (labelledBy) {
|
|
1101
|
+
const parts = labelledBy
|
|
1102
|
+
.split(/\s+/)
|
|
1103
|
+
.map((id) => el.ownerDocument.getElementById(id)?.textContent ?? '')
|
|
1104
|
+
.join(' ');
|
|
1105
|
+
const cleaned = clean(parts);
|
|
1106
|
+
if (cleaned)
|
|
1107
|
+
return cleaned;
|
|
1108
|
+
}
|
|
1109
|
+
if (el.id) {
|
|
1110
|
+
const forLabel = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
1111
|
+
if (forLabel)
|
|
1112
|
+
return clean(forLabel.textContent);
|
|
1113
|
+
}
|
|
1114
|
+
return clean(el.closest('label')?.textContent ?? null);
|
|
1115
|
+
};
|
|
1116
|
+
const cssPath = () => {
|
|
1117
|
+
const parts = [];
|
|
1118
|
+
let cur = el;
|
|
1119
|
+
while (cur && cur.nodeType === 1 && parts.length < 6) {
|
|
1120
|
+
const node = cur;
|
|
1121
|
+
if (stableId(node.id)) {
|
|
1122
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
let part = node.tagName.toLowerCase();
|
|
1126
|
+
const parent = node.parentElement;
|
|
1127
|
+
if (parent) {
|
|
1128
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
1129
|
+
if (sibs.length > 1)
|
|
1130
|
+
part += `:nth-of-type(${sibs.indexOf(node) + 1})`;
|
|
1131
|
+
}
|
|
1132
|
+
parts.unshift(part);
|
|
1133
|
+
cur = parent;
|
|
1134
|
+
}
|
|
1135
|
+
return parts.join(' > ');
|
|
1136
|
+
};
|
|
1137
|
+
// Nearest repeated container and this element's path inside it. The
|
|
1138
|
+
// container selector is deliberately GENERIC (its tag, scoped to a stable
|
|
1139
|
+
// ancestor id when there is one) so it matches every record's container on
|
|
1140
|
+
// a later run and `hasText` alone picks the record.
|
|
1141
|
+
const rowOf = () => {
|
|
1142
|
+
let box = el.closest('tr, li, [role="row"], [role="listitem"], [role="option"]');
|
|
1143
|
+
// Not every list is semantic: an app that renders rows as divs is just as
|
|
1144
|
+
// common. Fall back to the nearest ancestor that HAS siblings of its own
|
|
1145
|
+
// shape — that repetition is what makes it a record container.
|
|
1146
|
+
if (!box) {
|
|
1147
|
+
for (let cur = el, hops = 0; cur && hops < 4; cur = cur.parentElement, hops++) {
|
|
1148
|
+
const parent = cur.parentElement;
|
|
1149
|
+
if (!parent)
|
|
1150
|
+
break;
|
|
1151
|
+
const shape = (n) => `${n.tagName}.${n.getAttribute('class') ?? ''}`;
|
|
1152
|
+
const sibs = Array.from(parent.children).filter((c) => shape(c) === shape(cur));
|
|
1153
|
+
if (sibs.length >= 2 && cur !== el) {
|
|
1154
|
+
box = cur;
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (!box)
|
|
1160
|
+
return null;
|
|
1161
|
+
const text = box.innerText?.replace(/\s+/g, ' ').trim() ?? '';
|
|
1162
|
+
if (!text || text.length > 400)
|
|
1163
|
+
return null;
|
|
1164
|
+
const cls = (box.getAttribute('class') ?? '').trim().split(/\s+/).filter(Boolean)[0];
|
|
1165
|
+
const tagOf = box.tagName.toLowerCase() + (cls && /^[A-Za-z][\w-]*$/.test(cls) ? `.${cls}` : '');
|
|
1166
|
+
let container = tagOf;
|
|
1167
|
+
for (let p = box.parentElement, hops = 0; p && hops < 3; p = p.parentElement, hops++) {
|
|
1168
|
+
if (stableId(p.id)) {
|
|
1169
|
+
container = `#${CSS.escape(p.id)} ${tagOf}`;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// The element's path relative to the container, same shape as cssPath.
|
|
1174
|
+
const parts = [];
|
|
1175
|
+
let cur = el;
|
|
1176
|
+
while (cur && cur !== box && parts.length < 5) {
|
|
1177
|
+
const parent = cur.parentElement;
|
|
1178
|
+
let part = cur.tagName.toLowerCase();
|
|
1179
|
+
if (parent) {
|
|
1180
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
1181
|
+
if (sibs.length > 1)
|
|
1182
|
+
part += `:nth-of-type(${sibs.indexOf(cur) + 1})`;
|
|
1183
|
+
}
|
|
1184
|
+
parts.unshift(part);
|
|
1185
|
+
cur = parent;
|
|
1186
|
+
}
|
|
1187
|
+
// The row's own cells, so an anchor can be narrowed from "contains the
|
|
1188
|
+
// runid" (true of every row this run touched) to the one cell that
|
|
1189
|
+
// actually names this record.
|
|
1190
|
+
const cells = Array.from(box.children)
|
|
1191
|
+
.map((c) => c.innerText?.replace(/\s+/g, ' ').trim() ?? '')
|
|
1192
|
+
.filter((t) => t && t.length <= 120)
|
|
1193
|
+
.slice(0, 12);
|
|
1194
|
+
return { container, text, inner: cur === box ? parts.join(' > ') : '', cells };
|
|
1195
|
+
};
|
|
1196
|
+
const label = labelText();
|
|
1197
|
+
const name = clean(attr('aria-label')) ||
|
|
1198
|
+
label ||
|
|
1199
|
+
clean(attr('placeholder')) ||
|
|
1200
|
+
clean(attr('alt')) ||
|
|
1201
|
+
clean(attr('title')) ||
|
|
1202
|
+
// Never an input's VALUE: it is not an accessible name (getByRole would
|
|
1203
|
+
// not match it at replay), it changes every run, and on an unlabeled
|
|
1204
|
+
// password field it put the typed secret into the recording.
|
|
1205
|
+
clean(tag === 'input' ? '' : el.innerText);
|
|
1206
|
+
return {
|
|
1207
|
+
tag,
|
|
1208
|
+
testid: testidAttr ? { attr: testidAttr, value: el.getAttribute(testidAttr) } : null,
|
|
1209
|
+
id: el.id || null,
|
|
1210
|
+
role: attr('role') || implicitRole(),
|
|
1211
|
+
name,
|
|
1212
|
+
label,
|
|
1213
|
+
placeholder: clean(attr('placeholder')),
|
|
1214
|
+
text: clean(tag === 'input' ? null : el.innerText),
|
|
1215
|
+
cssPath: cssPath(),
|
|
1216
|
+
row: rowOf(),
|
|
1217
|
+
anchor: anchorOf(),
|
|
1218
|
+
box: (() => {
|
|
1219
|
+
const r = el.getBoundingClientRect();
|
|
1220
|
+
if (!r.width && !r.height)
|
|
1221
|
+
return null;
|
|
1222
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
1223
|
+
return { x: round(r.left + window.scrollX), y: round(r.top + window.scrollY), w: round(r.width), h: round(r.height) };
|
|
1224
|
+
})(),
|
|
1225
|
+
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
/** Single-quoted JS string literal. */
|
|
1229
|
+
export function q(value) {
|
|
1230
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '').replace(/\n/g, '\\n')}'`;
|
|
1231
|
+
}
|
|
1232
|
+
//# sourceMappingURL=recorder.js.map
|