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,1420 @@
|
|
|
1
|
+
import { newSkillId, originOf } from './store.js';
|
|
2
|
+
import { identifierLike } from './ledger.js';
|
|
3
|
+
import { WILDCARD, maskVolatile } from '../shared/text.js';
|
|
4
|
+
/** Args whose string values are candidates for parameter slots. */
|
|
5
|
+
const VALUE_ARGS = new Set(['value', 'text', 'option', 'url', 'prompt_text']);
|
|
6
|
+
const MAX_ADDED_LINES = 5;
|
|
7
|
+
const MAX_SLOT_VALUES = 12;
|
|
8
|
+
/**
|
|
9
|
+
* Turn one successful instruction's recording into a skill.
|
|
10
|
+
*
|
|
11
|
+
* Parameterisation is deliberately deterministic: any literal the agent typed
|
|
12
|
+
* (fill/type/select/goto values) that also occurs as a whole token in the
|
|
13
|
+
* instruction text becomes a slot, substituted everywhere it appears — step
|
|
14
|
+
* args, locator names, expectations, the report. Values that do not occur in
|
|
15
|
+
* the instruction stay literal: they are defaults the agent invented, which is
|
|
16
|
+
* the right thing to replay and worth being able to see in `skills show`.
|
|
17
|
+
*
|
|
18
|
+
* Returns null when there is nothing replayable (no steps, or no origin).
|
|
19
|
+
*/
|
|
20
|
+
export function compileSkill(input) {
|
|
21
|
+
return compileSkills(input)[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Like compileSkill, but the recording is first split at page-template seams
|
|
25
|
+
* — a step whose url PATTERN changed navigated to a different template — and
|
|
26
|
+
* each segment compiles into its own skill, scoped to the page it runs on
|
|
27
|
+
* (its own startUrl precondition and, when the recorder captured one, its own
|
|
28
|
+
* fingerprint). The segments share one template and one slot set and are
|
|
29
|
+
* linked by `seq`, so a caller binds params once and replay composes them;
|
|
30
|
+
* each segment is independently replayable, recoverable, and promotable, so a
|
|
31
|
+
* drift in one cannot cascade — the next segment refuses unless the page
|
|
32
|
+
* matches its template. A recording that never changes template compiles to
|
|
33
|
+
* exactly one skill, as before.
|
|
34
|
+
*/
|
|
35
|
+
export function compileSkills(input) {
|
|
36
|
+
const head = input.entries.find((e) => e.k === 'instruction');
|
|
37
|
+
const steps = input.entries.filter((e) => e.k === 'step');
|
|
38
|
+
if (!steps.length)
|
|
39
|
+
return [];
|
|
40
|
+
const startUrl = head?.url ?? firstUrl(steps);
|
|
41
|
+
const origin = startUrl ? originOf(startUrl) : null;
|
|
42
|
+
if (!origin || !startUrl)
|
|
43
|
+
return [];
|
|
44
|
+
const slots = discoverSlots(input.instruction, steps, input.knownValues);
|
|
45
|
+
const sub = (s) => substitute(s, slots);
|
|
46
|
+
/** Slots whose value the ledger banked from a `q.id` url position — the only
|
|
47
|
+
* slots substituteUrlId may write into a navigation arg's `id=`. */
|
|
48
|
+
const idOriginValues = new Set(Object.entries(input.knownValues ?? {})
|
|
49
|
+
.filter(([k]) => /:q\.id$/.test(k))
|
|
50
|
+
.map(([, v]) => String(v ?? '').trim()));
|
|
51
|
+
const urlIdSlots = new Map([...slots].filter(([, v]) => idOriginValues.has(v)));
|
|
52
|
+
/** The caller's values for THIS run — a runid, a record it vouched for. */
|
|
53
|
+
const runValues = Object.values(input.knownValues ?? {})
|
|
54
|
+
.map((v) => String(v ?? '').trim())
|
|
55
|
+
.filter((v) => v.length >= 3);
|
|
56
|
+
const reportValues = input.report.evidence?.values ?? {};
|
|
57
|
+
// Inspection-only actions the agent used to ORIENT itself — probe the DOM with
|
|
58
|
+
// `eval`, grab a `screenshot`, read a value it never reported — are not part of
|
|
59
|
+
// the reproducible procedure. An `eval` is worse than noise: it assumes the
|
|
60
|
+
// record-time DOM and, unlike a read (which replay skips on failure), it is
|
|
61
|
+
// fatal, which is exactly what sent the sign-in and archive steps to recovery
|
|
62
|
+
// on every replay. Keep the actions, the synthetic read-backs, and any read
|
|
63
|
+
// whose value the run actually reported; drop the rest.
|
|
64
|
+
const replayable = steps.filter((step) => {
|
|
65
|
+
if (step.tool === 'screenshot' || step.tool === 'eval')
|
|
66
|
+
return false;
|
|
67
|
+
if (step.tool === 'read' || step.tool === 'read_all') {
|
|
68
|
+
return step.args.target === '(read-back)' || Boolean(readLabel(step, reportValues));
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
let kept = replayable.length ? replayable : steps;
|
|
73
|
+
// A variant covers only the territory it repaired: steps that were replayed
|
|
74
|
+
// via a DIFFERENT stored skill (an earlier segment completing cleanly) are
|
|
75
|
+
// that skill's procedure, not this variant's.
|
|
76
|
+
if (input.variantOf)
|
|
77
|
+
kept = kept.filter((s) => !s.via || s.via.skill === input.variantOf);
|
|
78
|
+
if (!kept.length)
|
|
79
|
+
return [];
|
|
80
|
+
// Split at page-template seams. A step that navigated (diff.url) to a url
|
|
81
|
+
// with a DIFFERENT pattern ends its segment; the recorder's fingerprintAfter
|
|
82
|
+
// (when captured) becomes the next segment's precondition.
|
|
83
|
+
const segments = [];
|
|
84
|
+
let seg = {
|
|
85
|
+
steps: [],
|
|
86
|
+
startUrl,
|
|
87
|
+
...(head?.fingerprint ? { fingerprint: head.fingerprint } : {}),
|
|
88
|
+
...(head?.startText ? { startText: head.startText } : {}),
|
|
89
|
+
};
|
|
90
|
+
let currentUrl = startUrl;
|
|
91
|
+
for (const step of kept) {
|
|
92
|
+
seg.steps.push(step);
|
|
93
|
+
if (step.diff?.url && step.diff.url !== currentUrl) {
|
|
94
|
+
const crossed = urlPattern(step.diff.url, slots) !== urlPattern(currentUrl, slots);
|
|
95
|
+
currentUrl = step.diff.url;
|
|
96
|
+
if (crossed) {
|
|
97
|
+
segments.push(seg);
|
|
98
|
+
seg = {
|
|
99
|
+
steps: [],
|
|
100
|
+
startUrl: currentUrl,
|
|
101
|
+
...(step.fingerprintAfter ? { fingerprint: step.fingerprintAfter } : {}),
|
|
102
|
+
...(step.diff?.added?.length ? { startText: step.diff.added.join('\n') } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (seg.steps.length)
|
|
108
|
+
segments.push(seg);
|
|
109
|
+
// Provenance slots: values this run minted (first surfaced in a step's
|
|
110
|
+
// post-nav url). Kept only where they can pay: a later step or a later
|
|
111
|
+
// segment's start url mentions the value — otherwise the marker would just
|
|
112
|
+
// blunt the minting step's own expectation for nothing.
|
|
113
|
+
const mintedAll = discoverMinted(kept, startUrl, slots);
|
|
114
|
+
const minted = mintedAll.filter((m) => JSON.stringify(kept.slice(m.keptIndex + 1).map((s) => [s.args, s.locators, s.diff ?? null])).includes(m.value) ||
|
|
115
|
+
segments.some((sg, si) => si > 0 && urlParts(sg.startUrl).some((p) => p.value === m.value)));
|
|
116
|
+
const mintedMap = (pred) => new Map(minted.filter(pred).map((m) => [m.name, m.value]));
|
|
117
|
+
// Build every segment's steps first: slot retention is decided across the
|
|
118
|
+
// WHOLE chain (a slot used only by segment 2 must stay in the shared
|
|
119
|
+
// template, or binding an instruction to segment 1 would fail).
|
|
120
|
+
let segOffset = 0;
|
|
121
|
+
const built = segments.map((sg) => {
|
|
122
|
+
const base = segOffset;
|
|
123
|
+
segOffset += sg.steps.length;
|
|
124
|
+
const segParams = {};
|
|
125
|
+
for (const [name, value] of slots)
|
|
126
|
+
segParams[name] = { example: value, usedIn: [] };
|
|
127
|
+
const skillSteps = sg.steps.map((step, i) => {
|
|
128
|
+
const g = base + i;
|
|
129
|
+
// A minted value is a reference only DOWNSTREAM of its mint: in this
|
|
130
|
+
// step's args/locators when minted strictly earlier, and in this step's
|
|
131
|
+
// expectation when minted here or earlier (the minting step's own
|
|
132
|
+
// post-nav url is the first downstream occurrence).
|
|
133
|
+
const mintedBefore = mintedMap((m) => m.keptIndex < g);
|
|
134
|
+
const mintedHere = mintedMap((m) => m.keptIndex <= g);
|
|
135
|
+
const args = substituteDeep(substituteDeep(step.args, slots), mintedBefore);
|
|
136
|
+
// substitute() deliberately refuses to rewrite a number after `=` (the
|
|
137
|
+
// nth=25 guard), which is exactly where a navigation url carries its
|
|
138
|
+
// record id. Rewrite `id=<value>` structurally, and only for slots whose
|
|
139
|
+
// value the ledger banked from a q.id url position — a cost of "21"
|
|
140
|
+
// coinciding with a record id must not bind the url to the cost.
|
|
141
|
+
if (typeof args.url === 'string' && urlIdSlots.size)
|
|
142
|
+
args.url = substituteUrlId(args.url, urlIdSlots);
|
|
143
|
+
// The same `=` guard hides a MINTED value in a navigation url: fwod32's
|
|
144
|
+
// sign-in recorded `goto #action=135&menu_id=120` right after the step
|
|
145
|
+
// that minted action=135 and menu_id=120, so every replay navigated to
|
|
146
|
+
// the RECORDING run's action id. A minted value is rewritten at the
|
|
147
|
+
// url position it was minted from, and nowhere else.
|
|
148
|
+
if (typeof args.url === 'string')
|
|
149
|
+
args.url = substituteUrlParts(args.url, minted.filter((m) => m.keptIndex < g));
|
|
150
|
+
const locators = {};
|
|
151
|
+
for (const [key, loc] of Object.entries(step.locators)) {
|
|
152
|
+
const filled = (loc.chain ?? []).map((c) => substituteDeep(substituteDeep(c, slots), mintedBefore));
|
|
153
|
+
// An identity anchor still carrying THIS RUN's known value after
|
|
154
|
+
// slotting (the recorded runid, because the value was typed in an
|
|
155
|
+
// earlier instruction and so is not a slot here) can never match
|
|
156
|
+
// again — and worse, with no {{marker}} it carries no identity, so
|
|
157
|
+
// replay stops holding its fallbacks to the record and follows a
|
|
158
|
+
// positional one onto whatever sorted into that row (fwrd12l 04-add,
|
|
159
|
+
// 06-set). An anchor that cannot parameterise is not an anchor.
|
|
160
|
+
// Never strand the whole chain: a step with no way at all to find its
|
|
161
|
+
// element is worse than one carrying a candidate that will miss.
|
|
162
|
+
// `stranded` DELETES: the ledger knows the run made that value, so the
|
|
163
|
+
// candidate demonstrably cannot match another run. That is provenance,
|
|
164
|
+
// not a guess.
|
|
165
|
+
//
|
|
166
|
+
// `bookmarked` only DEMOTES. It reads an id's shape, and shape is a
|
|
167
|
+
// weak signal: grafana's ephemeral `_r8b_` matches none of our
|
|
168
|
+
// id patterns while odoo's stable `o_form_view` hooks trip several. A
|
|
169
|
+
// wrong deletion costs a working locator permanently; a wrong demotion
|
|
170
|
+
// costs one failed count(), and two replays of evidence put it right
|
|
171
|
+
// either way. So the shape rule sets the starting order and the
|
|
172
|
+
// running tally decides — see recordCandidateEvidence.
|
|
173
|
+
const usable = filled.filter((c) => !stranded(c, runValues));
|
|
174
|
+
const ranked = [...usable].sort((a, b) => Number(bookmarked(a)) - Number(bookmarked(b)));
|
|
175
|
+
const kept = stableFirst(ranked.length ? ranked : filled);
|
|
176
|
+
// A READ that lost its anchor and can now only be found BY POSITION
|
|
177
|
+
// must not publish. fwrd16-n3 is the cost of the alternative: the
|
|
178
|
+
// read fell back to `tbody > tr:nth-of-type(1) > td`, resolved
|
|
179
|
+
// instantly on a list whose first row was a seed ticket, and the step
|
|
180
|
+
// published `ref: RD-1014` at tier A with zero turns — a confidently
|
|
181
|
+
// wrong identity, which every later step then carried. Emptying the
|
|
182
|
+
// chain makes replay SKIP the read (reads are observations; a missing
|
|
183
|
+
// one is recoverable), so the value comes back absent, not wrong.
|
|
184
|
+
const lostAnchor = filled.length !== kept.length && filled.some((c) => c.kind === 'scoped');
|
|
185
|
+
const isRead = step.tool === 'read' || step.tool === 'read_all';
|
|
186
|
+
locators[key] = isRead && lostAnchor && kept.every(positional) ? [] : kept;
|
|
187
|
+
}
|
|
188
|
+
const out = { tool: step.tool, args, locators };
|
|
189
|
+
// Record-minting, from the evidence discoverMinted already gathered:
|
|
190
|
+
// this step's post-nav url carried an identifier the run had not seen
|
|
191
|
+
// before. Stored per step because a replay that stops needs to know
|
|
192
|
+
// whether it is past the point of creation, not merely how far it got.
|
|
193
|
+
const mintedHereOnly = mintedAll.find((m) => m.keptIndex === g);
|
|
194
|
+
if (mintedHereOnly)
|
|
195
|
+
out.mints = { at: mintedHereOnly.at };
|
|
196
|
+
// Minted values go into the url-pattern reduction as slots: an id-like
|
|
197
|
+
// one (odoo's "44", repair-desk's "t15") is otherwise reduced to `:id`
|
|
198
|
+
// before the {{dN}} marker can land, and the minting step then carries
|
|
199
|
+
// no reference to what it minted — so `derived` could not find it.
|
|
200
|
+
const expect = expectationFor(step, new Map([...slots, ...mintedHere]));
|
|
201
|
+
if (expect)
|
|
202
|
+
out.expect = substituteDeep(expect, mintedHere);
|
|
203
|
+
const label = readLabel(step, reportValues);
|
|
204
|
+
// A read with no way to find its element again publishes nothing, so it
|
|
205
|
+
// must not advertise the value either — publishedOutputs reads `label`,
|
|
206
|
+
// and a promised output that never arrives sends later steps to
|
|
207
|
+
// recovery with the reference blank.
|
|
208
|
+
const targetless = step.tool === 'read' && step.args.what === 'url';
|
|
209
|
+
if (label && (targetless || Object.values(locators).some((chain) => chain.length)))
|
|
210
|
+
out.label = label;
|
|
211
|
+
if (step.via)
|
|
212
|
+
out.via = step.via;
|
|
213
|
+
// Args and locators are use; expectations are NOT. Counting them (f24bdf9)
|
|
214
|
+
// kept a slot the procedure never types — a price the recording saw in
|
|
215
|
+
// a row, a uid in a post-save url — as a param bound by ORIGIN, and
|
|
216
|
+
// bindSkill then refused the whole skill whenever that origin had not
|
|
217
|
+
// been published (fwgr23 05-open, fwkb3-n3 03-create: tier null, 14-44
|
|
218
|
+
// model turns). The orphan-marker hazard it was fixing is handled below
|
|
219
|
+
// by re-inlining every dropped slot into the steps, expectations included.
|
|
220
|
+
for (const name of slotsUsed(JSON.stringify({ args, locators })))
|
|
221
|
+
segParams[name]?.usedIn.push(i + 1);
|
|
222
|
+
return out;
|
|
223
|
+
});
|
|
224
|
+
const mintedForStart = mintedMap((m) => m.keptIndex < base);
|
|
225
|
+
return { sg, segParams, mintedForStart, folded: foldLoops(coalesceControls(dropDismissedDialogs(dropSupersededNavigation(skillSteps)))) };
|
|
226
|
+
});
|
|
227
|
+
// Derived-param metadata lands on the MINTING segment: which post-fold step
|
|
228
|
+
// to bind from, and which url part to read there. Replay binds the value
|
|
229
|
+
// from the live run's own url right after that step executes.
|
|
230
|
+
const segDerived = {};
|
|
231
|
+
for (const m of minted) {
|
|
232
|
+
const si = segments.findIndex((sg, k) => {
|
|
233
|
+
const start = segments.slice(0, k).reduce((a, s) => a + s.steps.length, 0);
|
|
234
|
+
return m.keptIndex >= start && m.keptIndex < start + sg.steps.length;
|
|
235
|
+
});
|
|
236
|
+
if (si < 0)
|
|
237
|
+
continue;
|
|
238
|
+
const marker = `{{${m.name}}}`;
|
|
239
|
+
const stepIdx = built[si].folded.findIndex((st) => JSON.stringify(st).includes(marker));
|
|
240
|
+
if (stepIdx < 0)
|
|
241
|
+
continue;
|
|
242
|
+
(segDerived[si] ??= {})[m.name] = { step: stepIdx + 1, at: m.at, example: m.value };
|
|
243
|
+
}
|
|
244
|
+
// Drop slots no segment uses: instruction-only words (e.g. an id the
|
|
245
|
+
// orchestrator mentioned for context) would only make matching harder.
|
|
246
|
+
// EXCEPT known run values: a runid or threaded ref that appears only in the
|
|
247
|
+
// wording still changes every run, so leaving it literal would make the
|
|
248
|
+
// template single-run — bindSkill could never match run n+1's instruction.
|
|
249
|
+
const knownVals = new Set(Object.values(input.knownValues ?? {}).map((v) => String(v ?? '').trim()));
|
|
250
|
+
const usedNames = new Set();
|
|
251
|
+
for (const b of built)
|
|
252
|
+
for (const [name, p] of Object.entries(b.segParams))
|
|
253
|
+
if (p.usedIn.length)
|
|
254
|
+
usedNames.add(name);
|
|
255
|
+
// A known value can be wholly swallowed by a longer slot (the bare runid
|
|
256
|
+
// inside the ticket-title slot): its marker then appears nowhere, and a
|
|
257
|
+
// param that can never bind makes bindSkill refuse the skill's own source
|
|
258
|
+
// instruction. Keep a known-value slot only when its marker survives.
|
|
259
|
+
const tentative = sub(input.instruction);
|
|
260
|
+
const keptSlots = new Map([...slots].filter(([n, v]) => usedNames.has(n) || (knownVals.has(v) && tentative.includes(`{{${n}}}`))));
|
|
261
|
+
const finalTemplate = keptSlots.size === slots.size ? sub(input.instruction) : substitute(input.instruction, keptSlots);
|
|
262
|
+
// The mirror hazard: a slot whose marker survives only in STEPS (its every
|
|
263
|
+
// instruction occurrence was swallowed by a longer slot, or the value came
|
|
264
|
+
// from an EARLIER instruction and this one never names it) can never bind
|
|
265
|
+
// from the template — bindSkill derives values from the template alone,
|
|
266
|
+
// then requires every param to have one.
|
|
267
|
+
//
|
|
268
|
+
// If the run banked the value, the param binds to its ORIGIN instead: a
|
|
269
|
+
// later run resolves its own from the same place, and the marker stays. Only
|
|
270
|
+
// when the origin is unknown do we fall back to re-inlining the recorded
|
|
271
|
+
// literal, which is the compromise that put the recording run's runid inside
|
|
272
|
+
// an anchor's hasText and cost every replay a positional fallback
|
|
273
|
+
// (fwrd19l 04-edit, on both replays, deterministically).
|
|
274
|
+
const originOfValue = new Map(Object.entries(input.knownValues ?? {}).map(([key, v]) => [String(v ?? '').trim(), key]));
|
|
275
|
+
const bindings = new Map();
|
|
276
|
+
const inTemplate = new Set(Array.from(finalTemplate.matchAll(/\{\{(v\d+)\}\}/g), (m) => m[1]));
|
|
277
|
+
for (const [name, value] of [...keptSlots]) {
|
|
278
|
+
// Every slot whose origin is known records it — the ones the template
|
|
279
|
+
// can supply too. A re-pin onto this skill from another run rebinds its
|
|
280
|
+
// slots by origin (remapParams); without the origin it could only guess
|
|
281
|
+
// by value, and rpat2 guessed an earlier run's literal into a live run.
|
|
282
|
+
const origin = originOfValue.get(value);
|
|
283
|
+
if (origin)
|
|
284
|
+
bindings.set(name, origin);
|
|
285
|
+
if (inTemplate.has(name) || origin)
|
|
286
|
+
continue;
|
|
287
|
+
keptSlots.delete(name);
|
|
288
|
+
}
|
|
289
|
+
// Every slot that did not survive is re-inlined as its recorded literal —
|
|
290
|
+
// in args, locators AND expectations. A dropped slot whose marker lingered
|
|
291
|
+
// only in addedContains was an orphan {{vN}} that replay treated as a HARD,
|
|
292
|
+
// unfillable line.
|
|
293
|
+
for (const [name, value] of slots) {
|
|
294
|
+
if (keptSlots.has(name))
|
|
295
|
+
continue;
|
|
296
|
+
for (const b of built)
|
|
297
|
+
b.folded = fillParamsDeep(b.folded, { [name]: value });
|
|
298
|
+
}
|
|
299
|
+
const now = input.now ?? new Date().toISOString();
|
|
300
|
+
const reportTemplate = {
|
|
301
|
+
summary: sub(input.report.summary),
|
|
302
|
+
values: Object.fromEntries(Object.entries(reportValues).map(([k, v]) => [k, sub(String(v))])),
|
|
303
|
+
};
|
|
304
|
+
// What the page shows once this procedure's work is done. Derived from the
|
|
305
|
+
// recording's own before/after pair: report text that was NOT on the page
|
|
306
|
+
// when the instruction began. See deriveGoal.
|
|
307
|
+
const goal = deriveGoal({
|
|
308
|
+
startText: head?.startText,
|
|
309
|
+
reportValues,
|
|
310
|
+
sub,
|
|
311
|
+
// Single-segment only: startText is the page the instruction BEGAN on,
|
|
312
|
+
// and only when no page-template seam was crossed is that the same page
|
|
313
|
+
// the report read its values from. A confirm that started on the list
|
|
314
|
+
// would otherwise take "Sales Order" — listed in Odoo's status bar before
|
|
315
|
+
// the confirm as well — for its goal, and skip the step on a form.
|
|
316
|
+
mutating: built.length === 1 && built.some((b) => mutatesSteps(b.folded)),
|
|
317
|
+
// Identities, not states: anything the caller vouched for, anything the
|
|
318
|
+
// run minted, and every slot's recorded value. "S00021" appearing in the
|
|
319
|
+
// report is the record's NAME — it was equally true before the work.
|
|
320
|
+
identities: new Set([...knownVals, ...minted.map((m) => m.value), ...slots.values()].map((v) => String(v ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean)),
|
|
321
|
+
});
|
|
322
|
+
const of = built.length;
|
|
323
|
+
const chain = of > 1 ? newSkillId(origin, finalTemplate, now) : null;
|
|
324
|
+
return built.map((b, k) => {
|
|
325
|
+
const params = {};
|
|
326
|
+
for (const name of keptSlots.keys()) {
|
|
327
|
+
const value = keptSlots.get(name) ?? '';
|
|
328
|
+
params[name] = {
|
|
329
|
+
...b.segParams[name],
|
|
330
|
+
...(derivesFromKnown(value, knownVals) ? { known: true } : {}),
|
|
331
|
+
...(bindings.has(name) ? { binding: bindings.get(name) } : {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
id: newSkillId(origin, of > 1 ? `${finalTemplate}#${k}` : finalTemplate, now),
|
|
336
|
+
origin,
|
|
337
|
+
template: finalTemplate,
|
|
338
|
+
params,
|
|
339
|
+
preconditions: {
|
|
340
|
+
// keptSlots, not slots: a dropped slot has no param to bind, and its
|
|
341
|
+
// marker in the pattern would read as a wildcard segment.
|
|
342
|
+
urlPattern: urlPattern(b.sg.startUrl, new Map([...keptSlots, ...b.mintedForStart])),
|
|
343
|
+
...(b.sg.fingerprint ? { fingerprint: b.sg.fingerprint } : {}),
|
|
344
|
+
...(identityOf(b.sg.startText, keptSlots, knownVals).length
|
|
345
|
+
? { requireText: identityOf(b.sg.startText, keptSlots, knownVals) }
|
|
346
|
+
: {}),
|
|
347
|
+
},
|
|
348
|
+
// Only the LAST segment finishes the work, so only it can vouch for the
|
|
349
|
+
// end state — an earlier segment carrying the goal would let a chain be
|
|
350
|
+
// skipped from its head on evidence its tail produced.
|
|
351
|
+
...(k === of - 1 && goal ? { goal } : {}),
|
|
352
|
+
steps: b.folded,
|
|
353
|
+
...(segDerived[k] ? { derived: segDerived[k] } : {}),
|
|
354
|
+
// Only the last segment can vouch for the instruction's end state.
|
|
355
|
+
...(k === of - 1 ? { reportTemplate } : {}),
|
|
356
|
+
stats: { uses: 1, successes: 1, partial: 0, created: now, failedAtStep: {}, fallthroughs: 0 },
|
|
357
|
+
status: 'provisional',
|
|
358
|
+
...(chain ? { seq: { chain, index: k, of } } : {}),
|
|
359
|
+
...(input.variantOf ? { variantOf: input.variantOf } : {}),
|
|
360
|
+
provenance: { session: input.session, instruction: input.instruction, ...(input.model ? { model: input.model } : {}), created: now },
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const MIN_GOAL_LEN = 3;
|
|
365
|
+
const MAX_GOAL = 4;
|
|
366
|
+
/** Does this procedure CHANGE anything? (learn.ts's `mutates`, at compile time.) */
|
|
367
|
+
const MUTATING_TOOLS = new Set(['click', 'dblclick', 'right_click', 'modifier_click', 'fill', 'type', 'press', 'select', 'check', 'drag', 'upload']);
|
|
368
|
+
function mutatesSteps(steps) {
|
|
369
|
+
return steps.some((s) => MUTATING_TOOLS.has(s.tool) || (s.body ? mutatesSteps(s.body) : false));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The GOAL: what the page shows once this procedure's work is done.
|
|
373
|
+
*
|
|
374
|
+
* The evidence is the recording's own before/after pair — `startText` (what
|
|
375
|
+
* the page showed when the instruction began) against the report's read-back
|
|
376
|
+
* values (what it showed when the work was finished). A report line that was
|
|
377
|
+
* NOT in `startText` is text the procedure BROUGHT INTO EXISTENCE, which is
|
|
378
|
+
* exactly the signal "already done" needs: Odoo's status bar lists every
|
|
379
|
+
* reachable state, so "Sales Order" is showing whether or not the order was
|
|
380
|
+
* cancelled, while "Cancelled" is listed only once it was.
|
|
381
|
+
*
|
|
382
|
+
* Everything here is a filter against FALSE POSITIVES, because a false
|
|
383
|
+
* positive skips work that never happened while a false negative merely runs
|
|
384
|
+
* the step as before:
|
|
385
|
+
* - text already in `startText` is not evidence of anything (it was true
|
|
386
|
+
* before);
|
|
387
|
+
* - an identity — a caller-vouched value, a minted id, a slot's recorded
|
|
388
|
+
* literal — names the RECORD, not its state, and is equally true before
|
|
389
|
+
* and after;
|
|
390
|
+
* - a line that still carries a `{{slot}}` after substitution cannot be
|
|
391
|
+
* checked against a live page without guessing what fills it;
|
|
392
|
+
* - and no startText, no report values, or a read-only procedure means no
|
|
393
|
+
* goal at all. Never guess one.
|
|
394
|
+
*/
|
|
395
|
+
function deriveGoal(opts) {
|
|
396
|
+
if (!opts.mutating || !opts.startText)
|
|
397
|
+
return null;
|
|
398
|
+
const before = opts.startText.replace(/\s+/g, ' ').toLowerCase();
|
|
399
|
+
const out = [];
|
|
400
|
+
const seen = new Set();
|
|
401
|
+
for (const raw of Object.values(opts.reportValues)) {
|
|
402
|
+
for (const rawLine of String(raw ?? '').split('\n')) {
|
|
403
|
+
const line = rawLine.replace(/\s+/g, ' ').trim();
|
|
404
|
+
if (line.length < MIN_GOAL_LEN)
|
|
405
|
+
continue;
|
|
406
|
+
if (!/[A-Za-z]/.test(line))
|
|
407
|
+
continue; // digits and punctuation are ids and counts, not states
|
|
408
|
+
if (before.includes(line.toLowerCase()))
|
|
409
|
+
continue;
|
|
410
|
+
if (opts.identities.has(line))
|
|
411
|
+
continue;
|
|
412
|
+
const subbed = opts.sub(line);
|
|
413
|
+
if (subbed.includes('{{'))
|
|
414
|
+
continue;
|
|
415
|
+
if (seen.has(subbed.toLowerCase()))
|
|
416
|
+
continue;
|
|
417
|
+
seen.add(subbed.toLowerCase());
|
|
418
|
+
out.push(subbed);
|
|
419
|
+
if (out.length >= MAX_GOAL)
|
|
420
|
+
return { requireText: out };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return out.length ? { requireText: out } : null;
|
|
424
|
+
}
|
|
425
|
+
const MIN_IDENTITY_LEN = 4;
|
|
426
|
+
const MAX_IDENTITY = 2;
|
|
427
|
+
/**
|
|
428
|
+
* Which caller-vouched values the page ALREADY showed where this segment
|
|
429
|
+
* starts — the segment's identity precondition, as slot markers so replay
|
|
430
|
+
* checks the live run's own values.
|
|
431
|
+
*
|
|
432
|
+
* Only known values qualify (the runid, a threaded ref, url provenance): they
|
|
433
|
+
* are the ones that name the record the caller means, and a value the
|
|
434
|
+
* compiler merely inferred from repeated text could easily be page furniture.
|
|
435
|
+
* A value the segment is about to TYPE is not on the page yet, so it never
|
|
436
|
+
* qualifies either — which is what keeps this from refusing a create step.
|
|
437
|
+
*/
|
|
438
|
+
/**
|
|
439
|
+
* Caller-vouched, or built out of something the caller vouched for: the
|
|
440
|
+
* ticket TITLE ("r9-n2 RD Bench Ticket") is as run-scoped as the runid inside
|
|
441
|
+
* it, and it is usually the title — not the bare runid — that survives as a
|
|
442
|
+
* slot, because the longer value swallows the shorter one. Treating only the
|
|
443
|
+
* exact known value as identity would therefore lose identity on exactly the
|
|
444
|
+
* skills that need it.
|
|
445
|
+
*/
|
|
446
|
+
function derivesFromKnown(value, known) {
|
|
447
|
+
if (known.has(value))
|
|
448
|
+
return true;
|
|
449
|
+
for (const k of known) {
|
|
450
|
+
if (k.length < 3 || k.length === value.length)
|
|
451
|
+
continue;
|
|
452
|
+
if (new RegExp(`(?<![A-Za-z0-9])${escapeRe(k)}(?![A-Za-z0-9])`).test(value))
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
function identityOf(startText, slots, known) {
|
|
458
|
+
if (!startText)
|
|
459
|
+
return [];
|
|
460
|
+
const out = [];
|
|
461
|
+
for (const [name, raw] of slots) {
|
|
462
|
+
if (out.length >= MAX_IDENTITY)
|
|
463
|
+
break;
|
|
464
|
+
// Whitespace is not identity. fwkb3 published a column name as "Backlog "
|
|
465
|
+
// (trailing space, copied from the header's text), the slot became a
|
|
466
|
+
// requireText marker, and every replay refused the create step because
|
|
467
|
+
// the live page showed "Backlog" — the same word.
|
|
468
|
+
const value = raw.replace(/\s+/g, ' ').trim();
|
|
469
|
+
if (!derivesFromKnown(raw, known) || value.length < MIN_IDENTITY_LEN || /^https?:/i.test(value))
|
|
470
|
+
continue;
|
|
471
|
+
if (!startText.replace(/\s+/g, ' ').includes(value))
|
|
472
|
+
continue;
|
|
473
|
+
out.push(`{{${name}}}`);
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
const MAX_MINTED = 8;
|
|
478
|
+
/**
|
|
479
|
+
* Mechanism-1 provenance (PLAN-replay-v2): values the run itself minted. A
|
|
480
|
+
* url part that first appears in a step's post-navigation url — absent from
|
|
481
|
+
* the start url, every earlier url, the caller's slot values and everything
|
|
482
|
+
* the agent typed — was created by this run (a fresh record id, a generated
|
|
483
|
+
* uid). Every later occurrence is downstream of that step's outcome, so it
|
|
484
|
+
* becomes a {{dN}} reference bound at replay time from where the browser
|
|
485
|
+
* actually lands — the same mechanism as discoverSlots, with a new value
|
|
486
|
+
* source, and the same guards: id-shaped, whole-value match, first
|
|
487
|
+
* appearance wins.
|
|
488
|
+
*/
|
|
489
|
+
function discoverMinted(kept, startUrl, slots) {
|
|
490
|
+
const seen = new Set(urlParts(startUrl).map((p) => p.value));
|
|
491
|
+
const slotVals = new Set(slots.values());
|
|
492
|
+
const out = [];
|
|
493
|
+
kept.forEach((step, i) => {
|
|
494
|
+
// Values the agent TYPED are inputs, not mints, wherever they surface later.
|
|
495
|
+
for (const v of Object.values(step.args))
|
|
496
|
+
if (typeof v === 'string')
|
|
497
|
+
seen.add(v);
|
|
498
|
+
if (!step.diff?.url)
|
|
499
|
+
return;
|
|
500
|
+
for (const part of urlParts(step.diff.url)) {
|
|
501
|
+
const v = part.value;
|
|
502
|
+
const fresh = !seen.has(v);
|
|
503
|
+
seen.add(v);
|
|
504
|
+
// Position is evidence. A bare "44" free in prose means nothing, which is
|
|
505
|
+
// why the ledger keeps a length floor — but "44" sitting in a url part
|
|
506
|
+
// is a record id, and isIdLike already says so for url-pattern
|
|
507
|
+
// generalisation. Requiring 4 characters here contradicted that: odoo's
|
|
508
|
+
// record ids are two-digit integers, so fwod15 compiled ZERO minting
|
|
509
|
+
// steps and the whole `mints` mechanism was inert on that target.
|
|
510
|
+
//
|
|
511
|
+
// Same shape as the two floor mismatches already fixed today ("t15"
|
|
512
|
+
// below identifierLike's floor; url parts published at >= 4 while
|
|
513
|
+
// buildFlow minted refs at >= 3). Three separate thresholds asking one
|
|
514
|
+
// question, disagreeing three times. This one now asks the question the
|
|
515
|
+
// url code already answers.
|
|
516
|
+
if (!fresh || !(isIdLike(v) || identifierLike(v)) || slotVals.has(v) || /\{\{/.test(v))
|
|
517
|
+
continue;
|
|
518
|
+
// A stable route word ("tickets", "dashboards") also first appears in a
|
|
519
|
+
// post-nav url once; claiming it would wildcard preconditions that
|
|
520
|
+
// should stay exact. Requiring a digit is a heuristic, but one whose
|
|
521
|
+
// being wrong costs a soft-match comparison (mechanism 2 still catches
|
|
522
|
+
// a digitless minted id), not a dead flow.
|
|
523
|
+
if (!/\d/.test(v))
|
|
524
|
+
continue;
|
|
525
|
+
if (out.length >= MAX_MINTED)
|
|
526
|
+
continue;
|
|
527
|
+
out.push({ name: `d${out.length + 1}`, value: v, keptIndex: i, at: part.label });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Literal values the agent used that also appear as whole tokens in the
|
|
534
|
+
* instruction. Ordered by first occurrence in the instruction, longest match
|
|
535
|
+
* first when values nest ("x7 RD Part A" before "x7").
|
|
536
|
+
*
|
|
537
|
+
* `known` are run-scoped values the CALLER vouches for — declared flow vars
|
|
538
|
+
* (the runid) and minted url-provenance parts. They are slotted by policy,
|
|
539
|
+
* not heuristics: every occurrence in the instruction, args, and locators is
|
|
540
|
+
* the same value playing the same role by construction, so the
|
|
541
|
+
* single-occurrence guard below does not apply to them (unlike admin/admin,
|
|
542
|
+
* where one string served two different roles). This is what keeps a run
|
|
543
|
+
* identifier out of a compiled skill: fwrd3 baked "fwrd3-n1"/"RD-1015" into
|
|
544
|
+
* templates and steps, so every skill was single-run poison — tier-A replay
|
|
545
|
+
* died at the first stale literal on every later run, and repairs minted a
|
|
546
|
+
* fresh single-run corpse each time instead of converging.
|
|
547
|
+
*/
|
|
548
|
+
export function discoverSlots(instruction, steps, known = {}) {
|
|
549
|
+
const values = new Set();
|
|
550
|
+
const locatorCandidates = new Set();
|
|
551
|
+
for (const step of steps) {
|
|
552
|
+
if (step.tool === 'read' || step.tool === 'read_all' || step.tool === 'eval')
|
|
553
|
+
continue;
|
|
554
|
+
for (const [key, v] of Object.entries(step.args)) {
|
|
555
|
+
if (!VALUE_ARGS.has(key) || typeof v !== 'string')
|
|
556
|
+
continue;
|
|
557
|
+
const value = v.trim();
|
|
558
|
+
if (value.length < 2 || value.length > 200)
|
|
559
|
+
continue;
|
|
560
|
+
if (!occursAsToken(instruction, value))
|
|
561
|
+
continue;
|
|
562
|
+
values.add(value);
|
|
563
|
+
}
|
|
564
|
+
// wait_for text is a check, but a check on a parameter is still parameterised
|
|
565
|
+
if (step.tool === 'wait_for' && typeof step.args.text === 'string' && occursAsToken(instruction, step.args.text.trim())) {
|
|
566
|
+
values.add(step.args.text.trim());
|
|
567
|
+
}
|
|
568
|
+
// A locator that IDENTIFIES a record — clicking the row for ticket
|
|
569
|
+
// "RD-1015", a link named after the value — hard-codes that record unless
|
|
570
|
+
// its identifying string is parameterised too. Collect candidates now;
|
|
571
|
+
// add them below only if they look record-specific, so a plain UI label
|
|
572
|
+
// ("Save") that happens to appear in the instruction is not parameterised.
|
|
573
|
+
for (const loc of Object.values(step.locators)) {
|
|
574
|
+
for (const value of locatorValues(loc.chain ?? [])) {
|
|
575
|
+
const v = value.trim();
|
|
576
|
+
if (v.length >= 2 && v.length <= 200 && occursAsToken(instruction, v))
|
|
577
|
+
locatorCandidates.add(v);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const v of locatorCandidates) {
|
|
582
|
+
// Record-specific = already a value the caller typed (an arg slot), or
|
|
583
|
+
// carries an identifier (a digit / id-like token). Excludes stable UI text.
|
|
584
|
+
if (values.has(v) || /\d/.test(v) || v.split(/\s+/).some(isIdLike))
|
|
585
|
+
values.add(v);
|
|
586
|
+
}
|
|
587
|
+
const knownVals = [];
|
|
588
|
+
for (const raw of Object.values(known)) {
|
|
589
|
+
const v = String(raw ?? '').trim();
|
|
590
|
+
if (v.length < 2 || v.length > 200)
|
|
591
|
+
continue;
|
|
592
|
+
if (!occursAsToken(instruction, v) || knownVals.includes(v))
|
|
593
|
+
continue;
|
|
594
|
+
knownVals.push(v);
|
|
595
|
+
}
|
|
596
|
+
knownVals.sort((a, b) => instruction.indexOf(a) - instruction.indexOf(b) || b.length - a.length);
|
|
597
|
+
const ordered = [...values]
|
|
598
|
+
.filter((v) => !knownVals.includes(v))
|
|
599
|
+
// A value appearing twice in the instruction cannot be given a slot: one
|
|
600
|
+
// slot name would stand for two roles. "sign in with email admin and
|
|
601
|
+
// password admin" compiled to "email {{v1}} and password {{v1}}", and
|
|
602
|
+
// bindSkill emits a capture group per OCCURRENCE, so replaying it with
|
|
603
|
+
// "email alice@example.com and password hunter2" bound v1 to the last
|
|
604
|
+
// group and typed the password into the email field — silently, and with
|
|
605
|
+
// a credential. Leaving such a value literal costs generality (the skill
|
|
606
|
+
// only replays for the values it was recorded with) and keeps
|
|
607
|
+
// correctness, which is the right way round. Distinct positional slots
|
|
608
|
+
// per occurrence would recover the generality, but they also need the
|
|
609
|
+
// step-to-occurrence mapping that plain textual substitution cannot
|
|
610
|
+
// recover, so that is a separate change.
|
|
611
|
+
.filter((v) => countTokenOccurrences(instruction, v) === 1)
|
|
612
|
+
.map((v) => ({ v, at: instruction.indexOf(v) }))
|
|
613
|
+
.sort((a, b) => a.at - b.at || b.v.length - a.v.length)
|
|
614
|
+
.slice(0, Math.max(0, MAX_SLOT_VALUES - knownVals.length));
|
|
615
|
+
// Third slot source, exempt from instruction anchoring: a navigation arg's
|
|
616
|
+
// `id=` value that the ledger already banked from an EARLIER instruction's
|
|
617
|
+
// url. The armdoc rightly forbids instructions naming database ids, so this
|
|
618
|
+
// value can never anchor in prose — but its ORIGIN is known (a `url:iN:q.id`
|
|
619
|
+
// binding), and slots bind by origin when the template cannot supply them.
|
|
620
|
+
// fwod29 is the cost of the gap: three downstream skills carried
|
|
621
|
+
// `...&id=21` literally, every replay navigated to the recording run's
|
|
622
|
+
// deleted order, saw an empty page, and paid ~20 recovery turns.
|
|
623
|
+
const urlIdVals = [];
|
|
624
|
+
const knownIdOrigins = new Set(Object.entries(known)
|
|
625
|
+
.filter(([k]) => /:q\.id$/.test(k))
|
|
626
|
+
.map(([, v]) => String(v ?? '').trim()));
|
|
627
|
+
for (const step of steps) {
|
|
628
|
+
if (typeof step.args.url !== 'string')
|
|
629
|
+
continue;
|
|
630
|
+
for (const part of urlParts(step.args.url)) {
|
|
631
|
+
if (part.label !== 'q.id' || !/^\d{1,10}$/.test(part.value))
|
|
632
|
+
continue;
|
|
633
|
+
if (!knownIdOrigins.has(part.value) || urlIdVals.includes(part.value))
|
|
634
|
+
continue;
|
|
635
|
+
if (knownVals.includes(part.value) || values.has(part.value))
|
|
636
|
+
continue;
|
|
637
|
+
urlIdVals.push(part.value);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const slots = new Map();
|
|
641
|
+
// Known values first so a cap can never cut them: they are the slots that
|
|
642
|
+
// decide whether the skill survives past the run that recorded it.
|
|
643
|
+
[...knownVals, ...ordered.map(({ v }) => v), ...urlIdVals].forEach((v, i) => slots.set(`v${i + 1}`, v));
|
|
644
|
+
return slots;
|
|
645
|
+
}
|
|
646
|
+
/** Rewrite `id=<value>` url params to slot markers — see the call site. */
|
|
647
|
+
/**
|
|
648
|
+
* Rewrite minted url parts inside a navigation url at the position each was
|
|
649
|
+
* minted from: a value minted at `q.action` replaces `action=<value>` (query
|
|
650
|
+
* or hash state) with `action={{dN}}`, and nothing else — a "135" elsewhere
|
|
651
|
+
* in the url is left alone.
|
|
652
|
+
*/
|
|
653
|
+
export function substituteUrlParts(url, minted) {
|
|
654
|
+
let out = url;
|
|
655
|
+
for (const m of minted) {
|
|
656
|
+
if (!m.value || !m.at.startsWith('q.'))
|
|
657
|
+
continue;
|
|
658
|
+
const key = m.at.slice(2);
|
|
659
|
+
out = out.replace(new RegExp(`([?&#]${escapeRe(key)}=)${escapeRe(m.value)}(?=[&#]|$)`, 'g'), `$1{{${m.name}}}`);
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
export function substituteUrlId(url, slots) {
|
|
664
|
+
let out = url;
|
|
665
|
+
for (const [name, value] of slots) {
|
|
666
|
+
if (!/^\d{1,10}$/.test(value))
|
|
667
|
+
continue;
|
|
668
|
+
out = out.replace(new RegExp(`([?&#]id=)${escapeRe(value)}(?=[&#]|$)`, 'g'), `$1{{${name}}}`);
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
/** How many times `value` stands as a whole token in `text`. */
|
|
673
|
+
export function countTokenOccurrences(text, value) {
|
|
674
|
+
if (!value)
|
|
675
|
+
return 0;
|
|
676
|
+
const re = new RegExp(`(^|[^A-Za-z0-9])${escapeRe(value)}(?=$|[^A-Za-z0-9])`, 'g');
|
|
677
|
+
let n = 0;
|
|
678
|
+
while (re.exec(text) !== null)
|
|
679
|
+
n++;
|
|
680
|
+
return n;
|
|
681
|
+
}
|
|
682
|
+
function occursAsToken(text, value) {
|
|
683
|
+
if (!value)
|
|
684
|
+
return false;
|
|
685
|
+
const re = new RegExp(`(^|[^A-Za-z0-9])${escapeRe(value)}(?=$|[^A-Za-z0-9])`);
|
|
686
|
+
return re.test(text);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The human-meaningful identifying strings in a locator chain — the ones that
|
|
690
|
+
* can carry a record identifier (a role/link name, visible text, a label). Id
|
|
691
|
+
* and css selectors are excluded: their embedded ids are already handled by
|
|
692
|
+
* stableFirst (demoted) and are not values a caller would supply.
|
|
693
|
+
*/
|
|
694
|
+
/**
|
|
695
|
+
* An identity anchor left naming the recorded run's record: its text still
|
|
696
|
+
* contains a value the caller vouched for THIS run, with no slot to swap.
|
|
697
|
+
* Only anchors qualify — a role/text locator that survives un-slotted is
|
|
698
|
+
* ordinary UI text, and dropping it would cost a working fallback.
|
|
699
|
+
*/
|
|
700
|
+
/**
|
|
701
|
+
* Positional: this candidate finds an element by WHERE it sits (a structural
|
|
702
|
+
* path, or an index into a set of matches), not by what it is. Fine as a
|
|
703
|
+
* fallback for an action whose target is otherwise pinned; never sufficient
|
|
704
|
+
* on its own for a read that names a record.
|
|
705
|
+
*/
|
|
706
|
+
function positional(c) {
|
|
707
|
+
return c.kind === 'css' || c.kind === 'point' || c.nth !== undefined;
|
|
708
|
+
}
|
|
709
|
+
export function stranded(c, runValues) {
|
|
710
|
+
const fields = [];
|
|
711
|
+
if (c.kind === 'scoped')
|
|
712
|
+
fields.push(c.hasText);
|
|
713
|
+
// A NAME that is really a record reference. The rule used to stop at
|
|
714
|
+
// anchors, reasoning that "a role/text locator that survives un-slotted is
|
|
715
|
+
// ordinary UI text, and dropping it would cost a working fallback". True of
|
|
716
|
+
// ordinary UI text — false of a link whose accessible name IS the ticket
|
|
717
|
+
// ref. fwrd22l shipped six of these, `getByText('RD-1015')` and
|
|
718
|
+
// `getByRole('link', { name: 'RD-1015' })`, every one of them pinned to the
|
|
719
|
+
// record the RECORDING run created. A value that changes every run is not a
|
|
720
|
+
// working fallback, it is a fallback that has already stopped working.
|
|
721
|
+
else if (c.kind === 'role')
|
|
722
|
+
fields.push(c.name);
|
|
723
|
+
else if (c.kind === 'text')
|
|
724
|
+
fields.push(c.text);
|
|
725
|
+
else if (c.kind === 'label')
|
|
726
|
+
fields.push(c.label);
|
|
727
|
+
else if (c.kind === 'placeholder')
|
|
728
|
+
fields.push(c.placeholder);
|
|
729
|
+
// An ADDRESS welded out of a value this run minted. `ticket-link-t15` is
|
|
730
|
+
// the record's own id inside a test hook, and it survived every fix so far
|
|
731
|
+
// because this check only ever looked at anchors: fwrd20l and fwrd21l both
|
|
732
|
+
// shipped it. stableFirst demotes it to the tail, so it is not usually
|
|
733
|
+
// reached — but if the anchor and the structural path both miss, it
|
|
734
|
+
// resolves against whatever wears that id NEXT run, which on an app that
|
|
735
|
+
// reuses ids is a different record.
|
|
736
|
+
else if (c.kind === 'testid')
|
|
737
|
+
fields.push(c.value);
|
|
738
|
+
else if (c.kind === 'id' || c.kind === 'css')
|
|
739
|
+
fields.push(c.selector);
|
|
740
|
+
return fields.some((f) => runValues.some((v) => f.includes(v)));
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* An ADDRESS that is really a bookmark: a test hook or an id whose text
|
|
744
|
+
* carries a minted identifier, like `ticket-link-t15`. Next run the record is
|
|
745
|
+
* t16 and it matches nothing — or worse, on an app that reuses ids, it
|
|
746
|
+
* matches a DIFFERENT record.
|
|
747
|
+
*
|
|
748
|
+
* Distinct from `stranded`, which needs the value to be one the run
|
|
749
|
+
* demonstrably made. That is not enough here: the id an instruction MINTS is
|
|
750
|
+
* unknown while that instruction is compiling — repair-desk's create step
|
|
751
|
+
* never visits a t15 url, so nothing banks it — yet the testid recorded on
|
|
752
|
+
* that very step already has it welded in. fwrd19l shipped three, fwrd20l and
|
|
753
|
+
* fwrd21l two each, all of them below `stranded`'s reach.
|
|
754
|
+
*
|
|
755
|
+
* Structural, so it needs no provenance: an id-like token that is not a slot
|
|
756
|
+
* marker. Bare one- and two-digit numbers are excluded by `isIdLike`'s
|
|
757
|
+
* callers here, so ordinary hooks (`del-1`, `row-2`) are untouched.
|
|
758
|
+
*/
|
|
759
|
+
function bookmarked(c) {
|
|
760
|
+
if (c.kind !== 'testid' && c.kind !== 'id')
|
|
761
|
+
return false;
|
|
762
|
+
const text = c.kind === 'testid' ? c.value : c.selector;
|
|
763
|
+
return text
|
|
764
|
+
.split(/[^A-Za-z0-9{}]+/)
|
|
765
|
+
.filter(Boolean)
|
|
766
|
+
.some((tok) => !tok.includes('{{') && isIdLike(tok) && !/^\d{1,2}$/.test(tok));
|
|
767
|
+
}
|
|
768
|
+
function locatorValues(chain) {
|
|
769
|
+
const out = [];
|
|
770
|
+
for (const c of chain) {
|
|
771
|
+
if (c.kind === 'scoped')
|
|
772
|
+
out.push(c.hasText);
|
|
773
|
+
else if (c.kind === 'role')
|
|
774
|
+
out.push(c.name);
|
|
775
|
+
else if (c.kind === 'text')
|
|
776
|
+
out.push(c.text);
|
|
777
|
+
else if (c.kind === 'label')
|
|
778
|
+
out.push(c.label);
|
|
779
|
+
else if (c.kind === 'placeholder')
|
|
780
|
+
out.push(c.placeholder);
|
|
781
|
+
}
|
|
782
|
+
return out.filter(Boolean);
|
|
783
|
+
}
|
|
784
|
+
export function escapeRe(s) {
|
|
785
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
786
|
+
}
|
|
787
|
+
/** Replace every slot value in `text` by its "{{vN}}" marker, longest values first. */
|
|
788
|
+
export function substitute(text, slots) {
|
|
789
|
+
let out = text;
|
|
790
|
+
const byLength = [...slots].sort((a, b) => b[1].length - a[1].length);
|
|
791
|
+
for (const [name, value] of byLength) {
|
|
792
|
+
if (!value)
|
|
793
|
+
continue;
|
|
794
|
+
// Whole-token only, and a bare number never rewrites a selector index:
|
|
795
|
+
// a cost of "25" must not touch the 25 in `:nth-of-type(25)` or `nth=25`.
|
|
796
|
+
const numeric = /^\d+$/.test(value);
|
|
797
|
+
// A number inside a dotted run of numbers (127.0.0.1, 1.2.3) is part of
|
|
798
|
+
// that address or version, never a slot: fwod31 compiled the odoo start
|
|
799
|
+
// url as `http://127.0.0.{{d1}}:8069/...` after `cids=1` minted d1 = "1".
|
|
800
|
+
const re = numeric
|
|
801
|
+
? new RegExp(`(?<![A-Za-z0-9(=]|\\d\\.)${escapeRe(value)}(?![A-Za-z0-9)]|\\.\\d)`, 'g')
|
|
802
|
+
: new RegExp(`(?<![A-Za-z0-9])${escapeRe(value)}(?![A-Za-z0-9])`, 'g');
|
|
803
|
+
out = out.replace(re, `{{${name}}}`);
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
/** Inverse of substitute(): fill "{{vN}}" (caller param) and "{{dN}}" (derived,
|
|
808
|
+
* bound from the live run's own urls) markers from a param map. */
|
|
809
|
+
export function fillParams(text, params) {
|
|
810
|
+
return text.replace(/\{\{([vd]\d+)\}\}/g, (m, name) => (name in params ? params[name] : m));
|
|
811
|
+
}
|
|
812
|
+
export function substituteDeep(value, slots) {
|
|
813
|
+
if (typeof value === 'string')
|
|
814
|
+
return substitute(value, slots);
|
|
815
|
+
if (Array.isArray(value))
|
|
816
|
+
return value.map((v) => substituteDeep(v, slots));
|
|
817
|
+
if (value && typeof value === 'object') {
|
|
818
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substituteDeep(v, slots)]));
|
|
819
|
+
}
|
|
820
|
+
return value;
|
|
821
|
+
}
|
|
822
|
+
export function fillParamsDeep(value, params) {
|
|
823
|
+
if (typeof value === 'string')
|
|
824
|
+
return fillParams(value, params);
|
|
825
|
+
if (Array.isArray(value))
|
|
826
|
+
return value.map((v) => fillParamsDeep(v, params));
|
|
827
|
+
if (value && typeof value === 'object') {
|
|
828
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillParamsDeep(v, params)]));
|
|
829
|
+
}
|
|
830
|
+
return value;
|
|
831
|
+
}
|
|
832
|
+
export function slotsUsed(text) {
|
|
833
|
+
return [...new Set([...text.matchAll(/\{\{(v\d+)\}\}/g)].map((m) => m[1]))];
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* A url reduced to the shape that identifies its page: origin + path + hash
|
|
837
|
+
* route, with id-like segments replaced by `:id` and the query dropped.
|
|
838
|
+
* Slot values in the path become their markers, so a skill recorded on
|
|
839
|
+
* `/tickets/x7` matches `/tickets/{{v1}}` on the next run.
|
|
840
|
+
*/
|
|
841
|
+
export function urlPattern(url, slots = new Map()) {
|
|
842
|
+
let u;
|
|
843
|
+
try {
|
|
844
|
+
u = new URL(url);
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return url;
|
|
848
|
+
}
|
|
849
|
+
const norm = (p) => p
|
|
850
|
+
.split('/')
|
|
851
|
+
.map((seg) => {
|
|
852
|
+
if (!seg)
|
|
853
|
+
return seg;
|
|
854
|
+
const filled = substitute(safeDecode(seg), slots);
|
|
855
|
+
if (filled !== seg && filled.includes('{{'))
|
|
856
|
+
return filled;
|
|
857
|
+
return isIdLike(seg) ? ':id' : seg;
|
|
858
|
+
})
|
|
859
|
+
.join('/');
|
|
860
|
+
/**
|
|
861
|
+
* A hash-routed app puts its route in the fragment, in one of two shapes: a
|
|
862
|
+
* path ("#/orders/123") or a query-like state string
|
|
863
|
+
* ("#action=123&cids=1&menu_id=81", which is Odoo). Only the path shape was
|
|
864
|
+
* being reduced, because norm() splits on "/" and a query-shaped fragment
|
|
865
|
+
* has none — so the whole fragment survived verbatim, volatile ids and all.
|
|
866
|
+
*
|
|
867
|
+
* That made a segment's precondition unmatchable by anything but the run
|
|
868
|
+
* that recorded it: Odoo hands out a fresh action id per session, so a
|
|
869
|
+
* chain's second segment refused every replay and the work fell back to the
|
|
870
|
+
* model, run after run, with the store looking perfectly healthy.
|
|
871
|
+
*
|
|
872
|
+
* Keys are kept (they are what distinguishes one template from another) and
|
|
873
|
+
* id-like values reduced. Pairs are sorted because the app is free to emit
|
|
874
|
+
* them in any order between runs, and two orderings of the same state are
|
|
875
|
+
* the same page.
|
|
876
|
+
*/
|
|
877
|
+
const normHash = (raw) => {
|
|
878
|
+
const body = raw.split('?')[0];
|
|
879
|
+
if (!body)
|
|
880
|
+
return '';
|
|
881
|
+
if (body.startsWith('/') || !body.includes('='))
|
|
882
|
+
return '#' + norm(body);
|
|
883
|
+
const pairs = body
|
|
884
|
+
.split('&')
|
|
885
|
+
.filter(Boolean)
|
|
886
|
+
.map((pair) => {
|
|
887
|
+
const eq = pair.indexOf('=');
|
|
888
|
+
if (eq < 0)
|
|
889
|
+
return pair;
|
|
890
|
+
const key = pair.slice(0, eq);
|
|
891
|
+
const value = pair.slice(eq + 1);
|
|
892
|
+
const filled = substitute(safeDecode(value), slots);
|
|
893
|
+
if (filled !== value && filled.includes('{{'))
|
|
894
|
+
return `${key}=${filled}`;
|
|
895
|
+
return `${key}=${isIdLike(value) ? ':id' : value}`;
|
|
896
|
+
})
|
|
897
|
+
.sort();
|
|
898
|
+
return '#' + pairs.join('&');
|
|
899
|
+
};
|
|
900
|
+
const hash = u.hash && u.hash.length > 1 ? normHash(u.hash.slice(1)) : '';
|
|
901
|
+
// An opaque origin (chrome-error://, about:) prints as "null"; keep the
|
|
902
|
+
// url itself so a message says what the browser was actually showing.
|
|
903
|
+
if (u.origin === 'null')
|
|
904
|
+
return url;
|
|
905
|
+
const origin = u.protocol === 'file:' ? 'file://' : u.origin;
|
|
906
|
+
return `${origin}${norm(u.pathname)}${hash}`;
|
|
907
|
+
}
|
|
908
|
+
export function isIdLike(seg) {
|
|
909
|
+
if (/^\d+$/.test(seg))
|
|
910
|
+
return true;
|
|
911
|
+
if (/^[0-9a-f]{8,}$/i.test(seg))
|
|
912
|
+
return true;
|
|
913
|
+
if (/^[0-9a-f-]{32,}$/i.test(seg))
|
|
914
|
+
return true; // uuid
|
|
915
|
+
if (/^[A-Za-z]{1,4}[-_]?\d+$/.test(seg))
|
|
916
|
+
return true; // t15, RD-1015
|
|
917
|
+
if (/^[A-Za-z0-9_-]{16,}$/.test(seg) && /\d/.test(seg))
|
|
918
|
+
return true; // opaque tokens
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
function safeDecode(s) {
|
|
922
|
+
try {
|
|
923
|
+
return decodeURIComponent(s);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
return s;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function urlShapeOf(s) {
|
|
930
|
+
let u;
|
|
931
|
+
try {
|
|
932
|
+
u = new URL(s);
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
const origin = u.protocol === 'file:' ? 'file://' : u.origin;
|
|
938
|
+
const shape = {
|
|
939
|
+
origin,
|
|
940
|
+
path: u.pathname.split('/').filter(Boolean).map(safeDecode),
|
|
941
|
+
hashKind: 'none',
|
|
942
|
+
hashPath: [],
|
|
943
|
+
hashState: new Map(),
|
|
944
|
+
hashSlash: false,
|
|
945
|
+
};
|
|
946
|
+
const body = u.hash && u.hash.length > 1 ? u.hash.slice(1).split('?')[0] : '';
|
|
947
|
+
if (!body)
|
|
948
|
+
return shape;
|
|
949
|
+
if (body.startsWith('/') || !body.includes('=')) {
|
|
950
|
+
shape.hashKind = 'path';
|
|
951
|
+
shape.hashSlash = body.startsWith('/');
|
|
952
|
+
shape.hashPath = body.split('/').filter(Boolean).map(safeDecode);
|
|
953
|
+
return shape;
|
|
954
|
+
}
|
|
955
|
+
shape.hashKind = 'state';
|
|
956
|
+
for (const pair of body.split('&').filter(Boolean)) {
|
|
957
|
+
const eq = pair.indexOf('=');
|
|
958
|
+
if (eq < 0)
|
|
959
|
+
shape.hashState.set(pair, '');
|
|
960
|
+
else
|
|
961
|
+
shape.hashState.set(pair.slice(0, eq), safeDecode(pair.slice(eq + 1)));
|
|
962
|
+
}
|
|
963
|
+
return shape;
|
|
964
|
+
}
|
|
965
|
+
function serializeShape(s) {
|
|
966
|
+
let hash = '';
|
|
967
|
+
if (s.hashKind === 'path')
|
|
968
|
+
hash = '#' + (s.hashSlash ? '/' : '') + s.hashPath.join('/');
|
|
969
|
+
else if (s.hashKind === 'state') {
|
|
970
|
+
hash = '#' + [...s.hashState].map(([k, v]) => `${k}=${v}`).sort().join('&');
|
|
971
|
+
}
|
|
972
|
+
return `${s.origin}/${s.path.join('/')}${hash}`;
|
|
973
|
+
}
|
|
974
|
+
/** A pattern segment that stands for "any value here". */
|
|
975
|
+
function isWildcardSeg(seg) {
|
|
976
|
+
return seg === ':id' || seg === ':var' || /\{\{[\w.-]+\}\}/.test(seg);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Structural comparison of a stored pattern against a live url: `null` when
|
|
980
|
+
* the two are not even the same page shape (different origin, path length,
|
|
981
|
+
* route, or a required state key missing), otherwise the list of segments
|
|
982
|
+
* where a literal in the pattern disagrees with the live value — empty list
|
|
983
|
+
* means a match. Wildcard segments (`:id`, `:var`, unfilled `{{…}}`) match
|
|
984
|
+
* anything: matching consults the pattern's own markers, never a shape
|
|
985
|
+
* heuristic on the live value (that is what made isIdLike load-bearing).
|
|
986
|
+
*
|
|
987
|
+
* A query-shaped fragment is application STATE, and state accumulates (Odoo
|
|
988
|
+
* lands on "#cids=1" and has grown "#action=…&menu_id=…" by the next
|
|
989
|
+
* segment) — so it is a necessary condition: every pair the pattern names
|
|
990
|
+
* must be present, extra live pairs are allowed, and a pattern with no hash
|
|
991
|
+
* requires nothing of a live state fragment. A path-shaped fragment is a
|
|
992
|
+
* route and must match segment for segment.
|
|
993
|
+
*/
|
|
994
|
+
export function urlDiff(pattern, url) {
|
|
995
|
+
const p = urlShapeOf(pattern);
|
|
996
|
+
const l = urlShapeOf(url);
|
|
997
|
+
if (!p || !l)
|
|
998
|
+
return pattern === url ? [] : null;
|
|
999
|
+
if (p.origin !== l.origin || p.path.length !== l.path.length)
|
|
1000
|
+
return null;
|
|
1001
|
+
const diffs = [];
|
|
1002
|
+
p.path.forEach((seg, i) => {
|
|
1003
|
+
if (!isWildcardSeg(seg) && seg !== l.path[i])
|
|
1004
|
+
diffs.push({ where: 'path', index: i, expected: seg, actual: l.path[i] });
|
|
1005
|
+
});
|
|
1006
|
+
if (p.hashKind === 'path') {
|
|
1007
|
+
if (l.hashKind !== 'path' || p.hashPath.length !== l.hashPath.length)
|
|
1008
|
+
return null;
|
|
1009
|
+
p.hashPath.forEach((seg, i) => {
|
|
1010
|
+
if (!isWildcardSeg(seg) && seg !== l.hashPath[i])
|
|
1011
|
+
diffs.push({ where: 'hashPath', index: i, expected: seg, actual: l.hashPath[i] });
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
else if (p.hashKind === 'state') {
|
|
1015
|
+
if (l.hashKind !== 'state')
|
|
1016
|
+
return null;
|
|
1017
|
+
for (const [key, val] of p.hashState) {
|
|
1018
|
+
// A key the pattern only knows as a wildcard (`:id`, a {{dN}} it never
|
|
1019
|
+
// learned a value for) is app-minted state, not identity: odoo adds
|
|
1020
|
+
// `cids=1` to a url on one run and not the next, and fwod32's sign-in
|
|
1021
|
+
// stopped on every replay because the live url lacked it. A missing
|
|
1022
|
+
// key with a LITERAL value is still a different page.
|
|
1023
|
+
if (!l.hashState.has(key)) {
|
|
1024
|
+
if (isWildcardSeg(val))
|
|
1025
|
+
continue;
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
const lv = l.hashState.get(key);
|
|
1029
|
+
if (!isWildcardSeg(val) && val !== lv)
|
|
1030
|
+
diffs.push({ where: 'hashState', key, expected: val, actual: lv });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
else if (l.hashKind === 'path' && l.hashPath.length) {
|
|
1034
|
+
return null; // pattern names no route; the live url is on one
|
|
1035
|
+
}
|
|
1036
|
+
return diffs;
|
|
1037
|
+
}
|
|
1038
|
+
/** Whether a live url matches a stored pattern exactly (wildcards aside). */
|
|
1039
|
+
export function urlMatches(pattern, url, params = {}) {
|
|
1040
|
+
return urlDiff(fillParams(pattern, params), url)?.length === 0;
|
|
1041
|
+
}
|
|
1042
|
+
const MAX_SOFT_DIFFS = 2;
|
|
1043
|
+
/**
|
|
1044
|
+
* Mechanism-2 tolerance (PLAN-replay-v2): the live url is the same page
|
|
1045
|
+
* SHAPE as the pattern but 1–2 literal segments disagree — the signature of
|
|
1046
|
+
* an environment-minted identifier (a Grafana uid, an Odoo action id) that
|
|
1047
|
+
* this run minted differently. Returns the pattern with exactly the
|
|
1048
|
+
* disagreeing segments generalised to `:var`, for the caller to proceed
|
|
1049
|
+
* optimistically and PERSIST only once the run past this point succeeds —
|
|
1050
|
+
* the segment has then demonstrated volatility. Null when the urls differ in
|
|
1051
|
+
* shape, everything matched already, or a slot value broke segmentation.
|
|
1052
|
+
*/
|
|
1053
|
+
export function softUrlMatch(pattern, url, params = {}) {
|
|
1054
|
+
const filled = fillParams(pattern, params);
|
|
1055
|
+
const diffs = urlDiff(filled, url);
|
|
1056
|
+
if (!diffs || !diffs.length || diffs.length > MAX_SOFT_DIFFS)
|
|
1057
|
+
return null;
|
|
1058
|
+
// Generalise in the ORIGINAL pattern (markers intact). A param value
|
|
1059
|
+
// containing '/' would shift segment positions between the two — bail.
|
|
1060
|
+
const orig = urlShapeOf(pattern);
|
|
1061
|
+
const fld = urlShapeOf(filled);
|
|
1062
|
+
if (!orig || !fld || orig.path.length !== fld.path.length || orig.hashPath.length !== fld.hashPath.length)
|
|
1063
|
+
return null;
|
|
1064
|
+
for (const d of diffs) {
|
|
1065
|
+
if (d.where === 'path')
|
|
1066
|
+
orig.path[d.index] = ':var';
|
|
1067
|
+
else if (d.where === 'hashPath')
|
|
1068
|
+
orig.hashPath[d.index] = ':var';
|
|
1069
|
+
else
|
|
1070
|
+
orig.hashState.set(d.key, ':var');
|
|
1071
|
+
}
|
|
1072
|
+
return { generalised: serializeShape(orig), diffs };
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* The addressable parts of a url, labelled stably so a value observed at
|
|
1076
|
+
* record time can be re-extracted from the live run's url at the same
|
|
1077
|
+
* position: path segments `p<i>`, hash-route segments `h<i>`, hash-state
|
|
1078
|
+
* values `q.<key>`.
|
|
1079
|
+
*/
|
|
1080
|
+
export function urlParts(url) {
|
|
1081
|
+
const s = urlShapeOf(url);
|
|
1082
|
+
if (!s)
|
|
1083
|
+
return [];
|
|
1084
|
+
const out = [];
|
|
1085
|
+
s.path.forEach((value, i) => out.push({ label: `p${i}`, value }));
|
|
1086
|
+
s.hashPath.forEach((value, i) => out.push({ label: `h${i}`, value }));
|
|
1087
|
+
for (const [k, value] of s.hashState)
|
|
1088
|
+
out.push({ label: `q.${k}`, value });
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
1091
|
+
export function urlPart(url, label) {
|
|
1092
|
+
return urlParts(url).find((p) => p.label === label)?.value;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Page lines that describe the page in transit — spinners, progress bars,
|
|
1096
|
+
* and toasts (an alert that happened to be on screen, such as fwgr25's
|
|
1097
|
+
* "Error loading RSS feed", is not what the step did; a step's own alert is
|
|
1098
|
+
* carried by alertContains, which stays soft). Never a lasting effect.
|
|
1099
|
+
*/
|
|
1100
|
+
export const TRANSIENT_LINE = /^-?\s*(status|progressbar|alert)\b/;
|
|
1101
|
+
function expectationFor(step, slots) {
|
|
1102
|
+
if (!step.diff)
|
|
1103
|
+
return undefined;
|
|
1104
|
+
const out = {};
|
|
1105
|
+
if (step.diff.url)
|
|
1106
|
+
out.urlPattern = urlPattern(step.diff.url, slots);
|
|
1107
|
+
if (step.diff.alerts[0])
|
|
1108
|
+
out.alertContains = substitute(step.diff.alerts[0], slots).slice(0, 120);
|
|
1109
|
+
if (step.diff.added.length) {
|
|
1110
|
+
// A status or progress indicator is the page in transit, not where the
|
|
1111
|
+
// step left it: fwgr25's sign-in recorded `- status "Loading"` as its
|
|
1112
|
+
// click's only page change, and every replay — which caught the page
|
|
1113
|
+
// after the spinner — stopped there and recovered for 24 turns.
|
|
1114
|
+
const lasting = step.diff.added.filter((l) => !TRANSIENT_LINE.test(l));
|
|
1115
|
+
if (lasting.length)
|
|
1116
|
+
out.addedContains = lasting.slice(0, MAX_ADDED_LINES).map((l) => maskMinted(maskVolatile(substitute(l, slots))).slice(0, 120));
|
|
1117
|
+
}
|
|
1118
|
+
return Object.keys(out).length ? out : undefined;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* An effect expectation asserts what the PROCEDURE put on the page, and the
|
|
1122
|
+
* procedure only ever puts values there through its own fills and choices —
|
|
1123
|
+
* which are slots (`{{vN}}`, or a `{{dN}}` the app minted and a url showed)
|
|
1124
|
+
* by the time this runs, because substitute() went first. A control's
|
|
1125
|
+
* displayed value that is NOT a slot is therefore the app's: a default, a
|
|
1126
|
+
* computed figure, the id of the record the recording happened to make.
|
|
1127
|
+
* atelyr's project picker recorded `- combobox "…": 13f9pv52yozr` — the
|
|
1128
|
+
* recording's own project id — and every replay's project had another, so
|
|
1129
|
+
* both add-item steps stopped at "did not show … as it did when recorded".
|
|
1130
|
+
*
|
|
1131
|
+
* Provenance, not shape: no attempt is made to recognise an identifier by
|
|
1132
|
+
* how it looks, which breaks on the next app. The role and name still have
|
|
1133
|
+
* to match; only the value after the colon is wildcarded.
|
|
1134
|
+
*/
|
|
1135
|
+
export function maskMinted(line) {
|
|
1136
|
+
// `- role "name" [state]: value` — the value colon is the one after the
|
|
1137
|
+
// (quoted) name and any state markers, never one inside the name.
|
|
1138
|
+
return line.replace(/^(-?\s*\S+(?:\s+"(?:[^"\\]|\\.)*")?(?:\s+\[[^\]]*\])*)(:\s*)(\S.*?)\s*$/, (whole, head, sep, value) => value.includes('{{') ? whole : `${head}${sep}${WILDCARD}`);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Clock and calendar tokens in a recorded page line are the RECORDING's
|
|
1142
|
+
* moment, not the procedure's effect: kanboard names its due-date textbox
|
|
1143
|
+
* after the current minute ("09/03/2026 07:22"), so the fill's expectation
|
|
1144
|
+
* `- textbox "09/03/2026 07:22": {{v3}}` — HARD, because it carries the slot
|
|
1145
|
+
* — could never match a replay nine minutes later, and fwkb3 sent every
|
|
1146
|
+
* due-date step to recovery. The caller's own date is already a slot by the
|
|
1147
|
+
* time this runs (substitute() went first), so what is left is volatile and
|
|
1148
|
+
* becomes a `{{*}}` wildcard that lineShows() matches against anything short
|
|
1149
|
+
* of a line break.
|
|
1150
|
+
*/
|
|
1151
|
+
export { WILDCARD, maskVolatile } from '../shared/text.js';
|
|
1152
|
+
/** If a read's result equals one of the report's evidence values, label it with that key. */
|
|
1153
|
+
function readLabel(step, values) {
|
|
1154
|
+
if (step.tool !== 'read' && step.tool !== 'read_all')
|
|
1155
|
+
return undefined;
|
|
1156
|
+
// Carried from the report, where the name came from. Exact beats matching.
|
|
1157
|
+
if (step.label && step.label in values)
|
|
1158
|
+
return step.label;
|
|
1159
|
+
if (step.result === undefined)
|
|
1160
|
+
return undefined;
|
|
1161
|
+
let observed;
|
|
1162
|
+
try {
|
|
1163
|
+
observed = JSON.parse(step.result);
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
observed = step.result;
|
|
1167
|
+
}
|
|
1168
|
+
const flat = Array.isArray(observed) ? observed.map(String) : [String(observed)];
|
|
1169
|
+
// A list read is the LIST first: a read_all of three panel headings whose
|
|
1170
|
+
// first element happens to equal a per-item value was labelled with that
|
|
1171
|
+
// item (fwgr23: `read_all h2` → panel_title_request_rate) and the value the
|
|
1172
|
+
// report joined from the whole list — panel_titles — was never published.
|
|
1173
|
+
if (flat.length > 1) {
|
|
1174
|
+
const joined = new Set([', ', ' | ', '; ', ' ', '\n', ','].map((sep) => flat.map((f) => f.trim()).join(sep)));
|
|
1175
|
+
for (const [key, v] of Object.entries(values))
|
|
1176
|
+
if (joined.has(String(v).trim()))
|
|
1177
|
+
return key;
|
|
1178
|
+
}
|
|
1179
|
+
for (const [key, v] of Object.entries(values)) {
|
|
1180
|
+
const s = String(v).trim();
|
|
1181
|
+
if (s && flat.some((f) => f.trim() === s))
|
|
1182
|
+
return key;
|
|
1183
|
+
}
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
function firstUrl(steps) {
|
|
1187
|
+
const goto = steps.find((s) => s.tool === 'goto' && typeof s.args.url === 'string');
|
|
1188
|
+
return goto ? String(goto.args.url) : undefined;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Candidates whose selector text embeds something id-like (`ticket-link-t15`,
|
|
1192
|
+
* `#row-1042`) were unique on the recorded page but will name a *different*
|
|
1193
|
+
* element on the next run. They stay in the chain as a last resort; the
|
|
1194
|
+
* semantic candidates (role+name, label, text — now parameterised) go first.
|
|
1195
|
+
*/
|
|
1196
|
+
export function stableFirst(chain) {
|
|
1197
|
+
const volatile = (c) => {
|
|
1198
|
+
// Where it was is the last resort by definition: behind every name and
|
|
1199
|
+
// every path. fwgr27's store had it second, ahead of the anchored path.
|
|
1200
|
+
if (c.kind === 'point')
|
|
1201
|
+
return true;
|
|
1202
|
+
if (c.kind === 'testid' || c.kind === 'id' || c.kind === 'css') {
|
|
1203
|
+
const text = c.kind === 'testid' ? c.value : c.selector;
|
|
1204
|
+
return text
|
|
1205
|
+
.split(/[^A-Za-z0-9{}]+/)
|
|
1206
|
+
.filter(Boolean)
|
|
1207
|
+
.some((tok) => !tok.includes('{{') && isIdLike(tok) && !/^\d{1,2}$/.test(tok));
|
|
1208
|
+
}
|
|
1209
|
+
// A name that is nothing but an id ("RD-1017") names a record, not a
|
|
1210
|
+
// control: the same element next run will carry a different one.
|
|
1211
|
+
const name = c.kind === 'role' ? c.name : c.kind === 'text' ? c.text : '';
|
|
1212
|
+
return Boolean(name) && !name.includes('{{') && isIdLike(name.trim());
|
|
1213
|
+
};
|
|
1214
|
+
const stable = chain.filter((c) => !volatile(c));
|
|
1215
|
+
const points = chain.filter((c) => c.kind === 'point');
|
|
1216
|
+
const rest = chain.filter((c) => volatile(c) && c.kind !== 'point');
|
|
1217
|
+
return stable.length || points.length ? [...stable, ...rest, ...points] : chain;
|
|
1218
|
+
}
|
|
1219
|
+
/** Steps are structurally the same procedure: same tools, same primary locator shapes. */
|
|
1220
|
+
export function sameProcedure(a, b) {
|
|
1221
|
+
if (a.steps.length !== b.steps.length)
|
|
1222
|
+
return false;
|
|
1223
|
+
return a.steps.every((s, i) => {
|
|
1224
|
+
const t = b.steps[i];
|
|
1225
|
+
if (s.tool !== t.tool)
|
|
1226
|
+
return false;
|
|
1227
|
+
// Same procedure = same tools driven by the same KIND of primary locator,
|
|
1228
|
+
// regardless of the literal value (a label of 'Name' vs 'Name *', a role
|
|
1229
|
+
// name that is a parameter or a record id). This is what lets two runs'
|
|
1230
|
+
// "add a part" skills merge instead of fragmenting the store; the literal
|
|
1231
|
+
// differences are exactly the parameters the skills already carry.
|
|
1232
|
+
return locatorShape(s.locators.target?.[0]) === locatorShape(t.locators.target?.[0]);
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
/** A locator's structural shape for merge comparison: its kind, plus the stable
|
|
1236
|
+
* part of a css/id selector (tag/structure, not any embedded id). */
|
|
1237
|
+
function locatorShape(c) {
|
|
1238
|
+
if (!c)
|
|
1239
|
+
return 'none';
|
|
1240
|
+
if (c.kind === 'css' || c.kind === 'id') {
|
|
1241
|
+
// Drop id-like and numeric tokens so `#row-1042 > a` and `#row-77 > a` match.
|
|
1242
|
+
const skeleton = c.selector.replace(/[A-Za-z0-9_-]+/g, (tok) => (isIdLike(tok) ? '*' : tok));
|
|
1243
|
+
return `${c.kind}:${skeleton}`;
|
|
1244
|
+
}
|
|
1245
|
+
return c.kind;
|
|
1246
|
+
}
|
|
1247
|
+
const MAX_GROUP_LEN = 3;
|
|
1248
|
+
const LOOP_MAX_ITER_CAP = 50;
|
|
1249
|
+
// A loop iterates an ACTION over records (delete each part, ...). Its anchor
|
|
1250
|
+
// must be a click; a group may carry connector controls (dialog_expect) but
|
|
1251
|
+
// never an observation — folding consecutive read-backs into a loop, which they
|
|
1252
|
+
// superficially resemble (same shape, per-record ids), is a bug: reads observe,
|
|
1253
|
+
// they do not iterate.
|
|
1254
|
+
const LOOP_ANCHOR_TOOLS = new Set(['click', 'dblclick', 'modifier_click', 'right_click']);
|
|
1255
|
+
const NON_LOOP_TOOLS = new Set(['read', 'read_all', 'eval', 'screenshot']);
|
|
1256
|
+
/**
|
|
1257
|
+
* Collapse a run of consecutive, identical control steps that carry no target
|
|
1258
|
+
* (chiefly `dialog_expect`, which the agent often re-arms redundantly) into
|
|
1259
|
+
* one. Arming the same handler twice is a no-op, but the extra copies land
|
|
1260
|
+
* unevenly between otherwise-identical action groups and stop foldLoops from
|
|
1261
|
+
* seeing the repetition. Only no-locator steps with byte-identical args are
|
|
1262
|
+
* touched, so real actions are never merged.
|
|
1263
|
+
*/
|
|
1264
|
+
export function coalesceControls(steps) {
|
|
1265
|
+
const out = [];
|
|
1266
|
+
for (const step of steps) {
|
|
1267
|
+
const prev = out[out.length - 1];
|
|
1268
|
+
const noTarget = !step.locators.target?.length && !step.locators.source?.length;
|
|
1269
|
+
if (prev && noTarget && prev.tool === step.tool && !prev.locators.target?.length && JSON.stringify(prev.args) === JSON.stringify(step.args)) {
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
out.push(step);
|
|
1273
|
+
}
|
|
1274
|
+
return out;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Drop a navigation whose destination another navigation immediately
|
|
1278
|
+
* replaces. The agent explores — fwod6's step-01 skill recorded `goto /web`,
|
|
1279
|
+
* a hand-built `#action=&…&menu_id=` url, then `goto /web?cids=1` — and a
|
|
1280
|
+
* procedure that re-walks the search is not the procedure, just its history.
|
|
1281
|
+
* Only strictly adjacent navigations qualify: once anything else ran, the
|
|
1282
|
+
* intermediate page may have been load-bearing (a session bootstrap, a
|
|
1283
|
+
* redirect that set a cookie), and this cannot tell from the outside.
|
|
1284
|
+
*/
|
|
1285
|
+
export function dropSupersededNavigation(steps) {
|
|
1286
|
+
return steps.filter((step, i) => !(step.tool === 'goto' && steps[i + 1]?.tool === 'goto'));
|
|
1287
|
+
}
|
|
1288
|
+
/** Button names that dismiss a dialog without acting — UI convention, not app knowledge. */
|
|
1289
|
+
const DISMISSAL = /^(cancel|close|dismiss|no|not now|back|keep editing)$/i;
|
|
1290
|
+
/**
|
|
1291
|
+
* Drop a dialog the recording opened and immediately dismissed. fwgr25's
|
|
1292
|
+
* create step recorded Exit edit → "Discard changes to dashboard?" → Cancel,
|
|
1293
|
+
* because the RECORDING had unsaved edits when the model clicked Exit edit
|
|
1294
|
+
* and then thought better of it. The pair did nothing to the app, but a
|
|
1295
|
+
* replay with nothing unsaved has no dialog to cancel: Exit edit simply
|
|
1296
|
+
* exits, and every step that expected to still be in edit mode fails (5/18
|
|
1297
|
+
* on both replays, 23–49 model turns). Evidence-based: step N's recorded
|
|
1298
|
+
* effect includes a dialog, step N+1 clicks a button that dialog listed,
|
|
1299
|
+
* that button is named as a dismissal, and step N+1 recorded no page change
|
|
1300
|
+
* of its own. A confirm ("Discard", "Delete", "Save") never matches.
|
|
1301
|
+
*/
|
|
1302
|
+
export function dropDismissedDialogs(steps) {
|
|
1303
|
+
const out = [];
|
|
1304
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1305
|
+
const opener = steps[i];
|
|
1306
|
+
const closer = steps[i + 1];
|
|
1307
|
+
const added = opener.expect?.addedContains ?? [];
|
|
1308
|
+
const opensDialog = added.some((l) => /^-\s*dialog\b/.test(l));
|
|
1309
|
+
if (opensDialog && closer?.tool === 'click' && !closer.expect?.addedContains?.length) {
|
|
1310
|
+
const primary = (closer.locators.target ?? [])[0];
|
|
1311
|
+
const name = primary?.kind === 'role' && primary.role === 'button' ? primary.name : primary?.kind === 'text' ? primary.text : undefined;
|
|
1312
|
+
const listed = name !== undefined && added.some((l) => l.includes(`button "${name}"`));
|
|
1313
|
+
if (name && listed && DISMISSAL.test(name.trim())) {
|
|
1314
|
+
i += 1; // skip the closer too
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
out.push(opener);
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
}
|
|
1322
|
+
/** Replace id-like whole tokens in a string with `*`, so per-record ids collapse. */
|
|
1323
|
+
function stripIds(text) {
|
|
1324
|
+
return text
|
|
1325
|
+
.split(/([^A-Za-z0-9]+)/)
|
|
1326
|
+
.map((tok) => (/^[A-Za-z0-9]+$/.test(tok) && isIdLike(tok) ? '*' : tok))
|
|
1327
|
+
.join('');
|
|
1328
|
+
}
|
|
1329
|
+
/** A candidate's identity with per-record ids blanked — its shape AND its name/value. */
|
|
1330
|
+
function candSkeleton(c) {
|
|
1331
|
+
switch (c.kind) {
|
|
1332
|
+
case 'role':
|
|
1333
|
+
return `role:${c.role}:${stripIds(c.name ?? '')}`;
|
|
1334
|
+
case 'text':
|
|
1335
|
+
return `text:${stripIds(c.text ?? '')}`;
|
|
1336
|
+
case 'label':
|
|
1337
|
+
return `label:${stripIds(c.label ?? '')}`;
|
|
1338
|
+
case 'placeholder':
|
|
1339
|
+
return `placeholder:${stripIds(c.placeholder ?? '')}`;
|
|
1340
|
+
case 'testid':
|
|
1341
|
+
return `testid:${stripIds(c.value)}`;
|
|
1342
|
+
default:
|
|
1343
|
+
return locatorShape(c);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
function chainSkeleton(chain) {
|
|
1347
|
+
return (chain ?? []).map(candSkeleton).join('|');
|
|
1348
|
+
}
|
|
1349
|
+
/** Two steps are the same procedure applied to (possibly) a different record. */
|
|
1350
|
+
function loopEquivalent(a, b) {
|
|
1351
|
+
if (a.tool !== b.tool || a.tool === 'loop')
|
|
1352
|
+
return false;
|
|
1353
|
+
// Same procedure means the same TYPED values too: two edits that set
|
|
1354
|
+
// different quantities are two steps, not one loop replaying the first
|
|
1355
|
+
// group's value on every record. Targets are per-record by design.
|
|
1356
|
+
const typed = (s) => JSON.stringify(Object.fromEntries(Object.entries(s.args).filter(([k]) => k !== 'target' && k !== 'source')));
|
|
1357
|
+
return typed(a) === typed(b) && chainSkeleton(a.locators.target) === chainSkeleton(b.locators.target) && chainSkeleton(a.locators.source) === chainSkeleton(b.locators.source);
|
|
1358
|
+
}
|
|
1359
|
+
/** True when two groups differ in a *raw* id somewhere — proof they act on distinct records, not an accidental repeat. */
|
|
1360
|
+
function differsInRawId(a, b) {
|
|
1361
|
+
const raw = (g) => JSON.stringify(g.map((s) => [s.locators.target ?? [], s.locators.source ?? []]));
|
|
1362
|
+
return raw(a) !== raw(b);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Collapse a run of ≥2 consecutive, structurally-identical action groups that
|
|
1366
|
+
* differ only in a per-record id — the signature of iterating over a list (e.g.
|
|
1367
|
+
* deleting each part in turn) — into a single `loop` step. The loop repeats its
|
|
1368
|
+
* body while the body's first target still matches an element, so a replay on a
|
|
1369
|
+
* list of a different length still clears it, instead of hard-coding the count
|
|
1370
|
+
* seen when recording. Conservative by construction: distinct fields (a title
|
|
1371
|
+
* vs a customer box) have different skeletons and never fold, and an accidental
|
|
1372
|
+
* identical repeat (no id difference) is left alone.
|
|
1373
|
+
*/
|
|
1374
|
+
export function foldLoops(steps) {
|
|
1375
|
+
const out = [];
|
|
1376
|
+
let i = 0;
|
|
1377
|
+
while (i < steps.length) {
|
|
1378
|
+
let folded = false;
|
|
1379
|
+
// Prefer the smallest group length so [del, confirm] folds before [del]×2.
|
|
1380
|
+
for (let len = 1; len <= MAX_GROUP_LEN && i + 2 * len <= steps.length; len++) {
|
|
1381
|
+
const group = steps.slice(i, i + len);
|
|
1382
|
+
if (group.some((s) => s.tool === 'loop' || NON_LOOP_TOOLS.has(s.tool)))
|
|
1383
|
+
continue;
|
|
1384
|
+
// The body must anchor on a repeatable, locate-able ACTION (a click on a
|
|
1385
|
+
// record's control), never an observation.
|
|
1386
|
+
if (!LOOP_ANCHOR_TOOLS.has(group[0].tool) || !group[0].locators.target?.length)
|
|
1387
|
+
continue;
|
|
1388
|
+
let count = 1;
|
|
1389
|
+
const groups = [group];
|
|
1390
|
+
while (i + (count + 1) * len <= steps.length) {
|
|
1391
|
+
const next = steps.slice(i + count * len, i + (count + 1) * len);
|
|
1392
|
+
if (!group.every((s, k) => loopEquivalent(s, next[k])))
|
|
1393
|
+
break;
|
|
1394
|
+
groups.push(next);
|
|
1395
|
+
count++;
|
|
1396
|
+
}
|
|
1397
|
+
if (count < 2)
|
|
1398
|
+
continue;
|
|
1399
|
+
// Require a real per-record id difference across at least one pair, so we
|
|
1400
|
+
// only fold genuine iteration, never a control legitimately hit twice.
|
|
1401
|
+
if (!groups.slice(1).some((g) => differsInRawId(group, g)))
|
|
1402
|
+
continue;
|
|
1403
|
+
out.push({
|
|
1404
|
+
tool: 'loop',
|
|
1405
|
+
args: {},
|
|
1406
|
+
locators: {},
|
|
1407
|
+
body: group,
|
|
1408
|
+
while: group[0].locators.target,
|
|
1409
|
+
max: Math.min(count * 2 + 3, LOOP_MAX_ITER_CAP),
|
|
1410
|
+
});
|
|
1411
|
+
i += count * len;
|
|
1412
|
+
folded = true;
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
if (!folded)
|
|
1416
|
+
out.push(steps[i++]);
|
|
1417
|
+
}
|
|
1418
|
+
return out;
|
|
1419
|
+
}
|
|
1420
|
+
//# sourceMappingURL=compile.js.map
|