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,1281 @@
|
|
|
1
|
+
import { clip } from '../shared/text.js';
|
|
2
|
+
import { captureSignature } from '../daemon/diff.js';
|
|
3
|
+
import { cosine, fingerprintPage } from '../daemon/fingerprint.js';
|
|
4
|
+
import { candidateExpr, makeLocator, markPoint } from '../daemon/recorder.js';
|
|
5
|
+
import { retired } from './repair.js';
|
|
6
|
+
import { isRefTarget } from '../daemon/refs.js';
|
|
7
|
+
import { TRANSIENT_LINE, WILDCARD, fillParams, fillParamsDeep, maskMinted, maskVolatile, softUrlMatch, urlMatches, urlPart, urlPattern } from './compile.js';
|
|
8
|
+
/** Tools that look at or move to an element without setting or choosing anything. */
|
|
9
|
+
const OBSERVATION_TOOLS = new Set(['scroll_into_view', 'wait_for', 'hover', 'scroll', 'focus', 'screenshot', 'peek']);
|
|
10
|
+
import { originOf } from './store.js';
|
|
11
|
+
const MAX_LINE = 160;
|
|
12
|
+
// Shortest interacted/read value worth treating as an echo. Below this the
|
|
13
|
+
// coincidence rate is too high (a "1m" refresh, a "3" quantity) — a false echo
|
|
14
|
+
// would wrongly drop a legitimate finding, so only substantial values qualify.
|
|
15
|
+
const MIN_ECHO_LEN = 5;
|
|
16
|
+
/** Tools whose miss can be substituted by navigating to the step's recorded
|
|
17
|
+
* destination: plain navigation clicks. modifier_click (new tabs) and loop
|
|
18
|
+
* bodies are excluded. */
|
|
19
|
+
const NAV_FALLBACK_TOOLS = new Set(['click', 'dblclick']);
|
|
20
|
+
/** A soft-matched precondition needs the page's structural fingerprint to
|
|
21
|
+
* agree before replay proceeds on it. Same-template-different-record pages
|
|
22
|
+
* measured 0.94–1.0 in the swg sweeps; the different-template fixture pair
|
|
23
|
+
* measures 0.57. */
|
|
24
|
+
const SOFT_MATCH_MIN_SIMILARITY = 0.8;
|
|
25
|
+
/**
|
|
26
|
+
* Replay a stored skill deterministically: precondition → each step with its
|
|
27
|
+
* locator chain → expectation check → next. Stops at the first failure and
|
|
28
|
+
* hands back exactly what ran, so the agent can continue from the real page
|
|
29
|
+
* state without repeating anything.
|
|
30
|
+
*/
|
|
31
|
+
export async function replaySkill(skill, params, opts) {
|
|
32
|
+
const { page } = opts;
|
|
33
|
+
const res = {
|
|
34
|
+
ok: false,
|
|
35
|
+
skill: skill.id,
|
|
36
|
+
stepsRun: 0,
|
|
37
|
+
stepsTotal: skill.steps.length,
|
|
38
|
+
values: {},
|
|
39
|
+
echoedValues: [],
|
|
40
|
+
lines: [],
|
|
41
|
+
warnings: [],
|
|
42
|
+
fallthroughs: 0,
|
|
43
|
+
misses: [],
|
|
44
|
+
derivedValues: {},
|
|
45
|
+
generalisations: [],
|
|
46
|
+
candidateEvidence: [],
|
|
47
|
+
created: [],
|
|
48
|
+
acted: false,
|
|
49
|
+
similarity: null,
|
|
50
|
+
url: page.url(),
|
|
51
|
+
};
|
|
52
|
+
// Values the skill puts on the page as it runs — fill/type values, and the
|
|
53
|
+
// names of options it clicks. A later read that returns one of these is an
|
|
54
|
+
// echo (confirming the control, not app persistence); see echoedValues.
|
|
55
|
+
// Loosely keyed so "Last 6 hours" matches "last 6 hours".
|
|
56
|
+
const interacted = new Set();
|
|
57
|
+
// A recorded dialog that did not open (see StepVerdict.absentDialog): while
|
|
58
|
+
// set, a step whose target cannot be found is skipped as belonging to that
|
|
59
|
+
// dialog rather than stopping the replay; cleared by the next step that
|
|
60
|
+
// resolves its target normally.
|
|
61
|
+
let absentDialog = null;
|
|
62
|
+
const looseKey = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
63
|
+
// Copy the caller's bindings: derived ({{dN}}) values minted mid-replay are
|
|
64
|
+
// bound into this map as steps execute, so later steps see them.
|
|
65
|
+
params = { ...params };
|
|
66
|
+
const missing = Object.keys(skill.params).filter((p) => !(p in params) || params[p] === '');
|
|
67
|
+
if (missing.length) {
|
|
68
|
+
res.refused = true;
|
|
69
|
+
res.reason = `missing params: ${missing.map((m) => `${m} (e.g. ${JSON.stringify(skill.params[m].example)})`).join(', ')} — nothing was run`;
|
|
70
|
+
return res;
|
|
71
|
+
}
|
|
72
|
+
const startUrl = page.url();
|
|
73
|
+
// A procedure whose FIRST step navigates (goto) carries its own
|
|
74
|
+
// precondition: wherever the browser is, step 1 puts it on the recorded
|
|
75
|
+
// page. Refusing it by start-url would make it permanently unreplayable on
|
|
76
|
+
// apps that redirect at load (the recorded start url is a race between the
|
|
77
|
+
// capture and the redirect) — the flow6 head step failed exactly this way.
|
|
78
|
+
const navigatesItself = skill.steps[0]?.tool === 'goto';
|
|
79
|
+
if (skill.preconditions.fingerprint) {
|
|
80
|
+
res.similarity = cosine(skill.preconditions.fingerprint, (await fingerprintPage(page)) ?? undefined);
|
|
81
|
+
}
|
|
82
|
+
if (!navigatesItself && !urlMatches(skill.preconditions.urlPattern, startUrl, params)) {
|
|
83
|
+
// Same page shape with 1-2 disagreeing segments is likely an
|
|
84
|
+
// environment-minted id (an Odoo action id, a Grafana uid): proceed
|
|
85
|
+
// optimistically instead of refusing — a hard fail here is what turned a
|
|
86
|
+
// one-segment difference into a dead flow, and it also makes the
|
|
87
|
+
// volatility evidence uncollectible. But "likely" is not evidence, so
|
|
88
|
+
// when the segment carries a structural fingerprint, that second gate
|
|
89
|
+
// decides: a different RECORD of the same template fingerprints close
|
|
90
|
+
// (0.94–1.0 in the swg sweeps); a different TEMPLATE does not (the
|
|
91
|
+
// fixture pair measures 0.57). Only a close page proceeds.
|
|
92
|
+
const soft = softUrlMatch(skill.preconditions.urlPattern, startUrl, params);
|
|
93
|
+
const structurallySame = res.similarity === null || res.similarity >= SOFT_MATCH_MIN_SIMILARITY;
|
|
94
|
+
if (!soft || !structurallySame) {
|
|
95
|
+
res.refused = true;
|
|
96
|
+
res.reason =
|
|
97
|
+
`not on the page this procedure starts from (expects ${fillParams(skill.preconditions.urlPattern, params)}, browser is at ${urlPattern(startUrl)}` +
|
|
98
|
+
(soft && !structurallySame ? `; the url shape is close but the page structure is not — similarity ${res.similarity}` : '') +
|
|
99
|
+
`) — nothing was run`;
|
|
100
|
+
return res;
|
|
101
|
+
}
|
|
102
|
+
res.warnings.push(`start url differs from the recorded pattern in ${soft.diffs.length} segment(s) (${soft.diffs.map((d) => `${d.expected}→${d.actual}`).join(', ')}) — proceeding optimistically`);
|
|
103
|
+
res.generalisations.push({ kind: 'precondition', pattern: soft.generalised });
|
|
104
|
+
}
|
|
105
|
+
// Identity: the url pattern and the fingerprint both match every record of
|
|
106
|
+
// this template, so neither can tell ticket t15 from ticket t14. A segment
|
|
107
|
+
// that started on a page showing caller-vouched values must find them
|
|
108
|
+
// again, or it is about to do this run's work on someone else's record.
|
|
109
|
+
//
|
|
110
|
+
// A self-navigating procedure is checked AFTER its goto, not skipped. The
|
|
111
|
+
// old rule was "step 1 decides the page", which is true and beside the
|
|
112
|
+
// point: the recorded goto carries the RECORDING run's record id, so it
|
|
113
|
+
// decides the page to be the wrong one. fwod10 replayed
|
|
114
|
+
// goto .../web#id=44&...&model=res.partner
|
|
115
|
+
// and steps 03-07 did this run's work on n1's records at tier A, published
|
|
116
|
+
// no values, reported success, and verified 1/6. The guard designed to stop
|
|
117
|
+
// exactly that was disabled precisely for the procedures most likely to
|
|
118
|
+
// need it.
|
|
119
|
+
const checkIdentity = async () => {
|
|
120
|
+
for (const marker of skill.preconditions.requireText ?? []) {
|
|
121
|
+
const want = fillParams(marker, params);
|
|
122
|
+
if (!want || /\{\{/.test(want))
|
|
123
|
+
continue; // unbound marker proves nothing
|
|
124
|
+
if (await presentOnPage(page, [want]))
|
|
125
|
+
continue;
|
|
126
|
+
res.refused = true;
|
|
127
|
+
res.wrongRecord = `the page at ${urlPattern(page.url())} does not show ${JSON.stringify(clip(want, 60))} — it matches this procedure's page template but is a different record — nothing was run`;
|
|
128
|
+
res.reason = res.wrongRecord;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
};
|
|
133
|
+
if (!navigatesItself && skill.preconditions.requireText?.length && !(await checkIdentity()))
|
|
134
|
+
return res;
|
|
135
|
+
// One step against the live page. Mutates `res` (lines/warnings/values/
|
|
136
|
+
// stepsRun) and returns how it went; a 'stop' has already set failedAt/reason.
|
|
137
|
+
// `tag` labels the step for humans (e.g. "5" or, inside a loop, "9.2.1");
|
|
138
|
+
// `failIndex` is the top-level step number recorded in failedAt on a stop.
|
|
139
|
+
const runOneStep = async (step, tag, failIndex,
|
|
140
|
+
/** When set (loop bodies), collects what each target actually resolved to, for the loop's progress check. */
|
|
141
|
+
sink,
|
|
142
|
+
/** Loop-body cursor: which match an ambiguous per-record locator should act on (see resolveChain). */
|
|
143
|
+
ambiguousNth) => {
|
|
144
|
+
const args = fillParamsDeep(step.args, params);
|
|
145
|
+
const head = `${tag}. ${step.tool} ${describeArgs(step.tool, args)}`;
|
|
146
|
+
// The agent's observation turns were implicit waits; a replay has none,
|
|
147
|
+
// so let the DOM go quiet before looking for this step's target. Generic
|
|
148
|
+
// (no network-idle, no app knowledge) and instant on a static page.
|
|
149
|
+
await settleDom(page);
|
|
150
|
+
// A read/read_all is an OBSERVATION, not a state change: its failure means
|
|
151
|
+
// a value could not be re-captured, never that the procedure is broken. So
|
|
152
|
+
// a read that cannot resolve or errors is skipped with a warning and the
|
|
153
|
+
// replay continues — only an action step (click/fill/submit) or a hard
|
|
154
|
+
// expectation stops it. read_all also legitimately matches many elements,
|
|
155
|
+
// so its target need not be unique.
|
|
156
|
+
const isRead = step.tool === 'read' || step.tool === 'read_all';
|
|
157
|
+
// Resolve every target through its chain before touching the page.
|
|
158
|
+
const resolved = {};
|
|
159
|
+
let resolveError = null;
|
|
160
|
+
// Whether ANY target of this step resolved through a structural candidate
|
|
161
|
+
// — position, not identity. Sharpens the effect gate below: a positional
|
|
162
|
+
// resolution must be corroborated by a consequential page change, not by
|
|
163
|
+
// the fill's own echo.
|
|
164
|
+
let positionalResolution = false;
|
|
165
|
+
for (const key of ['target', 'source']) {
|
|
166
|
+
if (!(key in args))
|
|
167
|
+
continue;
|
|
168
|
+
const chain = fillParamsDeep(step.locators[key] ?? [], params) ?? [];
|
|
169
|
+
const identity = identityOfPrimary(step.locators[key] ?? [], skill, params);
|
|
170
|
+
const policy = {
|
|
171
|
+
rawTarget: typeof args[key] === 'string' ? String(args[key]) : '',
|
|
172
|
+
allowMultiple: step.tool === 'read_all',
|
|
173
|
+
ambiguousNth,
|
|
174
|
+
requireIdentity: identity,
|
|
175
|
+
// Polling to FIND something that is supposed to be gone only delays
|
|
176
|
+
// the answer; the absence is the condition (see waitsForAbsence).
|
|
177
|
+
waitMs: waitsForAbsence(step, args) ? 0 : resolveWaitMs(),
|
|
178
|
+
stayOnOrigin: originOf(step.expect?.urlPattern ?? '') ?? originOf(page.url()) ?? undefined,
|
|
179
|
+
};
|
|
180
|
+
let hit = await resolveChain(page, chain, policy);
|
|
181
|
+
// A virtualised page renders below-the-fold content only once it has
|
|
182
|
+
// been scrolled to. The agent's scrolls were evals, which never compile,
|
|
183
|
+
// so a read of the third panel heading found two headings and was
|
|
184
|
+
// skipped (fwgr23 01-open, both replays: objective 1 lost). One sweep
|
|
185
|
+
// of the page before giving up on an observation.
|
|
186
|
+
if (!hit && isRead && (await sweepPage(page)))
|
|
187
|
+
hit = await resolveChain(page, chain, { ...policy, waitMs: 0 });
|
|
188
|
+
if (!hit) {
|
|
189
|
+
// A wait for an element to be HIDDEN is satisfied by its absence: the
|
|
190
|
+
// step's own success condition is "nothing matches", so a dead chain
|
|
191
|
+
// here is the recorded outcome, not drift. fwrd42's 06-report waited
|
|
192
|
+
// for a deleted part's text to go and filed a drift ticket on every
|
|
193
|
+
// run, which no repair could clear because nothing was wrong.
|
|
194
|
+
if (waitsForAbsence(step, args)) {
|
|
195
|
+
res.lines.push(`${head} → condition met: ${String(args.state)} (nothing matched)`);
|
|
196
|
+
return 'ran';
|
|
197
|
+
}
|
|
198
|
+
resolveError = `no element matched any known locator for ${key}${chain.length ? ` (tried ${chain.length}: ${chain.slice(0, 3).map(candidateExpr).join(', ')}${chain.length > 3 ? ', …' : ''})` : ' (none recorded)'}`;
|
|
199
|
+
res.misses.push({ step: tag, key, primary: chain[0] ? candidateExpr(chain[0]) : '(none recorded)', used: null });
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
resolved[key] = hit.locator;
|
|
203
|
+
if (structural(hit.candidate))
|
|
204
|
+
positionalResolution = true;
|
|
205
|
+
// Evidence ONLY from a pass whose winner names something. When a
|
|
206
|
+
// structural path won, that is precisely the resolution we distrust —
|
|
207
|
+
// it may have acted on whatever sorted into that position — and banking
|
|
208
|
+
// it would retire the anchors that missed and confirm the path that hit,
|
|
209
|
+
// turning one bad resolution into a permanent one. fwrd26l did exactly
|
|
210
|
+
// that: its 8/8 zero-model replay had retired two identity anchors in
|
|
211
|
+
// favour of `tr:nth-of-type(1)`.
|
|
212
|
+
if (hit.missed.length && !structural(hit.candidate)) {
|
|
213
|
+
res.candidateEvidence.push({ step: tag, key, hit: hit.index, missed: hit.missed });
|
|
214
|
+
}
|
|
215
|
+
sink?.push(`${key}=${candidateExpr(hit.candidate)}`);
|
|
216
|
+
// Record what this action put on the page (see `interacted`). Only for
|
|
217
|
+
// non-read steps: a read observes, it does not set. The accessible name
|
|
218
|
+
// of a clicked option ("Last 6 hours") is the value it selects.
|
|
219
|
+
// Only a step that can SET or SELECT something counts. A scroll to the
|
|
220
|
+
// heading "Latency by endpoint" set nothing, but its target's name
|
|
221
|
+
// landed here and the later read of that heading was discounted as an
|
|
222
|
+
// echo — fwgr23 published two of three panel titles on every replay
|
|
223
|
+
// and objective 1 failed each time.
|
|
224
|
+
if (!isRead && !OBSERVATION_TOOLS.has(step.tool)) {
|
|
225
|
+
for (const cand of chain) {
|
|
226
|
+
const named = cand.name ?? cand.label;
|
|
227
|
+
if (named && named.length >= MIN_ECHO_LEN)
|
|
228
|
+
interacted.add(looseKey(named));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (hit.index > 0) {
|
|
232
|
+
res.fallthroughs++;
|
|
233
|
+
res.misses.push({ step: tag, key, primary: candidateExpr(chain[0]), used: candidateExpr(hit.candidate), usedIndex: hit.index });
|
|
234
|
+
res.warnings.push(`step ${tag}: primary locator did not resolve; used fallback #${hit.index + 1} ${candidateExpr(hit.candidate)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// A typed/filled value is likewise something the skill put on the page.
|
|
238
|
+
const setsSomething = !isRead && !OBSERVATION_TOOLS.has(step.tool);
|
|
239
|
+
if (setsSomething && typeof args.value === 'string' && args.value.length >= MIN_ECHO_LEN)
|
|
240
|
+
interacted.add(looseKey(args.value));
|
|
241
|
+
if (setsSomething && typeof args.text === 'string' && args.text.length >= MIN_ECHO_LEN)
|
|
242
|
+
interacted.add(looseKey(args.text));
|
|
243
|
+
if (!resolveError)
|
|
244
|
+
absentDialog = null;
|
|
245
|
+
if (resolveError) {
|
|
246
|
+
if (isRead) {
|
|
247
|
+
res.warnings.push(`step ${tag}: skipped read — ${resolveError}`);
|
|
248
|
+
res.lines.push(`${head} → skipped (${resolveError})`);
|
|
249
|
+
return 'skipped';
|
|
250
|
+
}
|
|
251
|
+
if (absentDialog !== null) {
|
|
252
|
+
res.warnings.push(`step ${tag}: skipped — acts inside the dialog ${JSON.stringify(absentDialog)}, which did not open this time`);
|
|
253
|
+
res.lines.push(`${head} → skipped (dialog ${JSON.stringify(absentDialog)} did not open)`);
|
|
254
|
+
return 'skipped';
|
|
255
|
+
}
|
|
256
|
+
// Navigation by recorded destination (PLAN-replay-v2 "order of
|
|
257
|
+
// application", rung 3). A navigation step's recorded EVIDENCE includes
|
|
258
|
+
// where it landed; the clicked affordance (a recents list, a shortcut —
|
|
259
|
+
// anything session-local) may be gone on a fresh browser, but the
|
|
260
|
+
// destination is what the step was for. Two sub-rungs, because this is
|
|
261
|
+
// testing how the app works for a HUMAN: (a) another link on the page
|
|
262
|
+
// to the same destination — click that, exercising the app's own
|
|
263
|
+
// navigation; (b) only then, and only when the destination is fully
|
|
264
|
+
// concrete (params/derived filled, nothing volatile left), navigate
|
|
265
|
+
// there directly. Both are logged as fallthroughs so drift telemetry
|
|
266
|
+
// and post-session repair still see the miss.
|
|
267
|
+
const destPattern = step.expect?.urlPattern;
|
|
268
|
+
const isMove = Boolean(destPattern) && NAV_FALLBACK_TOOLS.has(step.tool) && !tag.includes('.') && !urlMatches(destPattern, page.url(), params);
|
|
269
|
+
if (isMove) {
|
|
270
|
+
const arrived = await navigateToDestination(page, destPattern, params, (tool, a, resolved) => opts.exec(tool, a, resolved, { skill: skill.id, step: failIndex }));
|
|
271
|
+
if (arrived) {
|
|
272
|
+
const miss = res.misses[res.misses.length - 1];
|
|
273
|
+
if (miss && miss.step === tag)
|
|
274
|
+
miss.used = arrived.used;
|
|
275
|
+
res.fallthroughs++;
|
|
276
|
+
res.warnings.push(`step ${tag}: ${resolveError}; ${arrived.note}`);
|
|
277
|
+
res.lines.push(`${head} → target gone; ${arrived.note}`);
|
|
278
|
+
return 'ran';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
res.failedAt = failIndex;
|
|
282
|
+
res.reason = resolveError;
|
|
283
|
+
res.lines.push(`${head} → FAILED: ${resolveError}`);
|
|
284
|
+
return 'stop';
|
|
285
|
+
}
|
|
286
|
+
let outcome;
|
|
287
|
+
// Dispatched, not completed. A step whose action fires and whose
|
|
288
|
+
// EXPECTATION then fails returns 'stop' without incrementing stepsRun, so
|
|
289
|
+
// stepsRun === 0 has never meant "the page was not touched" — and the
|
|
290
|
+
// caller reads it as exactly that before trying another candidate. A
|
|
291
|
+
// second candidate then clicks Create again.
|
|
292
|
+
if (!isRead)
|
|
293
|
+
res.acted = true;
|
|
294
|
+
const urlBefore = page.url();
|
|
295
|
+
// A click that opens a popup (menu, dialog, listbox) is a TOGGLE in most
|
|
296
|
+
// SPAs: the same click on an already-open popup closes it. When the
|
|
297
|
+
// recorded effect is already showing before the click, the click would
|
|
298
|
+
// undo the state the next step depends on — fwgr26's third click on
|
|
299
|
+
// "New" shut the menu its "New dashboard" link lived in, on every
|
|
300
|
+
// replay. Skipped as already in effect. Only popup lines count: a
|
|
301
|
+
// re-usable effect (another row of textboxes) must still be produced.
|
|
302
|
+
const opener = openerLines(step, params);
|
|
303
|
+
if (opener.length && (await presentOnPage(page, opener))) {
|
|
304
|
+
res.warnings.push(`step ${tag}: the recorded effect (${clip(opener[0], 60)}) is already showing — a click would toggle it away; skipped as already in effect`);
|
|
305
|
+
res.lines.push(`${head} → skipped (already in effect)`);
|
|
306
|
+
return 'skipped';
|
|
307
|
+
}
|
|
308
|
+
// A click that produced NO observable change at all (no diff, no url
|
|
309
|
+
// change, no alert) while the recording shows one most likely landed
|
|
310
|
+
// during a repaint — React detaches and re-mounts controls between
|
|
311
|
+
// frames, and Playwright's click can hit the old node. One retry after
|
|
312
|
+
// the DOM settles; a click that changed anything is never repeated.
|
|
313
|
+
for (let attempt = 0;; attempt++) {
|
|
314
|
+
try {
|
|
315
|
+
outcome = await opts.exec(step.tool, args, resolved, { skill: skill.id, step: failIndex });
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const message = (err instanceof Error ? err.message : String(err)).split('\nCall log:')[0];
|
|
319
|
+
if (isRead) {
|
|
320
|
+
res.warnings.push(`step ${tag}: read errored — ${clip(message, 120)}`);
|
|
321
|
+
res.lines.push(`${head} → skipped (${clip(message, 120)})`);
|
|
322
|
+
return 'skipped';
|
|
323
|
+
}
|
|
324
|
+
res.failedAt = failIndex;
|
|
325
|
+
res.reason = `${step.tool} failed: ${clip(message, 300)}`;
|
|
326
|
+
res.lines.push(`${head} → FAILED: ${clip(message, 300)}`);
|
|
327
|
+
return 'stop';
|
|
328
|
+
}
|
|
329
|
+
// A navigation renders a route skeleton first; let it hydrate before
|
|
330
|
+
// the effect gates look for the recorded content.
|
|
331
|
+
if (page.url() !== urlBefore)
|
|
332
|
+
await settleDom(page);
|
|
333
|
+
// Bind values this step just minted (derived params) from the live url,
|
|
334
|
+
// BEFORE the expectation check: the minting step's own expectation refers
|
|
335
|
+
// to the value it produced, so it must compare against the replay's own.
|
|
336
|
+
if (skill.derived) {
|
|
337
|
+
for (const [name, d] of Object.entries(skill.derived)) {
|
|
338
|
+
if (d.step !== failIndex)
|
|
339
|
+
continue;
|
|
340
|
+
const v = urlPart(page.url(), d.at);
|
|
341
|
+
if (v !== undefined) {
|
|
342
|
+
params[name] = v;
|
|
343
|
+
res.derivedValues[name] = v;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// A step declared record-minting has now run: read THIS run's identifier
|
|
348
|
+
// off the live url and keep it. If the replay later stops, recovery is
|
|
349
|
+
// told the record already exists and what it is called, instead of being
|
|
350
|
+
// told only how many steps ran and left to infer the rest — which is how
|
|
351
|
+
// fwod13 came to create a second and third order.
|
|
352
|
+
if (step.mints) {
|
|
353
|
+
// Only a part the step CHANGED: a rejected click leaves the url as it
|
|
354
|
+
// was, and "new" from /tickets/new is not a record this run created.
|
|
355
|
+
const made = urlPart(page.url(), step.mints.at);
|
|
356
|
+
if (made && made !== urlPart(urlBefore, step.mints.at) && !res.created.includes(made))
|
|
357
|
+
res.created.push(made);
|
|
358
|
+
}
|
|
359
|
+
// Effect gates: did the step leave the page as the recording said it
|
|
360
|
+
// would? Each gate's warnings and staged generalisations always apply; a
|
|
361
|
+
// stop ends the replay here, with what ran already in `res`.
|
|
362
|
+
let stop = null;
|
|
363
|
+
const warnings = [];
|
|
364
|
+
for (const gate of STEP_GATES) {
|
|
365
|
+
const verdict = await gate({ page, step, tag, failIndex, args, params, outcome, isRead, positionalResolution });
|
|
366
|
+
if (!verdict)
|
|
367
|
+
continue;
|
|
368
|
+
if (verdict.warnings)
|
|
369
|
+
warnings.push(...verdict.warnings);
|
|
370
|
+
if (verdict.generalise)
|
|
371
|
+
res.generalisations.push(verdict.generalise);
|
|
372
|
+
if (verdict.absentDialog !== undefined)
|
|
373
|
+
absentDialog = verdict.absentDialog;
|
|
374
|
+
if (verdict.stop) {
|
|
375
|
+
stop = verdict;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!stop) {
|
|
380
|
+
res.warnings.push(...warnings);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
const noEffect = step.tool === 'click' && !!outcome.diff && !outcome.diff.added.length && !outcome.diff.alerts.length && page.url() === urlBefore;
|
|
384
|
+
if (stop.retryable && noEffect && attempt === 0) {
|
|
385
|
+
res.warnings.push(`step ${tag}: the click changed nothing on the page — retried once after the DOM settled`);
|
|
386
|
+
await settleDom(page);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
res.warnings.push(...warnings);
|
|
390
|
+
// The step took the tab off the app (an error page, another origin):
|
|
391
|
+
// whoever picks up from here — recovery, the next segment — needs the
|
|
392
|
+
// app, not the wreck. Go back to where the step started. rpgr13-r2's
|
|
393
|
+
// recovery spent its whole budget on chrome-error://chromewebdata/.
|
|
394
|
+
const landed = page.url();
|
|
395
|
+
if (landed !== urlBefore && (/^chrome-error:|^about:neterror/.test(landed) || (originOf(landed) ?? '') !== (originOf(urlBefore) ?? ''))) {
|
|
396
|
+
try {
|
|
397
|
+
await page.goto(urlBefore, { waitUntil: 'domcontentloaded' });
|
|
398
|
+
warnings.push(`step ${tag}: the browser was returned to ${urlPattern(urlBefore)} from ${urlPattern(landed)}`);
|
|
399
|
+
res.warnings.push(warnings[warnings.length - 1]);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// the tab is truly gone; the stop below says where it ended
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
res.failedAt = failIndex;
|
|
406
|
+
res.reason = stop.stop;
|
|
407
|
+
res.lines.push(`${head} → ran, but ${stop.stop}`);
|
|
408
|
+
return 'stop';
|
|
409
|
+
}
|
|
410
|
+
if (isRead) {
|
|
411
|
+
const key = step.label ?? `read${tag}`;
|
|
412
|
+
const value = parseRead(outcome.result);
|
|
413
|
+
res.values[key] = value;
|
|
414
|
+
// An echo read: this value is only what the skill itself set or chose,
|
|
415
|
+
// so it confirms the control's display, not that the app persisted it.
|
|
416
|
+
if (value && value.length >= MIN_ECHO_LEN && interacted.has(looseKey(value))) {
|
|
417
|
+
res.echoedValues.push(key);
|
|
418
|
+
res.warnings.push(`step ${tag}: read '${key}' returned a value the skill itself set/selected ('${clip(value, 60)}') — confirms the control, not persistence; dropped from the report's confident values`);
|
|
419
|
+
}
|
|
420
|
+
res.lines.push(`${head} → ${key} = ${clip(outcome.result, MAX_LINE)}`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
res.lines.push(`${head} → ${clip(outcome.result.split('\n')[0], MAX_LINE)}`);
|
|
424
|
+
}
|
|
425
|
+
return 'ran';
|
|
426
|
+
};
|
|
427
|
+
// A folded loop: repeat the body while its guard locator still matches an
|
|
428
|
+
// element, capped at `max`. Counts as ONE top-level step no matter how many
|
|
429
|
+
// times the body runs, so the ok check below stays about top-level progress.
|
|
430
|
+
const runLoop = async (step, n) => {
|
|
431
|
+
const body = step.body ?? [];
|
|
432
|
+
const guard = step.while ?? body[0]?.locators.target ?? [];
|
|
433
|
+
const max = step.max ?? 20;
|
|
434
|
+
const before = res.stepsRun;
|
|
435
|
+
let iter = 0;
|
|
436
|
+
// Progress guard: a folded loop exists because the recording acted on one
|
|
437
|
+
// RECORD after another, so every iteration must either shrink the guard's
|
|
438
|
+
// match count (a delete loop) or resolve different elements (a per-record
|
|
439
|
+
// edit). When neither happens the per-record locators have stopped
|
|
440
|
+
// distinguishing records — fwrd4l-n3's "edit each part's supplier" loop
|
|
441
|
+
// missed its ambiguous role locator, fell through to a positional path
|
|
442
|
+
// pinned to ROW 1, and edited the same part seven times while the replay
|
|
443
|
+
// counted it as progress. Same targets + no shrink = fail to recovery.
|
|
444
|
+
let prevSig = null;
|
|
445
|
+
let prevRemaining = Number.POSITIVE_INFINITY;
|
|
446
|
+
// Cursor over unprocessed records: a delete loop shrinks the guard count,
|
|
447
|
+
// so match 0 is always the next record; an edit-in-place loop leaves the
|
|
448
|
+
// count alone, so the next record is the next match index. The cursor
|
|
449
|
+
// advances exactly when the previous iteration did not consume its record.
|
|
450
|
+
let cursor = 0;
|
|
451
|
+
while (iter < max) {
|
|
452
|
+
// A loop cut short by the budget is NOT a finished loop: breaking out
|
|
453
|
+
// used to count it as 'ran', and a part-cleared list became a success.
|
|
454
|
+
if (opts.signal?.aborted) {
|
|
455
|
+
res.stepsRun = before;
|
|
456
|
+
res.failedAt = n;
|
|
457
|
+
res.reason = `instruction budget exhausted after ${iter} loop iteration(s), before the loop finished`;
|
|
458
|
+
res.lines.push(`${n}. loop → stopped after ×${iter}: ${res.reason}`);
|
|
459
|
+
return 'stop';
|
|
460
|
+
}
|
|
461
|
+
await settleDom(page);
|
|
462
|
+
const chain = fillParamsDeep(guard, params);
|
|
463
|
+
// No waitMs: this asks whether the list still has rows, and a null
|
|
464
|
+
// return is the loop's normal exit, not a failure to find something.
|
|
465
|
+
const hit = await resolveChain(page, chain, { allowMultiple: true });
|
|
466
|
+
const remaining = hit ? await hit.locator.count().catch(() => 0) : 0;
|
|
467
|
+
if (!remaining || cursor >= remaining)
|
|
468
|
+
break;
|
|
469
|
+
const sig = [];
|
|
470
|
+
for (const [k, bstep] of body.entries()) {
|
|
471
|
+
const st = await runOneStep(bstep, `${n}.${iter + 1}.${k + 1}`, n, sig, cursor);
|
|
472
|
+
if (st === 'stop') {
|
|
473
|
+
res.stepsRun = before;
|
|
474
|
+
return 'stop';
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const joined = sig.join('; ');
|
|
478
|
+
if (joined && joined === prevSig && remaining >= prevRemaining) {
|
|
479
|
+
res.stepsRun = before;
|
|
480
|
+
res.failedAt = n;
|
|
481
|
+
res.reason =
|
|
482
|
+
`loop iteration ${iter + 1} resolved the same element(s) as the previous one with the guard count unchanged (${remaining}) — ` +
|
|
483
|
+
`the recorded per-record locators no longer distinguish records, so the loop was re-acting on one record`;
|
|
484
|
+
res.lines.push(`${n}. loop → FAILED after ×${iter + 1}: ${res.reason}`);
|
|
485
|
+
return 'stop';
|
|
486
|
+
}
|
|
487
|
+
prevSig = joined;
|
|
488
|
+
// Did this iteration consume its record (guard shrank) or leave it in
|
|
489
|
+
// place (edit-in-place)? Advance the cursor only in the second case.
|
|
490
|
+
// Settle first: a delete's row removal landing late would otherwise
|
|
491
|
+
// advance the cursor and make the next iteration skip a record.
|
|
492
|
+
await settleDom(page);
|
|
493
|
+
// Recount with the SAME candidate that produced `remaining`. Re-walking
|
|
494
|
+
// the chain can answer from a different rung — the recorded guard
|
|
495
|
+
// `[data-testid="del-1"]` matches 1 before its row goes and 0 after, but
|
|
496
|
+
// the chain then falls through to a generic `button "Remove"` matching
|
|
497
|
+
// the OTHER rows, so a shrink read as growth, advanced the cursor, and
|
|
498
|
+
// left the last row undeleted while the loop reported success.
|
|
499
|
+
const count = async () => makeLocator(page, hit.candidate).count().catch(() => 0);
|
|
500
|
+
// Poll for the shrink rather than reading the count once: a row that
|
|
501
|
+
// leaves the DOM a beat after the click would otherwise look like an
|
|
502
|
+
// edit-in-place, advance the cursor, and make a delete loop skip a
|
|
503
|
+
// record — and then stop early on `cursor >= remaining`, leaving the
|
|
504
|
+
// list part-cleared while reporting success.
|
|
505
|
+
let after = await count();
|
|
506
|
+
for (let waited = 0; after >= remaining && waited < LOOP_SHRINK_WAIT_MS; waited += LOOP_SHRINK_POLL_MS) {
|
|
507
|
+
await page.waitForTimeout(LOOP_SHRINK_POLL_MS);
|
|
508
|
+
after = await count();
|
|
509
|
+
}
|
|
510
|
+
if (after >= remaining)
|
|
511
|
+
cursor++;
|
|
512
|
+
prevRemaining = remaining;
|
|
513
|
+
iter++;
|
|
514
|
+
}
|
|
515
|
+
res.stepsRun = before + 1;
|
|
516
|
+
res.lines.push(`${n}. loop ×${iter} (while ${chain0Desc(guard)} matches)`);
|
|
517
|
+
return 'ran';
|
|
518
|
+
};
|
|
519
|
+
for (const [i, step] of skill.steps.entries()) {
|
|
520
|
+
const n = i + 1;
|
|
521
|
+
if (opts.signal?.aborted) {
|
|
522
|
+
res.failedAt = n;
|
|
523
|
+
res.reason = 'instruction budget exhausted before this step';
|
|
524
|
+
res.lines.push(`${n}. ${step.tool} — not run (budget exhausted)`);
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
if (step.tool === 'loop') {
|
|
528
|
+
if ((await runLoop(step, n)) === 'stop')
|
|
529
|
+
break;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const status = await runOneStep(step, String(n), n);
|
|
533
|
+
if (status === 'stop')
|
|
534
|
+
break;
|
|
535
|
+
res.stepsRun++;
|
|
536
|
+
// The identity check a self-navigating procedure deferred: its goto has
|
|
537
|
+
// now run, so ask whether it landed on THIS run's record before doing any
|
|
538
|
+
// work on it. Refusing here costs a recovery; not refusing costs the work
|
|
539
|
+
// being done to the wrong record and reported as success.
|
|
540
|
+
if (navigatesItself && n === 1 && skill.preconditions.requireText?.length && !(await checkIdentity())) {
|
|
541
|
+
// NOT `refused`. Refused means "nothing ran, free to try the next
|
|
542
|
+
// candidate" — and the goto has already moved the browser, so trying
|
|
543
|
+
// another candidate would run it from a page nobody expects. This is a
|
|
544
|
+
// partial stop: what ran, ran, and the caller hands it to recovery
|
|
545
|
+
// rather than restarting. `wrongRecord` still tells the flow runner to
|
|
546
|
+
// put the browser back on the flow's start url first.
|
|
547
|
+
res.refused = false;
|
|
548
|
+
res.failedAt = n;
|
|
549
|
+
res.url = page.url(); // the goto moved the browser; the caller repositions from here
|
|
550
|
+
return res;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
res.ok = res.stepsRun === skill.steps.length && res.failedAt === undefined;
|
|
554
|
+
res.url = page.url();
|
|
555
|
+
return res;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Hard expectation: where the step was supposed to leave the browser. A
|
|
559
|
+
* same-shape url whose literal segment(s) disagree is treated as volatile
|
|
560
|
+
* (mechanism 2): warn, stage the generalisation, continue.
|
|
561
|
+
*/
|
|
562
|
+
const expectedUrl = async ({ step, page, params, tag, failIndex }) => {
|
|
563
|
+
const pattern = step.expect?.urlPattern;
|
|
564
|
+
if (!pattern || urlMatches(pattern, page.url(), params))
|
|
565
|
+
return null;
|
|
566
|
+
// The recorded url may still be on its way: an SPA sign-in answers the
|
|
567
|
+
// click, then routes to the landing page a moment later. fwat2's sign-in
|
|
568
|
+
// step was judged at "/" on every replay and sent to recovery, whose
|
|
569
|
+
// report then lacked the landing-page value every later step referred to.
|
|
570
|
+
// Give a navigation in flight the resolve window before judging.
|
|
571
|
+
for (let waited = 0; waited < resolveWaitMs(); waited += RESOLVE_POLL_MS) {
|
|
572
|
+
await new Promise((r) => setTimeout(r, RESOLVE_POLL_MS));
|
|
573
|
+
if (urlMatches(pattern, page.url(), params))
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
const soft = softUrlMatch(pattern, page.url(), params);
|
|
577
|
+
if (!soft)
|
|
578
|
+
return { stop: `after step ${tag} expected url ${fillParams(pattern, params)} but browser is at ${urlPattern(page.url())}` };
|
|
579
|
+
return {
|
|
580
|
+
warnings: [`step ${tag}: url segment(s) differ from recorded (${soft.diffs.map((d) => `${d.expected}→${d.actual}`).join(', ')}) — treated as volatile`],
|
|
581
|
+
generalise: { kind: 'expect', step: failIndex, pattern: soft.generalised },
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* An alert the recording never saw is the app talking back — usually a
|
|
586
|
+
* rejection ("Ticket is not ready…") that leaves the page superficially
|
|
587
|
+
* intact. fwrd4l-n3 clicked into exactly that: the step counted as run, the
|
|
588
|
+
* synthesized report declared the recorded outcome, and only external
|
|
589
|
+
* verification caught that the ticket never reached Ready. So a
|
|
590
|
+
* state-changing step that provokes an UNRECORDED alert fails hard, while a
|
|
591
|
+
* recorded-but-missing alert stays soft (expectedAlert — toasts are volatile).
|
|
592
|
+
*/
|
|
593
|
+
const unrecordedAlert = ({ outcome, isRead, step, tag }) => {
|
|
594
|
+
if (!outcome.diff?.alerts.length || isRead || step.expect?.alertContains)
|
|
595
|
+
return null;
|
|
596
|
+
return { stop: `step ${tag} raised an alert the recording never saw: ${clip(outcome.diff.alerts.join(' | '), 200)}` };
|
|
597
|
+
};
|
|
598
|
+
const expectedAlert = ({ outcome, step, params, tag }) => {
|
|
599
|
+
if (!outcome.diff || !step.expect?.alertContains)
|
|
600
|
+
return null;
|
|
601
|
+
const want = fillParams(step.expect.alertContains, params);
|
|
602
|
+
return outcome.diff.alerts.some((a) => a.includes(want)) ? null : { warnings: [`step ${tag}: expected alert containing ${JSON.stringify(want)}`] };
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* Page-change expectations. Lines that carry a parameter are HARD: they are
|
|
606
|
+
* what distinguishes this run from the recorded one (the new title appearing
|
|
607
|
+
* as a heading), so their absence means the step acted on the wrong thing
|
|
608
|
+
* even though it "worked". Everything else stays soft until data says it is
|
|
609
|
+
* reliable — but a plain change absent from the diff AND the live page means
|
|
610
|
+
* the action did not have its recorded effect, and failing there is what
|
|
611
|
+
* turns a rejected state change into a clean recovery instead of a false
|
|
612
|
+
* success (the fwrd4l-n3 Ready click).
|
|
613
|
+
*/
|
|
614
|
+
const expectedChanges = async ({ outcome, step, params, tag, args, page, positionalResolution }) => {
|
|
615
|
+
if (!outcome.diff || !step.expect?.addedContains?.length)
|
|
616
|
+
return null;
|
|
617
|
+
const warnings = [];
|
|
618
|
+
// A line carrying a {{vN}} slot is HARD (below). A {{dN}} derived marker
|
|
619
|
+
// is filled like any other param but stays soft — the app minted it.
|
|
620
|
+
const isParam = (l) => /\{\{v\d+\}\}/.test(l);
|
|
621
|
+
// maskVolatile at replay too, so a store compiled before masking existed
|
|
622
|
+
// (every skill recorded up to set 24) stops failing on the recording's clock.
|
|
623
|
+
// Transient lines (spinners, progress bars) are dropped here too, so a
|
|
624
|
+
// store compiled before TRANSIENT_LINE existed stops failing on them.
|
|
625
|
+
const lines = step.expect.addedContains.filter((l) => !TRANSIENT_LINE.test(l));
|
|
626
|
+
if (!lines.length)
|
|
627
|
+
return null;
|
|
628
|
+
let parameterised = lines.filter(isParam).map((l) => fillParams(maskMinted(maskVolatile(l)), params));
|
|
629
|
+
const plain = lines.filter((l) => !isParam(l)).map((l) => fillParams(maskMinted(maskVolatile(l)), params));
|
|
630
|
+
// A positionally-resolved fill must prove itself with a CONSEQUENTIAL
|
|
631
|
+
// change: its own echo in a same-role element is what the wrong element
|
|
632
|
+
// produces too (see consequentialExpectations). When the echo is all the
|
|
633
|
+
// recording has, the old gate stands and we say so.
|
|
634
|
+
if (positionalResolution && parameterised.length && typeof args.value === 'string' && args.value) {
|
|
635
|
+
const value = args.value;
|
|
636
|
+
const consequential = parameterised.filter((l) => !isEchoLine(l, value));
|
|
637
|
+
if (consequential.length)
|
|
638
|
+
parameterised = consequential;
|
|
639
|
+
else
|
|
640
|
+
warnings.push(`step ${tag}: resolved positionally and its only recorded effect is the fill's own echo — the effect gate cannot tell right element from wrong here`);
|
|
641
|
+
}
|
|
642
|
+
if (parameterised.length && !lineShows(outcome.diff.added, parameterised) && !(await presentOnPage(page, parameterised))) {
|
|
643
|
+
return { warnings, stop: `after step ${tag} the page did not show ${parameterised.map((w) => JSON.stringify(w)).join(' / ')} as it did when recorded — the step ran but probably acted on the wrong element` };
|
|
644
|
+
}
|
|
645
|
+
if (plain.length && !lineShows(outcome.diff.added, plain)) {
|
|
646
|
+
// None of the recorded effects in the step diff — check the live page
|
|
647
|
+
// before judging (a change can land outside the diff window).
|
|
648
|
+
if (!(await presentOnPage(page, plain))) {
|
|
649
|
+
// The recorded effect was a dialog opening. A dialog is conditional
|
|
650
|
+
// UI: fwgr24's create step recorded "Exit edit" → "Discard changes to
|
|
651
|
+
// dashboard?" because the RECORDING had unsaved edits at that moment;
|
|
652
|
+
// a replay whose earlier steps saved cleanly has none, no dialog
|
|
653
|
+
// opens, and that is the app working — not the step failing. The
|
|
654
|
+
// steps that would have acted inside the dialog are skipped instead.
|
|
655
|
+
const dialog = plain.map((l) => /^-\s*dialog\s+"([^"]*)"/.exec(l)?.[1]).find((n) => n !== undefined);
|
|
656
|
+
if (dialog !== undefined) {
|
|
657
|
+
warnings.push(`step ${tag}: the recorded dialog ${JSON.stringify(dialog)} did not open — conditional UI, treated as absent; steps inside it will be skipped`);
|
|
658
|
+
return { warnings, absentDialog: dialog };
|
|
659
|
+
}
|
|
660
|
+
return { warnings, retryable: true, stop: `after step ${tag} none of the ${plain.length} recorded page change(s) appeared (e.g. ${JSON.stringify(plain[0])}) — the step ran but did not have its recorded effect` };
|
|
661
|
+
}
|
|
662
|
+
warnings.push(`step ${tag}: none of the ${plain.length} expected page change(s) appeared in the step diff (found on the page instead)`);
|
|
663
|
+
}
|
|
664
|
+
return warnings.length ? { warnings } : null;
|
|
665
|
+
};
|
|
666
|
+
/** The effect gates a step passes through after its action, in order. */
|
|
667
|
+
/**
|
|
668
|
+
* The tab is on a browser error page (a crashed renderer, a navigation the
|
|
669
|
+
* network refused): nothing recorded can hold there, and every later step
|
|
670
|
+
* would resolve nothing while the replay pressed on. fwgr26-n2 ran eleven
|
|
671
|
+
* more steps on chrome-error://chromewebdata/ before the next segment
|
|
672
|
+
* refused with the unreadable "browser is at null/".
|
|
673
|
+
*/
|
|
674
|
+
const errorPage = ({ page, tag }) => {
|
|
675
|
+
const url = page.url();
|
|
676
|
+
return /^chrome-error:|^about:neterror/.test(url) ? { stop: `after step ${tag} the browser is on an error page (${url}) — the tab crashed or a navigation failed` } : null;
|
|
677
|
+
};
|
|
678
|
+
const STEP_GATES = [errorPage, expectedUrl, unrecordedAlert, expectedAlert, expectedChanges];
|
|
679
|
+
/** Short human label for a loop's guard locator. */
|
|
680
|
+
function chain0Desc(chain) {
|
|
681
|
+
return chain[0] ? candidateExpr(chain[0]) : 'element';
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Structural: a path through the document, or an index into a set of matches.
|
|
685
|
+
*
|
|
686
|
+
* Note this is NOT "kind === css". An agent-chosen `#modal-save` is a handle
|
|
687
|
+
* — it names one control — while `#view > div > button:nth-of-type(2)` is a
|
|
688
|
+
* route to wherever that shape currently sits. Demoting the first alongside
|
|
689
|
+
* the second would push a deliberate selector below a role guess.
|
|
690
|
+
*/
|
|
691
|
+
export function structural(c) {
|
|
692
|
+
if (c.nth !== undefined)
|
|
693
|
+
return true;
|
|
694
|
+
if (c.kind === 'point')
|
|
695
|
+
return true; // where it was, not what it is
|
|
696
|
+
if (c.kind !== 'css')
|
|
697
|
+
return false;
|
|
698
|
+
return /[>+~]|:nth-/.test(c.selector);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* The expectation lines that could tell a right-element fill from a
|
|
702
|
+
* wrong-element one. A recorded added-line that merely restates the fill in a
|
|
703
|
+
* same-role element — `textbox "": {{v4}}` — is an ECHO: the WRONG textbox
|
|
704
|
+
* produces it too, so it is no evidence at all. fwgr17-n3's 03-open passed
|
|
705
|
+
* its effect gate on exactly that line after a positional fallback took the
|
|
706
|
+
* step. When consequential lines exist (the heading that renders the typed
|
|
707
|
+
* title, the menu button named after it), only those count; when the echo is
|
|
708
|
+
* all the recording has, it is returned unchanged — a lone search-box fill
|
|
709
|
+
* legitimately shows nothing else, and the caller warns instead.
|
|
710
|
+
*/
|
|
711
|
+
export function isEchoLine(line, filledValue) {
|
|
712
|
+
const escaped = filledValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
713
|
+
return new RegExp(`^-?\\s*(textbox|searchbox|spinbutton|combobox)\\b[^:]*:\\s*${escaped}\\s*$`).test(line.trim());
|
|
714
|
+
}
|
|
715
|
+
export function consequentialExpectations(lines, filledValue) {
|
|
716
|
+
if (!filledValue)
|
|
717
|
+
return lines;
|
|
718
|
+
const rest = lines.filter((l) => !isEchoLine(l, filledValue));
|
|
719
|
+
return rest.length ? rest : lines;
|
|
720
|
+
}
|
|
721
|
+
export function specOf(chain) {
|
|
722
|
+
return {
|
|
723
|
+
identity: chain.filter((c) => c.kind === 'scoped'),
|
|
724
|
+
handles: chain.filter((c) => c.kind !== 'scoped' && !structural(c)),
|
|
725
|
+
// Paths, then where it was: a point is the last resort behind every path.
|
|
726
|
+
path: [...chain.filter((c) => c.kind !== 'scoped' && c.kind !== 'point' && structural(c)), ...chain.filter((c) => c.kind === 'point')],
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
export async function resolveChain(page, chain, policy = {}) {
|
|
730
|
+
const { rawTarget = '', allowMultiple = false, ambiguousNth, requireIdentity = [], waitMs = 0, stayOnOrigin } = policy;
|
|
731
|
+
/** Does the resolved element sit inside a link that leaves the recorded origin? */
|
|
732
|
+
const leavesOrigin = async (locator) => {
|
|
733
|
+
if (!stayOnOrigin)
|
|
734
|
+
return false;
|
|
735
|
+
try {
|
|
736
|
+
return await locator.first().evaluate((el, origin) => {
|
|
737
|
+
const a = el.closest('a[href]');
|
|
738
|
+
if (!a)
|
|
739
|
+
return false;
|
|
740
|
+
try {
|
|
741
|
+
const target = new URL(a.href, location.href);
|
|
742
|
+
// file:// pages have an opaque origin; a link there is "home" when
|
|
743
|
+
// it stays on the same scheme.
|
|
744
|
+
if (target.origin === 'null' || location.origin === 'null')
|
|
745
|
+
return target.protocol !== location.protocol;
|
|
746
|
+
return target.origin !== origin;
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
}, stayOnOrigin);
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
const candidates = chain.length || !rawTarget || isRefTarget(rawTarget) ? chain : [{ kind: 'css', selector: rawTarget }];
|
|
758
|
+
// Identity, then handles, then paths — each keeping its recorded order, and
|
|
759
|
+
// each carrying its index in the STORED chain so drift still reports which
|
|
760
|
+
// recorded candidate actually took the step.
|
|
761
|
+
const spec = specOf(candidates);
|
|
762
|
+
// Demonstrated volatile last, whatever kind it is. Evidence outranks the
|
|
763
|
+
// identity/handle/path ordering because that ordering is a prior about what
|
|
764
|
+
// a candidate IS, and this is a measurement of whether it WORKS.
|
|
765
|
+
const byEvidence = (list) => [...list].sort((a, b) => Number(retired(a)) - Number(retired(b)));
|
|
766
|
+
const ordered = [...byEvidence(spec.identity), ...byEvidence(spec.handles), ...byEvidence(spec.path)].map((candidate) => ({
|
|
767
|
+
candidate,
|
|
768
|
+
index: candidates.indexOf(candidate),
|
|
769
|
+
}));
|
|
770
|
+
/** Does this fallback still identify the record the primary named? */
|
|
771
|
+
const keepsIdentity = async (index, candidate, locator) => {
|
|
772
|
+
// The recorded primary is trusted — unless it is itself structural (an
|
|
773
|
+
// agent-typed positional selector at the head), which names no record.
|
|
774
|
+
if (!requireIdentity.length || (index === 0 && !structural(candidate)))
|
|
775
|
+
return true;
|
|
776
|
+
const expr = JSON.stringify(candidate);
|
|
777
|
+
const wanted = requireIdentity.filter((v) => !expr.includes(v));
|
|
778
|
+
if (!wanted.length)
|
|
779
|
+
return true;
|
|
780
|
+
let text;
|
|
781
|
+
try {
|
|
782
|
+
text = ((await locator.first().textContent({ timeout: 1_000 })) ?? '').replace(/\s+/g, ' ');
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
return wanted.every((v) => text.toLowerCase().includes(v.toLowerCase()));
|
|
788
|
+
};
|
|
789
|
+
/**
|
|
790
|
+
* One pass over the chain, best candidate first, reporting which candidates
|
|
791
|
+
* it REJECTED before the winner.
|
|
792
|
+
*
|
|
793
|
+
* Per pass, deliberately. A candidate that missed while the page was still
|
|
794
|
+
* painting and hits on the next poll is not volatile — it was early. Only
|
|
795
|
+
* the pass that actually resolved is evidence about the locators, because
|
|
796
|
+
* only then do we know the element was there to be found.
|
|
797
|
+
*/
|
|
798
|
+
// The recorded geometry, when the chain carries it: the yardstick a guess
|
|
799
|
+
// is measured against. A structural fallback that resolves far from where
|
|
800
|
+
// the recorded element sat is a different element — rpgr13's `div > … >
|
|
801
|
+
// button` took a header button for a control in the editor's side pane.
|
|
802
|
+
const recordedBox = candidates.find((c) => c.kind === 'point') ?? null;
|
|
803
|
+
const plausible = async (locator) => {
|
|
804
|
+
if (!recordedBox)
|
|
805
|
+
return true;
|
|
806
|
+
try {
|
|
807
|
+
const box = await locator.first().boundingBox();
|
|
808
|
+
if (!box)
|
|
809
|
+
return true; // nothing to measure — let the other guards judge
|
|
810
|
+
const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
|
|
811
|
+
const cx = box.x + box.width / 2 + scroll.x;
|
|
812
|
+
const cy = box.y + box.height / 2 + scroll.y;
|
|
813
|
+
const limit = Math.max(recordedBox.vw, recordedBox.vh) / 3;
|
|
814
|
+
return Math.hypot(cx - recordedBox.x, cy - recordedBox.y) <= limit;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const walk = async () => {
|
|
821
|
+
const missed = [];
|
|
822
|
+
for (const { index, candidate } of ordered) {
|
|
823
|
+
try {
|
|
824
|
+
// A point names a place; find what is there (of the recorded kind)
|
|
825
|
+
// before a locator can name it.
|
|
826
|
+
if (candidate.kind === 'point' && !(await markPoint(page, candidate))) {
|
|
827
|
+
missed.push(index);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const locator = makeLocator(page, candidate);
|
|
831
|
+
const count = await locator.count();
|
|
832
|
+
if (count === 1) {
|
|
833
|
+
if (!(await keepsIdentity(index, candidate, locator))) {
|
|
834
|
+
missed.push(index);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if ((index > 0 || structural(candidate)) && candidate.kind !== 'point' && !(await plausible(locator))) {
|
|
838
|
+
missed.push(index);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
// The recorded primary is trusted even when it is such a link — the
|
|
842
|
+
// recording clicked it (rpgr12-r2's sign-in skill had a stray click
|
|
843
|
+
// on Grafana's "Support" footer link, and refusing it cost a
|
|
844
|
+
// 19-turn recovery). Only a guess may not leave the origin.
|
|
845
|
+
if ((index > 0 || structural(candidate)) && (await leavesOrigin(locator))) {
|
|
846
|
+
missed.push(index);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
return { locator, index, candidate, missed };
|
|
850
|
+
}
|
|
851
|
+
if (count > 1) {
|
|
852
|
+
if (allowMultiple)
|
|
853
|
+
return { locator, index, candidate, missed };
|
|
854
|
+
if (ambiguousNth !== undefined && candidate.nth === undefined && ambiguousNth < count) {
|
|
855
|
+
const picked = locator.nth(ambiguousNth);
|
|
856
|
+
if (!(await keepsIdentity(index, candidate, picked))) {
|
|
857
|
+
missed.push(index);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
return { locator: picked, index, candidate: { ...candidate, nth: ambiguousNth }, missed };
|
|
861
|
+
}
|
|
862
|
+
if (candidate.nth === undefined && index === 0) {
|
|
863
|
+
missed.push(index); // was unique; ambiguity is drift, keep looking
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
missed.push(index);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
missed.push(index); // malformed selector or detached page — try the next
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
};
|
|
875
|
+
// Fast path first: on a page that is ready this returns immediately and the
|
|
876
|
+
// wait below never runs. Re-walking the WHOLE chain each poll (rather than
|
|
877
|
+
// waiting on the primary alone) keeps the preference order intact — the
|
|
878
|
+
// best candidate still wins the moment it appears — and the identity guard
|
|
879
|
+
// stops a positional fallback taking the turn while the anchor is pending.
|
|
880
|
+
//
|
|
881
|
+
// A structural (positional) hit is not taken on the spot when the chain
|
|
882
|
+
// also names the element: a path resolves instantly against whatever sits
|
|
883
|
+
// in that slot while the named control is still rendering. rpgr13 lost
|
|
884
|
+
// both replays that way — the panel editor's `toggle-viz-picker` test id
|
|
885
|
+
// was not there yet, `div > … > button` matched a header button that
|
|
886
|
+
// opens grafana.com, and the tab left the app. The guess is held until the
|
|
887
|
+
// names have had the whole window; it stands only when none of them came.
|
|
888
|
+
const named = ordered.some((o) => !structural(o.candidate));
|
|
889
|
+
const guess = (hit) => !!hit && named && structural(hit.candidate);
|
|
890
|
+
let held = null;
|
|
891
|
+
const first = await walk();
|
|
892
|
+
if (first && !guess(first))
|
|
893
|
+
return first;
|
|
894
|
+
held = first;
|
|
895
|
+
for (let waited = 0; waited < waitMs; waited += RESOLVE_POLL_MS) {
|
|
896
|
+
// A plain timer, not page.waitForTimeout: this path runs precisely when
|
|
897
|
+
// the page is unhappy, and a navigating or detached page makes its own
|
|
898
|
+
// clock throw.
|
|
899
|
+
await new Promise((r) => setTimeout(r, RESOLVE_POLL_MS));
|
|
900
|
+
const hit = await walk();
|
|
901
|
+
if (hit && !guess(hit))
|
|
902
|
+
return hit;
|
|
903
|
+
if (hit)
|
|
904
|
+
held = hit;
|
|
905
|
+
}
|
|
906
|
+
return held;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* The identity values the primary locator carried: known ({{known}}) slots
|
|
910
|
+
* whose value the recorded run used to NAME the target by its visible text.
|
|
911
|
+
* Only text-bearing locator kinds count — a slot inside a css selector or a
|
|
912
|
+
* testid is an address, not a name, and holding a fallback to it would break
|
|
913
|
+
* ordinary form fills whose fallbacks are structural by design.
|
|
914
|
+
*/
|
|
915
|
+
export function identityOfPrimary(chain, skill, params) {
|
|
916
|
+
// The WHOLE chain, not chain[0]. Identity is a property of the STEP — which
|
|
917
|
+
// record it acts on — not of whichever candidate happens to sit first.
|
|
918
|
+
//
|
|
919
|
+
// fwrd26l is why. The agent's raw target was an XPath,
|
|
920
|
+
// `//tr[contains(., '{{v5}}')]`, stored as `css` because the recorder does
|
|
921
|
+
// not parse selector strings. So the primary advertised no identity, the
|
|
922
|
+
// guard was disarmed, and `#ticket-rows > tr:nth-of-type(1)` took the step —
|
|
923
|
+
// while the scoped anchor sitting right behind it named the record perfectly
|
|
924
|
+
// well. Same shape as the `text="..."` case, different syntax; reading the
|
|
925
|
+
// chain instead of its head fixes both without parsing anything.
|
|
926
|
+
const named = chain
|
|
927
|
+
.flatMap((c) => {
|
|
928
|
+
const f = c;
|
|
929
|
+
return [f.name, f.text, f.label, f.hasText];
|
|
930
|
+
})
|
|
931
|
+
.filter((v) => typeof v === 'string');
|
|
932
|
+
if (!named.length)
|
|
933
|
+
return [];
|
|
934
|
+
const out = new Set();
|
|
935
|
+
for (const field of named) {
|
|
936
|
+
for (const m of field.matchAll(/\{\{(v\d+)\}\}/g)) {
|
|
937
|
+
if (!skill.params[m[1]]?.known)
|
|
938
|
+
continue;
|
|
939
|
+
const value = params[m[1]];
|
|
940
|
+
if (value && value.length >= 3)
|
|
941
|
+
out.add(value);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return [...out];
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* The rungs a navigation step falls through when its recorded link is gone,
|
|
948
|
+
* each testing how the app works for a HUMAN before the next is tried:
|
|
949
|
+
* (a) another link on the page to the same destination — click that,
|
|
950
|
+
* exercising the app's own navigation;
|
|
951
|
+
* (b) only then, and only when the destination is fully concrete
|
|
952
|
+
* (params/derived filled, nothing volatile left), navigate there
|
|
953
|
+
* directly — the last resort before model recovery.
|
|
954
|
+
* Returns what got the browser there, or null when neither rung did.
|
|
955
|
+
*/
|
|
956
|
+
async function navigateToDestination(page, destPattern, params, exec) {
|
|
957
|
+
// (a) Requires the matching anchors to agree on ONE destination —
|
|
958
|
+
// ambiguity (a wildcard pattern matching many records) skips the rung.
|
|
959
|
+
const link = await linkToDestination(page, destPattern, params);
|
|
960
|
+
if (link) {
|
|
961
|
+
try {
|
|
962
|
+
await exec('click', { target: link.selector }, { target: page.locator(link.selector).first() });
|
|
963
|
+
await settleDom(page);
|
|
964
|
+
if (urlMatches(destPattern, page.url(), params)) {
|
|
965
|
+
return { used: `click ${link.selector}`, note: `clicked another link to the recorded destination (${link.selector})` };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// that link did not work either — try the direct navigation
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// (b)
|
|
973
|
+
const dest = fillParams(destPattern, params);
|
|
974
|
+
const concrete = dest && !dest.includes('{{') && !/[/=#](:id|:var)(?=[/&#]|$)/.test(dest);
|
|
975
|
+
if (concrete && !urlMatches(destPattern, page.url(), params)) {
|
|
976
|
+
try {
|
|
977
|
+
await exec('goto', { url: dest }, {});
|
|
978
|
+
if (urlMatches(destPattern, page.url(), params)) {
|
|
979
|
+
return { used: `goto ${dest}`, note: `navigated to the step's recorded destination instead (${dest})` };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// destination unreachable — the caller reports the original miss
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* A visible anchor on the page whose destination matches the recorded
|
|
990
|
+
* pattern. Used by the navigation fallback's first rung: when the recorded
|
|
991
|
+
* link is gone, another route to the same place may exist (a sidebar entry, a
|
|
992
|
+
* search result, a breadcrumb). Returns null unless every matching anchor
|
|
993
|
+
* agrees on ONE destination — a wildcard-heavy pattern matching several
|
|
994
|
+
* records is ambiguity, not evidence.
|
|
995
|
+
*/
|
|
996
|
+
async function linkToDestination(page, pattern, params) {
|
|
997
|
+
let anchors;
|
|
998
|
+
try {
|
|
999
|
+
const raw = await page.evaluate(() => Array.from(document.querySelectorAll('a[href]'))
|
|
1000
|
+
.filter((a) => a.offsetParent !== null)
|
|
1001
|
+
.map((a) => ({ attr: a.getAttribute('href') ?? '', abs: a.href })));
|
|
1002
|
+
anchors = Array.isArray(raw) ? raw : [];
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
const hits = anchors.filter((a) => a.abs && urlMatches(pattern, a.abs, params));
|
|
1008
|
+
if (!hits.length || new Set(hits.map((h) => h.abs)).size !== 1)
|
|
1009
|
+
return null;
|
|
1010
|
+
return { selector: `a[href="${hits[0].attr.replace(/(["\\])/g, '\\$1')}"]`, href: hits[0].abs };
|
|
1011
|
+
}
|
|
1012
|
+
/** How long a loop iteration waits for its record to leave the guard's match set. */
|
|
1013
|
+
const LOOP_SHRINK_WAIT_MS = 1_000;
|
|
1014
|
+
const LOOP_SHRINK_POLL_MS = 100;
|
|
1015
|
+
/**
|
|
1016
|
+
* How long a step keeps re-trying its locator chain before calling the target
|
|
1017
|
+
* absent. Overridable so a test exercising a FALLBACK path need not sit
|
|
1018
|
+
* through the wait that precedes it.
|
|
1019
|
+
*/
|
|
1020
|
+
/** A wait_for whose recorded condition is that its target is NOT there. */
|
|
1021
|
+
export function waitsForAbsence(step, args) {
|
|
1022
|
+
if (step.tool !== 'wait_for')
|
|
1023
|
+
return false;
|
|
1024
|
+
return args.state === 'hidden' || (args.state === 'count' && Number(args.count) === 0);
|
|
1025
|
+
}
|
|
1026
|
+
function resolveWaitMs() {
|
|
1027
|
+
const raw = Number(process.env.SITELOOPER_RESOLVE_WAIT_MS);
|
|
1028
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 3_000;
|
|
1029
|
+
}
|
|
1030
|
+
const RESOLVE_POLL_MS = 100;
|
|
1031
|
+
/**
|
|
1032
|
+
* Scroll the page end to end so a virtualised or lazily rendered UI paints
|
|
1033
|
+
* everything it has, then let the DOM settle. Returns false when the page
|
|
1034
|
+
* cannot be scripted (gone, cross-origin frame), in which case the caller
|
|
1035
|
+
* simply does not retry.
|
|
1036
|
+
*/
|
|
1037
|
+
async function sweepPage(page) {
|
|
1038
|
+
try {
|
|
1039
|
+
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
|
1040
|
+
await settleDom(page);
|
|
1041
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
1042
|
+
await settleDom(page);
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const SETTLE_QUIET_MS = 250;
|
|
1050
|
+
const SETTLE_MAX_MS = 2_000;
|
|
1051
|
+
/**
|
|
1052
|
+
* How long a page gets to show it is busy before it is called quiet. The
|
|
1053
|
+
* quiet window used to be the floor too — 250ms per step even on a static
|
|
1054
|
+
* page, ~20s across an 80-step replay that was otherwise at the engine's
|
|
1055
|
+
* floor. Now the full quiet window is demanded only once a mutation shows.
|
|
1056
|
+
*/
|
|
1057
|
+
const SETTLE_PROBE_MS = 60;
|
|
1058
|
+
/** Resolve once no DOM mutation has happened for SETTLE_QUIET_MS, or after SETTLE_MAX_MS. */
|
|
1059
|
+
async function settleDom(page) {
|
|
1060
|
+
try {
|
|
1061
|
+
await page.evaluate(({ probe, quiet, max }) => new Promise((resolve) => {
|
|
1062
|
+
let timer = setTimeout(resolve, probe);
|
|
1063
|
+
const stop = setTimeout(() => {
|
|
1064
|
+
observer.disconnect();
|
|
1065
|
+
resolve();
|
|
1066
|
+
}, max);
|
|
1067
|
+
const observer = new MutationObserver(() => {
|
|
1068
|
+
clearTimeout(timer);
|
|
1069
|
+
timer = setTimeout(() => {
|
|
1070
|
+
observer.disconnect();
|
|
1071
|
+
clearTimeout(stop);
|
|
1072
|
+
resolve();
|
|
1073
|
+
}, quiet);
|
|
1074
|
+
});
|
|
1075
|
+
observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
1076
|
+
}), { probe: SETTLE_PROBE_MS, quiet: SETTLE_QUIET_MS, max: SETTLE_MAX_MS });
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
// navigating / detached — the locator resolution will report it
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/** A snapshot line that names a popup: the thing a toggle opens and closes. */
|
|
1083
|
+
export const OPENER_LINE = /^-?\s*(dialog|alertdialog|menu|menubar|listbox|tooltip)\b/;
|
|
1084
|
+
/**
|
|
1085
|
+
* The popup lines a click was recorded to open, with params filled — empty
|
|
1086
|
+
* for anything but a click whose plain (unparameterised) effects include a
|
|
1087
|
+
* popup. See runOneStep's already-in-effect skip.
|
|
1088
|
+
*/
|
|
1089
|
+
export function openerLines(step, params) {
|
|
1090
|
+
if (step.tool !== 'click' || !step.expect?.addedContains?.length)
|
|
1091
|
+
return [];
|
|
1092
|
+
const plain = step.expect.addedContains.filter((l) => !TRANSIENT_LINE.test(l) && !/\{\{v\d+\}\}/.test(l));
|
|
1093
|
+
// Only the POPUP lines decide. A click recorded to open a dialog also
|
|
1094
|
+
// records whatever else changed around it — the row it was about to fill,
|
|
1095
|
+
// the combobox it typed into — and those are on the page BEFORE the click
|
|
1096
|
+
// too. fwod34's 03-open picked "Conference Chair" from a product
|
|
1097
|
+
// autocomplete: the recorded effect listed `row "£ 0.00"` (the empty line
|
|
1098
|
+
// added one step earlier) beside `dialog ""`/`heading "Configure your
|
|
1099
|
+
// product"`, lineShows is any-of, so the option click was skipped as
|
|
1100
|
+
// "already in effect" on every replay and the dialog's Confirm at the next
|
|
1101
|
+
// step had nothing to click.
|
|
1102
|
+
const popup = plain.filter((l) => OPENER_LINE.test(l));
|
|
1103
|
+
if (!popup.length)
|
|
1104
|
+
return [];
|
|
1105
|
+
return popup.map((l) => fillParams(maskMinted(maskVolatile(l)), params));
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Is this step's work already done on the record it names?
|
|
1109
|
+
*
|
|
1110
|
+
* Two halves, and both are load-bearing. `preconditions.requireText` says the
|
|
1111
|
+
* page is showing THIS record (the url pattern and fingerprint only ever say
|
|
1112
|
+
* "a page of this template"); `goal.requireText` says that record is in the
|
|
1113
|
+
* state the procedure exists to produce. Identity without goal would skip a
|
|
1114
|
+
* step because the right record is open; goal without identity would skip it
|
|
1115
|
+
* because some OTHER record happens to read "Cancelled".
|
|
1116
|
+
*
|
|
1117
|
+
* Conservative by construction: a skill with no goal, or no identity, is never
|
|
1118
|
+
* satisfied, and an unbound marker (one that still reads `{{v1}}`) proves
|
|
1119
|
+
* nothing so it fails the check rather than passing it. Being wrong the other
|
|
1120
|
+
* way costs one replay; being wrong this way skips work that never happened.
|
|
1121
|
+
*/
|
|
1122
|
+
export async function goalSatisfied(page, skill, params) {
|
|
1123
|
+
const fill = (markers) => (markers ?? []).map((m) => fillParams(m, params));
|
|
1124
|
+
const identity = fill(skill.preconditions.requireText);
|
|
1125
|
+
const goal = fill(skill.goal?.requireText);
|
|
1126
|
+
if (!identity.length || !goal.length)
|
|
1127
|
+
return { satisfied: false, shown: [] };
|
|
1128
|
+
if ([...identity, ...goal].some((t) => !t || /\{\{/.test(t)))
|
|
1129
|
+
return { satisfied: false, shown: [] };
|
|
1130
|
+
// The goal was read on the page template the procedure ends on (single
|
|
1131
|
+
// segment, so also where it starts): on any other template the same words
|
|
1132
|
+
// mean nothing — a list row can show "Cancelled" for a different order.
|
|
1133
|
+
if (!urlMatches(skill.preconditions.urlPattern, page.url(), params))
|
|
1134
|
+
return { satisfied: false, shown: [] };
|
|
1135
|
+
const sig = await captureSignature(page);
|
|
1136
|
+
if (!sig)
|
|
1137
|
+
return { satisfied: false, shown: [] };
|
|
1138
|
+
for (const want of [...identity, ...goal]) {
|
|
1139
|
+
if (!lineShows(sig.lines, [want]))
|
|
1140
|
+
return { satisfied: false, shown: [] };
|
|
1141
|
+
}
|
|
1142
|
+
return { satisfied: true, shown: goal };
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* A parameterised line that did not *appear* may still be *there*: filling a
|
|
1146
|
+
* field with the value it already held produces no diff. One extra capture on
|
|
1147
|
+
* the miss path settles it.
|
|
1148
|
+
*/
|
|
1149
|
+
async function presentOnPage(page, lines) {
|
|
1150
|
+
const sig = await captureSignature(page);
|
|
1151
|
+
if (!sig)
|
|
1152
|
+
return false;
|
|
1153
|
+
return lineShows(sig.lines, lines);
|
|
1154
|
+
}
|
|
1155
|
+
const normWs = (s) => s.replace(/\s+/g, ' ').trim();
|
|
1156
|
+
/**
|
|
1157
|
+
* Does any of `wants` appear in `haystack` (recorded page lines, or a diff's
|
|
1158
|
+
* added lines)? Whitespace-insensitive on both sides — a marker copied with
|
|
1159
|
+
* a trailing space is the same word — and a `{{*}}` wildcard (see
|
|
1160
|
+
* maskVolatile) matches anything within one line.
|
|
1161
|
+
*/
|
|
1162
|
+
export function lineShows(haystack, wants) {
|
|
1163
|
+
const all = haystack.map(normWs).join('\n');
|
|
1164
|
+
return wants.some((raw) => {
|
|
1165
|
+
const want = normWs(raw);
|
|
1166
|
+
if (!want)
|
|
1167
|
+
return false;
|
|
1168
|
+
if (!want.includes(WILDCARD))
|
|
1169
|
+
return all.includes(want);
|
|
1170
|
+
const re = new RegExp(want
|
|
1171
|
+
.split(WILDCARD)
|
|
1172
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
1173
|
+
.join('[^\\n]*?'));
|
|
1174
|
+
return re.test(all);
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
function parseRead(result) {
|
|
1178
|
+
try {
|
|
1179
|
+
const v = JSON.parse(result);
|
|
1180
|
+
return Array.isArray(v) ? v.map(String).join(' | ') : String(v);
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
function describeArgs(tool, args) {
|
|
1187
|
+
const parts = [];
|
|
1188
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1189
|
+
if (k === 'target' || k === 'source')
|
|
1190
|
+
continue;
|
|
1191
|
+
if (typeof v === 'string')
|
|
1192
|
+
parts.push(`${k}=${JSON.stringify(clip(v, 60))}`);
|
|
1193
|
+
else if (typeof v === 'number' || typeof v === 'boolean')
|
|
1194
|
+
parts.push(`${k}=${v}`);
|
|
1195
|
+
}
|
|
1196
|
+
void tool;
|
|
1197
|
+
return parts.join(' ');
|
|
1198
|
+
}
|
|
1199
|
+
/** Text rendering of a replay result for the agent's tool output. */
|
|
1200
|
+
export function renderReplay(skill, res) {
|
|
1201
|
+
const lines = [];
|
|
1202
|
+
if (res.refused)
|
|
1203
|
+
return `ERROR: could not replay ${skill.id}: ${res.reason}`;
|
|
1204
|
+
lines.push(res.ok
|
|
1205
|
+
? `replayed ${skill.id}: ${res.stepsRun}/${res.stepsTotal} steps ok`
|
|
1206
|
+
: `replayed ${skill.id}: ${res.stepsRun}/${res.stepsTotal} steps ok, FAILED at step ${res.failedAt}`);
|
|
1207
|
+
lines.push(...res.lines.map((l) => ' ' + l));
|
|
1208
|
+
if (!res.ok && res.failedAt !== undefined && res.failedAt < res.stepsTotal) {
|
|
1209
|
+
lines.push(` not run: steps ${res.failedAt + 1}-${res.stepsTotal}`);
|
|
1210
|
+
}
|
|
1211
|
+
if (!res.ok) {
|
|
1212
|
+
lines.push(`Steps 1-${res.stepsRun} HAVE run and changed the page — do not repeat them. Observe the current page and continue from here to finish the instruction yourself.`);
|
|
1213
|
+
// Naming the steps is not enough when the steps CREATED something. fwod13
|
|
1214
|
+
// replayed 02-create part-way, stopped, and recovery created a second
|
|
1215
|
+
// order: run n2 finished with 2 orders for its customer and n3 with 3,
|
|
1216
|
+
// which is why every later objective scored "no single order to check".
|
|
1217
|
+
// The model was told which steps ran; it was not told that a record it is
|
|
1218
|
+
// about to create may already exist.
|
|
1219
|
+
if (res.created.length) {
|
|
1220
|
+
// Evidence, not persuasion: these were read off the live url as the
|
|
1221
|
+
// minting steps ran, so they are THIS run's records, not the
|
|
1222
|
+
// recording's.
|
|
1223
|
+
lines.push(`ALREADY CREATED by those steps: ${res.created.map((c) => JSON.stringify(c)).join(', ')}. ` +
|
|
1224
|
+
`Continue with ${res.created.length === 1 ? 'it' : 'them'} — creating another is a silent duplicate, not a recovery.`);
|
|
1225
|
+
}
|
|
1226
|
+
else if (res.stepsRun > 0) {
|
|
1227
|
+
lines.push(`If this instruction CREATES a record, one may already exist from those steps — search for it first and continue with it. Creating a second one is a silent duplicate, not a recovery.`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
const values = Object.entries(res.values);
|
|
1231
|
+
if (values.length)
|
|
1232
|
+
lines.push(`values read from the live page: ${values.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ')}`);
|
|
1233
|
+
if (res.warnings.length)
|
|
1234
|
+
lines.push(`notes: ${res.warnings.join('; ')}`);
|
|
1235
|
+
return lines.join('\n');
|
|
1236
|
+
}
|
|
1237
|
+
/** Which stored skills could apply on this page, best first. */
|
|
1238
|
+
export function candidatesFor(skills, url, limit = 5) {
|
|
1239
|
+
return skills
|
|
1240
|
+
.filter((s) => s.status !== 'demoted' && !(s.seq && s.seq.index > 0) && urlMatches(s.preconditions.urlPattern, url))
|
|
1241
|
+
.sort((a, b) => {
|
|
1242
|
+
const rank = (s) => (s.status === 'validated' ? 1 : 0);
|
|
1243
|
+
const rate = (s) => (s.stats.uses ? s.stats.successes / s.stats.uses : 0);
|
|
1244
|
+
return rank(b) - rank(a) || rate(b) - rate(a) || (b.stats.lastUsed ?? '').localeCompare(a.stats.lastUsed ?? '');
|
|
1245
|
+
})
|
|
1246
|
+
.slice(0, limit);
|
|
1247
|
+
}
|
|
1248
|
+
/** Values a skill will type verbatim because they were not parameterised. */
|
|
1249
|
+
export function literalInputs(s) {
|
|
1250
|
+
const out = [];
|
|
1251
|
+
for (const st of s.steps) {
|
|
1252
|
+
for (const key of ['value', 'text', 'option']) {
|
|
1253
|
+
const v = st.args[key];
|
|
1254
|
+
if (typeof v === 'string' && v.trim() && !/\{\{v\d+\}\}/.test(v) && !out.includes(JSON.stringify(clip(v, 40)))) {
|
|
1255
|
+
out.push(JSON.stringify(clip(v, 40)));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return out.slice(0, 6);
|
|
1260
|
+
}
|
|
1261
|
+
/** The `[skills]` block appended to an instruction's user message. */
|
|
1262
|
+
export function renderCandidates(skills) {
|
|
1263
|
+
if (!skills.length)
|
|
1264
|
+
return '';
|
|
1265
|
+
const lines = ['[skills] stored procedures that have worked on this page before — if one matches the instruction, call run_skill with it FIRST instead of rediscovering the steps:'];
|
|
1266
|
+
for (const s of skills) {
|
|
1267
|
+
const params = Object.entries(s.params)
|
|
1268
|
+
.map(([k, p]) => `${k} e.g. ${JSON.stringify(clip(p.example, 40))}`)
|
|
1269
|
+
.join(', ');
|
|
1270
|
+
const reads = s.steps.filter((st) => st.label).map((st) => st.label);
|
|
1271
|
+
const status = s.status === 'validated' ? `validated ${s.stats.successes}/${s.stats.uses}` : `unverified, ${s.stats.successes}/${s.stats.uses} run(s)`;
|
|
1272
|
+
lines.push(` ${s.id} ${JSON.stringify(s.template)}`);
|
|
1273
|
+
lines.push(` ${s.steps.length} steps · ${status}${params ? ` · params: ${params}` : ' · no params'}${reads.length ? ` · reads back: ${reads.join(', ')}` : ''}`);
|
|
1274
|
+
const literals = literalInputs(s);
|
|
1275
|
+
if (literals.length) {
|
|
1276
|
+
lines.push(` types these FIXED values (not parameters — do not use this procedure if the instruction wants different ones): ${literals.join(', ')}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return lines.join('\n');
|
|
1280
|
+
}
|
|
1281
|
+
//# sourceMappingURL=replay.js.map
|