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,2084 @@
|
|
|
1
|
+
import { TRANSIENT_LINE } from '../skills/compile.js';
|
|
2
|
+
import { OPENER_LINE, consequentialExpectations, waitsForAbsence } from '../skills/replay.js';
|
|
3
|
+
import { candidateSources, chainSource, maskedMatcherSource, matcherSource, stringSource } from './locators.js';
|
|
4
|
+
import { diagnosticNote, formatDiagnostic } from './diagnostics.js';
|
|
5
|
+
/**
|
|
6
|
+
* The diagnostics that belong ABOVE a step's body, per flow step id.
|
|
7
|
+
*
|
|
8
|
+
* Only the record-level ones: a demoted pin and a record-time no-op are both
|
|
9
|
+
* "this recording is wrong", which is exactly what a reader of the generated
|
|
10
|
+
* file cannot otherwise tell from a locator error. A rethread warning is about
|
|
11
|
+
* a binding, not about the step's existence, and belongs in the compile
|
|
12
|
+
* report, not in every reviewer's diff.
|
|
13
|
+
*/
|
|
14
|
+
const FLAGGED = ['demoted-pin', 'noop-step'];
|
|
15
|
+
function flaggedByStep(diagnostics) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
for (const d of diagnostics ?? []) {
|
|
18
|
+
if (!d.step || !FLAGGED.includes(d.code))
|
|
19
|
+
continue;
|
|
20
|
+
out.set(d.step, [...(out.get(d.step) ?? []), d]);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The one-line note a FLAGGED step's failure carries, or none.
|
|
26
|
+
*
|
|
27
|
+
* WHY. fwod34's 08-open is pinned to a demoted skill whose first action clicks
|
|
28
|
+
* a Cancel button that no longer exists, and the emitted spec said only "none
|
|
29
|
+
* of 3 recorded locators resolved" — which reads as app drift and sends the
|
|
30
|
+
* reader hunting for a changed selector. The step's own error is the one place
|
|
31
|
+
* the reader is guaranteed to look, so the reason and the fix go there too.
|
|
32
|
+
* Only a demoted pin: a no-op step still replays, so failing it with that note
|
|
33
|
+
* would be a guess about a failure it did not cause.
|
|
34
|
+
*/
|
|
35
|
+
function stepNote(flagged) {
|
|
36
|
+
const d = flagged?.find((x) => x.code === 'demoted-pin');
|
|
37
|
+
return d ? diagnosticNote(d) : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Markers LIFT reads the FLOW constant back out of. Changing either breaks the round trip. */
|
|
40
|
+
const BEGIN_MARKER = '// @sitelooper-flow-begin';
|
|
41
|
+
const END_MARKER = '// @sitelooper-flow-end';
|
|
42
|
+
/**
|
|
43
|
+
* What the inlined `pick` waits, mirroring replay's own resolve window
|
|
44
|
+
* (resolveWaitMs / RESOLVE_POLL_MS): a spec has no observation turns, and an
|
|
45
|
+
* app that renders a beat late is the normal case, not a failure.
|
|
46
|
+
*/
|
|
47
|
+
const PICK_WAIT_MS = 3_000;
|
|
48
|
+
const PICK_POLL_MS = 100;
|
|
49
|
+
/**
|
|
50
|
+
* What the inlined `urlPartsWhen` waits for the url a step navigated TO.
|
|
51
|
+
*
|
|
52
|
+
* Longer than `pick`'s window because that is what replay effectively allows a
|
|
53
|
+
* url: runOneStep lets the DOM go quiet first (settleDom, capped at 2s) and
|
|
54
|
+
* only then does `expectedUrl` poll for another resolveWaitMs (3s) before it
|
|
55
|
+
* judges the url wrong. A spec that gave up after 3s bound an EMPTY part and
|
|
56
|
+
* built a pattern that could never match — odoo populates `action=` late, and
|
|
57
|
+
* fwod34's 01-signin asserted `#action=&cids=1&menu_id=81` against a browser
|
|
58
|
+
* that was, a beat later, exactly where the recording left it.
|
|
59
|
+
*/
|
|
60
|
+
const URL_WAIT_MS = 5_000;
|
|
61
|
+
/** How long a segment's identity marker has to appear before the segment is on the wrong record. */
|
|
62
|
+
const IDENTITY_WAIT_MS = 5_000;
|
|
63
|
+
/**
|
|
64
|
+
* What the inlined `settle` waits, mirroring replay's `settleDom` constants
|
|
65
|
+
* exactly (SETTLE_QUIET_MS / SETTLE_MAX_MS / SETTLE_PROBE_MS in
|
|
66
|
+
* src/skills/replay.ts): a page shows it is busy within the probe, must then
|
|
67
|
+
* be mutation-free for the quiet window, and is called quiet after the cap
|
|
68
|
+
* whatever it is still doing.
|
|
69
|
+
*/
|
|
70
|
+
const SETTLE_QUIET_MS = 250;
|
|
71
|
+
const SETTLE_MAX_MS = 2_000;
|
|
72
|
+
const SETTLE_PROBE_MS = 60;
|
|
73
|
+
/** Playwright's own default; only a different timeout is worth carrying over. */
|
|
74
|
+
const DEFAULT_WAIT_MS = 10_000;
|
|
75
|
+
/** How long a recorded template may run inside a generated comment. */
|
|
76
|
+
const COMMENT_CLIP = 120;
|
|
77
|
+
/** How far a chain's `.or(` continuation lines sit in from the statement that opens them. */
|
|
78
|
+
const CONT_INDENT = ' ';
|
|
79
|
+
/** Default iterations a folded loop may run when the recording set no cap. */
|
|
80
|
+
const DEFAULT_LOOP_MAX = 20;
|
|
81
|
+
const clip = (s, max = COMMENT_CLIP) => (s.length <= max ? s : s.slice(0, max) + '…');
|
|
82
|
+
/** One line of comment text: no newlines, and nothing that would close a doc comment. */
|
|
83
|
+
const commentSafe = (s) => clip(String(s).replace(/\s+/g, ' ').replace(/\*\//g, '* /').trim(), 200);
|
|
84
|
+
/** A slot marker anywhere in the text — the mark of a value this run supplies. */
|
|
85
|
+
const SLOT_LINE = /\{\{v\d+\}\}/;
|
|
86
|
+
/** How a recorded slot renders inside generated source: as the step's own param. */
|
|
87
|
+
const slotAsParam = (s) => '${p.' + s + '}';
|
|
88
|
+
/** A JS single-quoted literal (mirrors recorder.q, which this module cannot import without pulling in playwright types). */
|
|
89
|
+
function q(value) {
|
|
90
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '').replace(/\n/g, '\\n')}'`;
|
|
91
|
+
}
|
|
92
|
+
/** An object key as source: bare when it is an identifier, quoted otherwise. */
|
|
93
|
+
function key(name) {
|
|
94
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : q(name);
|
|
95
|
+
}
|
|
96
|
+
/** Literal text inside a template literal: a backtick or a `${` would end it or open a hole. */
|
|
97
|
+
function templateSafe(text) {
|
|
98
|
+
return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
99
|
+
}
|
|
100
|
+
function escapeRe(s) {
|
|
101
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Per-tier budget for the inlined `click` helper, mirroring the 10s
|
|
105
|
+
* `timeout` tools.ts hands robustClick, but cut so that ALL THREE tiers plus
|
|
106
|
+
* the scroll ahead of tier 2 fit well inside a default 60s Playwright test:
|
|
107
|
+
* grafana's viz-picker burned the whole 60s on tier 1 alone before this.
|
|
108
|
+
*/
|
|
109
|
+
const CLICK_TIER_MS = 5_000;
|
|
110
|
+
/**
|
|
111
|
+
* What the inlined `fill` waits, mirroring reactSafeFill in
|
|
112
|
+
* src/daemon/inputs.ts: the field must be visible (its own 10s), and the focus
|
|
113
|
+
* click is a best-effort 5s that some widgets need and most ignore.
|
|
114
|
+
*/
|
|
115
|
+
const FILL_WAIT_MS = 10_000;
|
|
116
|
+
const FILL_FOCUS_MS = 5_000;
|
|
117
|
+
/**
|
|
118
|
+
* The two settles inside the editor recipe, taken from `editorSetValue` in
|
|
119
|
+
* src/skills/components.ts verbatim: 400ms for the insert to be absorbed
|
|
120
|
+
* before Escape, 200ms after the blur for the commit the app hangs off it.
|
|
121
|
+
*/
|
|
122
|
+
const EDITOR_SETTLE_MS = 400;
|
|
123
|
+
const EDITOR_BLUR_SETTLE_MS = 200;
|
|
124
|
+
/** The inlined helpers, keyed by the token that proves the body (or another helper) uses one. */
|
|
125
|
+
const HELPERS = [
|
|
126
|
+
{
|
|
127
|
+
// Shared by `pick` and `urlPartsWhen`: one poll cadence. The token is the
|
|
128
|
+
// POLL constant, because that is the one BOTH of them name.
|
|
129
|
+
token: 'PICK_POLL_MS',
|
|
130
|
+
source: [`const PICK_WAIT_MS = ${PICK_WAIT_MS};`, `const PICK_POLL_MS = ${PICK_POLL_MS};`],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
token: 'await settle(',
|
|
134
|
+
source: [
|
|
135
|
+
`const SETTLE_QUIET_MS = ${SETTLE_QUIET_MS};`,
|
|
136
|
+
`const SETTLE_MAX_MS = ${SETTLE_MAX_MS};`,
|
|
137
|
+
`const SETTLE_PROBE_MS = ${SETTLE_PROBE_MS};`,
|
|
138
|
+
'/**',
|
|
139
|
+
' * Let the DOM go quiet before this step looks at the page at all.',
|
|
140
|
+
' *',
|
|
141
|
+
" * WHICH REPLAY RULE THIS MIRRORS. runOneStep's very first act, before the",
|
|
142
|
+
' * already-in-effect check and before any chain is resolved, is',
|
|
143
|
+
' * `await settleDom(page)` (src/skills/replay.ts): the agent that recorded',
|
|
144
|
+
' * the flow had observation turns, which were implicit waits, and a replay',
|
|
145
|
+
' * — or a spec — has none. Same constants: a page gets SETTLE_PROBE_MS to',
|
|
146
|
+
' * show it is busy at all, then must be mutation-free for SETTLE_QUIET_MS,',
|
|
147
|
+
' * and is called quiet regardless after SETTLE_MAX_MS. Instant on a static',
|
|
148
|
+
' * page, which is why it can sit on every step.',
|
|
149
|
+
' *',
|
|
150
|
+
' * WHY EVERY STEP NEEDS IT, not just the resolving ones. The odoo recording',
|
|
151
|
+
" * toggles the home menu open and shut seven times before clicking `Sales`.",
|
|
152
|
+
' * Each toggle click is an opener, so each is guarded by "is the recorded',
|
|
153
|
+
' * popup already showing?" — and asked in the same tick as the CLOSING click',
|
|
154
|
+
' * that preceded it, that question is answered off a DOM still mid-transition:',
|
|
155
|
+
' * the menu is on its way out but still visible, the guard says "already in',
|
|
156
|
+
' * effect", the opening click is skipped, and eight steps later there is no',
|
|
157
|
+
" * `Sales` menuitem because the menu is shut. Replay never sees this, because",
|
|
158
|
+
' * its own presence check happens only AFTER settleDom. So the settle goes',
|
|
159
|
+
' * ahead of the guard, not merely ahead of the action.',
|
|
160
|
+
' *',
|
|
161
|
+
' * Errors are swallowed: a page that is navigating or detached cannot be',
|
|
162
|
+
' * scripted, and that is the locator resolution\'s failure to report, not this.',
|
|
163
|
+
' */',
|
|
164
|
+
'async function settle(page: Page): Promise<void> {',
|
|
165
|
+
' try {',
|
|
166
|
+
' await page.evaluate(',
|
|
167
|
+
' ({ probe, quiet, max }: { probe: number; quiet: number; max: number }) =>',
|
|
168
|
+
' new Promise<void>((resolve) => {',
|
|
169
|
+
' let timer = setTimeout(resolve, probe);',
|
|
170
|
+
' const stop = setTimeout(() => {',
|
|
171
|
+
' observer.disconnect();',
|
|
172
|
+
' resolve();',
|
|
173
|
+
' }, max);',
|
|
174
|
+
' const observer = new MutationObserver(() => {',
|
|
175
|
+
' clearTimeout(timer);',
|
|
176
|
+
' timer = setTimeout(() => {',
|
|
177
|
+
' observer.disconnect();',
|
|
178
|
+
' clearTimeout(stop);',
|
|
179
|
+
' resolve();',
|
|
180
|
+
' }, quiet);',
|
|
181
|
+
' });',
|
|
182
|
+
' observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });',
|
|
183
|
+
' }),',
|
|
184
|
+
' { probe: SETTLE_PROBE_MS, quiet: SETTLE_QUIET_MS, max: SETTLE_MAX_MS },',
|
|
185
|
+
' );',
|
|
186
|
+
' } catch {',
|
|
187
|
+
' // navigating / detached — the locator resolution will report it',
|
|
188
|
+
' }',
|
|
189
|
+
'}',
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
token: 'urlPart(',
|
|
194
|
+
source: [
|
|
195
|
+
'/**',
|
|
196
|
+
" * The addressable parts of a url, labelled as the recorder labels them:",
|
|
197
|
+
' * path segments `p<i>`, hash-route segments `h<i>`, hash-state values `q.<key>`.',
|
|
198
|
+
' * Inlined so the spec depends on nothing but Playwright.',
|
|
199
|
+
' */',
|
|
200
|
+
'function urlPart(url: string, label: string): string {',
|
|
201
|
+
' let u: URL;',
|
|
202
|
+
' try {',
|
|
203
|
+
' u = new URL(url);',
|
|
204
|
+
' } catch {',
|
|
205
|
+
" return '';",
|
|
206
|
+
' }',
|
|
207
|
+
' const dec = (s: string) => {',
|
|
208
|
+
' try {',
|
|
209
|
+
' return decodeURIComponent(s);',
|
|
210
|
+
' } catch {',
|
|
211
|
+
' return s;',
|
|
212
|
+
' }',
|
|
213
|
+
' };',
|
|
214
|
+
' const parts: Record<string, string> = {};',
|
|
215
|
+
" u.pathname.split('/').filter(Boolean).forEach((v, i) => (parts[`p${i}`] ??= dec(v)));",
|
|
216
|
+
" const body = u.hash.length > 1 ? u.hash.slice(1).split('?')[0] : '';",
|
|
217
|
+
" if (body.startsWith('/') || (body && !body.includes('='))) {",
|
|
218
|
+
" body.split('/').filter(Boolean).forEach((v, i) => (parts[`h${i}`] ??= dec(v)));",
|
|
219
|
+
' } else if (body) {',
|
|
220
|
+
" for (const pair of body.split('&').filter(Boolean)) {",
|
|
221
|
+
" const eq = pair.indexOf('=');",
|
|
222
|
+
' const k = eq < 0 ? pair : pair.slice(0, eq);',
|
|
223
|
+
" parts[`q.${k}`] ??= eq < 0 ? '' : dec(pair.slice(eq + 1));",
|
|
224
|
+
' }',
|
|
225
|
+
' }',
|
|
226
|
+
" return parts[label] ?? '';",
|
|
227
|
+
'}',
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
token: 'urlPartsWhen(',
|
|
232
|
+
source: [
|
|
233
|
+
`const URL_WAIT_MS = ${URL_WAIT_MS};`,
|
|
234
|
+
'/**',
|
|
235
|
+
' * The url parts a step mints, read AFTER the navigation it started has landed.',
|
|
236
|
+
' *',
|
|
237
|
+
' * WHICH REPLAY RULE THIS MIRRORS. runOneStep captures the url before the',
|
|
238
|
+
' * action and, when the action changed it, awaits settleDom before binding',
|
|
239
|
+
" * the step's derived values — the value a spec needs is the one on the url",
|
|
240
|
+
' * the step navigated TO, and `page.url()` read in the same tick as the',
|
|
241
|
+
' * click still says where the page came FROM. Bound empty, every pattern',
|
|
242
|
+
' * built from these parts (`toHaveURL`, an identity marker) can only fail.',
|
|
243
|
+
' *',
|
|
244
|
+
' * ALL of them together, not one at a time, because they are read into ONE',
|
|
245
|
+
' * pattern: an app is free to populate its state fragment key by key (odoo',
|
|
246
|
+
' * lands on `#cids=1&menu_id=81` and adds `action=` a beat later), so a part',
|
|
247
|
+
' * that binds the instant IT is non-empty can be bound off a half-built url',
|
|
248
|
+
' * while its neighbour is still missing. The step is not where it was',
|
|
249
|
+
' * recorded until every part is there.',
|
|
250
|
+
' *',
|
|
251
|
+
' * A spec has no settleDom, so it polls on `pick`\'s cadence within the window',
|
|
252
|
+
' * replay effectively allows a url (URL_WAIT_MS), and takes one last reading',
|
|
253
|
+
' * at the deadline: a step whose url genuinely does not change (the parts were',
|
|
254
|
+
' * already there) must still bind what is there rather than hang or throw.',
|
|
255
|
+
' */',
|
|
256
|
+
"async function urlPartsWhen(page: Page, labels: string[], urlBefore = ''): Promise<string[]> {",
|
|
257
|
+
' const read = (url: string) => labels.map((label) => urlPart(url, label));',
|
|
258
|
+
' for (let waited = 0; waited < URL_WAIT_MS; waited += PICK_POLL_MS) {',
|
|
259
|
+
' const url = page.url();',
|
|
260
|
+
' const values = read(url);',
|
|
261
|
+
' if (url !== urlBefore && values.every(Boolean)) {',
|
|
262
|
+
' // The parts are there — but an app is free to redirect AGAIN from',
|
|
263
|
+
' // the url that first carried them, and the value that matters is',
|
|
264
|
+
' // the one on the url the step SETTLES on. Replay never sees this,',
|
|
265
|
+
' // because it binds derived values only after settleDom absorbs the',
|
|
266
|
+
' // whole redirect chain. So: let the DOM go quiet, and if the url',
|
|
267
|
+
' // moved while it did, settle once more before reading.',
|
|
268
|
+
' for (let pass = 0; pass < 2; pass++) {',
|
|
269
|
+
' const before = page.url();',
|
|
270
|
+
' await settle(page);',
|
|
271
|
+
' if (page.url() === before) break;',
|
|
272
|
+
' }',
|
|
273
|
+
' return read(page.url());',
|
|
274
|
+
' }',
|
|
275
|
+
' await page.waitForTimeout(PICK_POLL_MS);',
|
|
276
|
+
' }',
|
|
277
|
+
' return read(page.url());',
|
|
278
|
+
'}',
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
token: 'urlPartWhen(',
|
|
283
|
+
source: [
|
|
284
|
+
'/** One part, on the same terms. `urlBefore` is omitted where no action of this step',
|
|
285
|
+
" * moved the page: then the wait is simply for the part to be there at all, which is",
|
|
286
|
+
' * what the flow runner does before it publishes a step\'s url outputs (consumedUrlOutputs). */',
|
|
287
|
+
"async function urlPartWhen(page: Page, label: string, urlBefore = ''): Promise<string> {",
|
|
288
|
+
' return (await urlPartsWhen(page, [label], urlBefore))[0];',
|
|
289
|
+
'}',
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
token: 'hashState(',
|
|
294
|
+
source: [
|
|
295
|
+
'/**',
|
|
296
|
+
' * A query-shaped hash fragment (`#action=1&cids=2`, which is odoo) as its',
|
|
297
|
+
' * key/value state, or null when the fragment is a route (`#/orders/7`) or',
|
|
298
|
+
' * absent. Mirrors urlShapeOf in src/skills/compile.ts, decoding included.',
|
|
299
|
+
' */',
|
|
300
|
+
'function hashState(href: string): Map<string, string> | null {',
|
|
301
|
+
' let u: URL;',
|
|
302
|
+
' try {',
|
|
303
|
+
' u = new URL(href);',
|
|
304
|
+
' } catch {',
|
|
305
|
+
' return null;',
|
|
306
|
+
' }',
|
|
307
|
+
" const body = u.hash.length > 1 ? u.hash.slice(1).split('?')[0] : '';",
|
|
308
|
+
" if (!body || body.startsWith('/') || !body.includes('=')) return null;",
|
|
309
|
+
' const out = new Map<string, string>();',
|
|
310
|
+
" for (const pair of body.split('&').filter(Boolean)) {",
|
|
311
|
+
" const eq = pair.indexOf('=');",
|
|
312
|
+
' const k = eq < 0 ? pair : pair.slice(0, eq);',
|
|
313
|
+
" let v = eq < 0 ? '' : pair.slice(eq + 1);",
|
|
314
|
+
' try {',
|
|
315
|
+
' v = decodeURIComponent(v);',
|
|
316
|
+
' } catch {',
|
|
317
|
+
' // an invalid escape is data too: keep it raw',
|
|
318
|
+
' }',
|
|
319
|
+
' if (!out.has(k)) out.set(k, v);',
|
|
320
|
+
' }',
|
|
321
|
+
' return out;',
|
|
322
|
+
'}',
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
token: 'hashMatch(',
|
|
327
|
+
source: [
|
|
328
|
+
'/**',
|
|
329
|
+
' * A url expectation whose pattern carries a query-shaped hash, checked the',
|
|
330
|
+
' * way replay checks it (urlDiff/urlMatches in src/skills/compile.ts) rather',
|
|
331
|
+
' * than as one regex over the whole url.',
|
|
332
|
+
' *',
|
|
333
|
+
' * A state fragment is application STATE, and state has no ORDER: odoo emits',
|
|
334
|
+
" * `#action=316&cids=1&menu_id=194&model=sale.order` on one run and",
|
|
335
|
+
' * `#action=316&model=sale.order&view_type=list&cids=1&menu_id=194` on the',
|
|
336
|
+
' * next, and a regex fails on the reordering alone — which is the only thing',
|
|
337
|
+
" * wrong with fwod34's second cloud run. So: every pair the recording named",
|
|
338
|
+
' * must be present with the same value, extra live pairs are fine (state',
|
|
339
|
+
' * accumulates), and order means nothing. A `null` value is a `:id`/`:var`',
|
|
340
|
+
' * wildcard — app-minted state, which urlDiff lets be anything or absent.',
|
|
341
|
+
' *',
|
|
342
|
+
' * The head (origin + path) keeps the regex form: a path IS ordered.',
|
|
343
|
+
' */',
|
|
344
|
+
'function hashMatch(url: URL, head: RegExp, want: [string, string | null][]): boolean {',
|
|
345
|
+
' if (!head.test(url.origin + url.pathname)) return false;',
|
|
346
|
+
' const state = hashState(url.href);',
|
|
347
|
+
' if (!state) return false;',
|
|
348
|
+
' return want.every(([k, v]) => v === null || state.get(k) === v);',
|
|
349
|
+
'}',
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
token: 'await click(',
|
|
354
|
+
source: [
|
|
355
|
+
'/**',
|
|
356
|
+
" * A click that lands, mirroring replay's robustClick (src/agent/tools.ts)",
|
|
357
|
+
' * tier for tier, in the same order. A plain `locator.click()` waits for',
|
|
358
|
+
' * actionability and NOTHING else, so a control an overlay covers — or one',
|
|
359
|
+
' * the app re-mounts between frames — burns the whole test timeout on a',
|
|
360
|
+
' * single attempt: grafana\'s `toggle-viz-picker` resolved fine and then sat',
|
|
361
|
+
' * behind an `<svg>` in a `data-overlay-container` for 60s.',
|
|
362
|
+
' *',
|
|
363
|
+
' * 1. Playwright\'s own click, actionability checks and all. What a healthy',
|
|
364
|
+
' * app answers on, and the only tier that proves the control was really',
|
|
365
|
+
' * clickable the way a user would find it.',
|
|
366
|
+
' * 2. Scrolled into view and FORCED past the checks. This is the overlay',
|
|
367
|
+
' * tier: a decorative layer that intercepts pointer events, or a sticky',
|
|
368
|
+
' * header over the target, is exactly what the checks refuse and what',
|
|
369
|
+
' * the app itself treats as fine.',
|
|
370
|
+
' * 3. A synthetic DOM event dispatched at the element. React and friends',
|
|
371
|
+
" * hang delegated handlers off the document, so they see this even when",
|
|
372
|
+
' * the element is not "clickable" by any geometric rule at all.',
|
|
373
|
+
' *',
|
|
374
|
+
' * Each tier gets its own bounded budget so all three (plus the scroll)',
|
|
375
|
+
' * finish well inside one test timeout — replay could afford 10s a tier',
|
|
376
|
+
' * because it had turns left afterwards; a spec has one shot.',
|
|
377
|
+
' *',
|
|
378
|
+
' * A strict-mode violation is rethrown at once, as robustClick does: two',
|
|
379
|
+
' * matches is a locator that names the wrong thing, and no tier can fix it —',
|
|
380
|
+
' * forcing or dispatching would just act on an arbitrary one of them.',
|
|
381
|
+
' * Not mirrored: the re-render window tier (fireWhenAttached), which needs',
|
|
382
|
+
" * to poll element handles; tier 3's dispatch covers the same apps.",
|
|
383
|
+
' */',
|
|
384
|
+
`const CLICK_TIER_MS = ${CLICK_TIER_MS};`,
|
|
385
|
+
'async function click(loc: Locator, opts: { dbl?: boolean } = {}): Promise<void> {',
|
|
386
|
+
' const act = (o: { timeout: number; force?: boolean }) => (opts.dbl ? loc.dblclick(o) : loc.click(o));',
|
|
387
|
+
' let firstFailure: unknown;',
|
|
388
|
+
' try {',
|
|
389
|
+
' return await act({ timeout: CLICK_TIER_MS });',
|
|
390
|
+
' } catch (err) {',
|
|
391
|
+
' firstFailure = err;',
|
|
392
|
+
" if (/strict mode violation/i.test(err instanceof Error ? err.message : String(err))) throw err;",
|
|
393
|
+
' }',
|
|
394
|
+
' try {',
|
|
395
|
+
' await loc.scrollIntoViewIfNeeded({ timeout: CLICK_TIER_MS }).catch(() => {});',
|
|
396
|
+
' return await act({ timeout: CLICK_TIER_MS, force: true });',
|
|
397
|
+
' } catch {',
|
|
398
|
+
' // the overlay is a real one, or the element moved: fall through',
|
|
399
|
+
' }',
|
|
400
|
+
' try {',
|
|
401
|
+
' await loc',
|
|
402
|
+
' .first()',
|
|
403
|
+
' .evaluate((el: Element, dbl: boolean) => {',
|
|
404
|
+
" const fire = (type: string) => el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));",
|
|
405
|
+
" fire('click');",
|
|
406
|
+
' if (dbl) {',
|
|
407
|
+
" fire('click');",
|
|
408
|
+
" fire('dblclick');",
|
|
409
|
+
' }',
|
|
410
|
+
' }, Boolean(opts.dbl));',
|
|
411
|
+
' } catch {',
|
|
412
|
+
' // Every tier lost. The FIRST failure is the one worth reporting: it says',
|
|
413
|
+
' // what a normal click was actually waiting for.',
|
|
414
|
+
' throw firstFailure;',
|
|
415
|
+
' }',
|
|
416
|
+
'}',
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
token: 'await fill(',
|
|
421
|
+
source: [
|
|
422
|
+
'/**',
|
|
423
|
+
' * A fill the app actually SEES — mirror of daemon/inputs.ts reactSafeFill',
|
|
424
|
+
" * (which is how replay executes every recorded `fill`): Playwright's fill",
|
|
425
|
+
' * fires only `input`, and apps that commit on `change` (Odoo, React',
|
|
426
|
+
" * controlled inputs) never see a plain fill. Odoo's sp4od run failed",
|
|
427
|
+
' * deterministically on exactly that: `02-create s_c99d6c/6` filled a',
|
|
428
|
+
' * quantity of 3, the field never committed, and the recorded row',
|
|
429
|
+
' * ("20% £ 36.00") that the next expectation waits for never appeared.',
|
|
430
|
+
' *',
|
|
431
|
+
' * Same waits and the same event order as the daemon: visible, scrolled',
|
|
432
|
+
' * into view, clicked for focus (both best-effort), then the NATIVE',
|
|
433
|
+
" * prototype value setter — the one React's value tracker cannot see",
|
|
434
|
+
' * through — clear-then-set (a number input otherwise appends), an `input`',
|
|
435
|
+
' * event after each set and a `change` after the last. An element with no',
|
|
436
|
+
' * value property or no prototype setter (contenteditable, a custom',
|
|
437
|
+
" * widget) falls back to Playwright's own fill, exactly as the daemon does.",
|
|
438
|
+
' *',
|
|
439
|
+
' * But reactSafeFill is only the SECOND half of what replay does. The',
|
|
440
|
+
" * daemon's `case 'fill'` (src/agent/tools.ts) asks `tryRecipe(page, target,",
|
|
441
|
+
" * 'set-value', value)` FIRST (src/skills/components.ts), and only falls back",
|
|
442
|
+
' * to reactSafeFill when no widget was recognised or the recipe could not',
|
|
443
|
+
' * verify its own effect. That first half is what a keyboard-driven editor',
|
|
444
|
+
' * needs: monaco has no value property to set — its `<textarea>` is an input',
|
|
445
|
+
' * sink, and the text you see is a rendered `.view-lines` div — so the native',
|
|
446
|
+
' * setter writes into a box the editor never reads. Local grafana run',
|
|
447
|
+
' * `03-add s_e4d3e5/6` did exactly that: no error, and the saved text panel',
|
|
448
|
+
" * kept grafana's default markdown, so the objective failed on a step that",
|
|
449
|
+
' * reported success. So the ladder is mirrored here too, recipe first.',
|
|
450
|
+
' */',
|
|
451
|
+
`const FILL_WAIT_MS = ${FILL_WAIT_MS};`,
|
|
452
|
+
`const FILL_FOCUS_MS = ${FILL_FOCUS_MS};`,
|
|
453
|
+
`const EDITOR_SETTLE_MS = ${EDITOR_SETTLE_MS};`,
|
|
454
|
+
`const EDITOR_BLUR_SETTLE_MS = ${EDITOR_BLUR_SETTLE_MS};`,
|
|
455
|
+
'/**',
|
|
456
|
+
' * The widget families whose set-value recipe a fill goes through, in the',
|
|
457
|
+
' * FAMILIES order of src/skills/components.ts (most specific first —',
|
|
458
|
+
" * CodeMirror's .cm-content IS contenteditable, and monaco embeds a",
|
|
459
|
+
' * textarea), with each recipe\'s click/blur/verify targets as the seeds',
|
|
460
|
+
' * name them. `aria-combobox` is deliberately absent: it carries a',
|
|
461
|
+
' * select-option recipe only, and never a set-value one.',
|
|
462
|
+
' *',
|
|
463
|
+
' * `up` is the same `closest(root)` walk expressed as a locator, so the',
|
|
464
|
+
' * root can be CLICKED and READ rather than merely detected; the class',
|
|
465
|
+
' * test is token-wise (`" monaco-editor "`), because `contains(@class,',
|
|
466
|
+
' * "monaco-editor")` would also match `monaco-editor-background`.',
|
|
467
|
+
' */',
|
|
468
|
+
'const EDITORS: { root: string; up: string; click?: string; blur?: string; read?: string }[] = [',
|
|
469
|
+
' {',
|
|
470
|
+
" root: '.monaco-editor',",
|
|
471
|
+
' up: \'xpath=ancestor-or-self::*[contains(concat(" ", normalize-space(@class), " "), " monaco-editor ")]\',',
|
|
472
|
+
" blur: 'textarea',",
|
|
473
|
+
" read: '.view-lines',",
|
|
474
|
+
' },',
|
|
475
|
+
' {',
|
|
476
|
+
" root: '.cm-editor',",
|
|
477
|
+
' up: \'xpath=ancestor-or-self::*[contains(concat(" ", normalize-space(@class), " "), " cm-editor ")]\',',
|
|
478
|
+
" click: '.cm-content',",
|
|
479
|
+
" blur: '.cm-content',",
|
|
480
|
+
" read: '.cm-content',",
|
|
481
|
+
' },',
|
|
482
|
+
' {',
|
|
483
|
+
" root: '.ProseMirror',",
|
|
484
|
+
' up: \'xpath=ancestor-or-self::*[contains(concat(" ", normalize-space(@class), " "), " ProseMirror ")]\',',
|
|
485
|
+
' },',
|
|
486
|
+
' {',
|
|
487
|
+
' root: \'[contenteditable="true"]\',',
|
|
488
|
+
' up: \'xpath=ancestor-or-self::*[@contenteditable="true"]\',',
|
|
489
|
+
' },',
|
|
490
|
+
'];',
|
|
491
|
+
'/**',
|
|
492
|
+
' * The set-value recipe, run with Playwright primitives — a transcription of',
|
|
493
|
+
' * `editorSetValue` in src/skills/components.ts (click, ControlOrMeta+a,',
|
|
494
|
+
' * insertText, settle 400, Escape, blur, settle 200) followed by the',
|
|
495
|
+
' * verification read `verifyRecipe` makes: the family\'s verifyRead node must',
|
|
496
|
+
' * re-observe the payload, whitespace-squashed (monaco renders spaces as',
|
|
497
|
+
' * NBSP and rewraps lines). True only when it did — a recipe that cannot',
|
|
498
|
+
' * prove its own effect is a failure, and the caller falls back, exactly as',
|
|
499
|
+
' * `tryRecipe` does.',
|
|
500
|
+
' */',
|
|
501
|
+
'async function editorSetValue(loc: Locator, value: string): Promise<boolean> {',
|
|
502
|
+
' const which = await loc',
|
|
503
|
+
' .evaluate((el: Element, roots: string[]) => roots.findIndex((sel) => Boolean(el.closest(sel))), EDITORS.map((e) => e.root))',
|
|
504
|
+
' .catch(() => -1);',
|
|
505
|
+
' if (which < 0) return false;',
|
|
506
|
+
' const ed = EDITORS[which];',
|
|
507
|
+
' const page = loc.page();',
|
|
508
|
+
' const root = loc.locator(ed.up).last(); // ancestor-or-self is document order: nearest is last',
|
|
509
|
+
' const within = (sel?: string) => (sel ? root.locator(sel).first() : root);',
|
|
510
|
+
' try {',
|
|
511
|
+
' await within(ed.click).click({ timeout: FILL_FOCUS_MS });',
|
|
512
|
+
" await page.keyboard.press('ControlOrMeta+a');",
|
|
513
|
+
' await page.keyboard.insertText(value);',
|
|
514
|
+
' await page.waitForTimeout(EDITOR_SETTLE_MS);',
|
|
515
|
+
" await page.keyboard.press('Escape');",
|
|
516
|
+
' // A blur target the recipe names but the widget does not have is simply',
|
|
517
|
+
' // skipped (stepHandle returns null, and the daemon blurs nothing).',
|
|
518
|
+
' await within(ed.blur)',
|
|
519
|
+
' .evaluate((el: Element) => (el as HTMLElement).blur?.())',
|
|
520
|
+
' .catch(() => {});',
|
|
521
|
+
' await page.waitForTimeout(EDITOR_BLUR_SETTLE_MS);',
|
|
522
|
+
' } catch {',
|
|
523
|
+
' return false; // a step that could not run at all: fall back',
|
|
524
|
+
' }',
|
|
525
|
+
' const seen = await within(ed.read)',
|
|
526
|
+
' .evaluate((el: Element) => {',
|
|
527
|
+
' const v = (el as HTMLInputElement).value;',
|
|
528
|
+
" return typeof v === 'string' ? v : ((el as HTMLElement).innerText ?? el.textContent ?? '');",
|
|
529
|
+
' })',
|
|
530
|
+
' .catch(() => null);',
|
|
531
|
+
' if (seen === null) return false;',
|
|
532
|
+
" const squash = (s: string) => s.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();",
|
|
533
|
+
' return !value || squash(seen).includes(squash(value));',
|
|
534
|
+
'}',
|
|
535
|
+
'async function fill(loc: Locator, value: string): Promise<void> {',
|
|
536
|
+
" await loc.waitFor({ state: 'visible', timeout: FILL_WAIT_MS });",
|
|
537
|
+
' await loc.scrollIntoViewIfNeeded().catch(() => {});',
|
|
538
|
+
' // The recipe half of the ladder, ahead of the native setter exactly as',
|
|
539
|
+
" // tools.ts puts tryRecipe ahead of reactSafeFill. Verified, or nothing.",
|
|
540
|
+
' if (await editorSetValue(loc, value)) return;',
|
|
541
|
+
' await loc.click({ timeout: FILL_FOCUS_MS }).catch(() => {}); // focus; some widgets need it',
|
|
542
|
+
' const handled = await loc.evaluate((el: Element, val: string) => {',
|
|
543
|
+
' const input = el as HTMLInputElement | HTMLTextAreaElement;',
|
|
544
|
+
" if (!('value' in input)) return false;",
|
|
545
|
+
' const proto =',
|
|
546
|
+
' input instanceof HTMLTextAreaElement',
|
|
547
|
+
' ? HTMLTextAreaElement.prototype',
|
|
548
|
+
' : input instanceof HTMLInputElement',
|
|
549
|
+
' ? HTMLInputElement.prototype',
|
|
550
|
+
' : null;',
|
|
551
|
+
' if (!proto) return false;',
|
|
552
|
+
" const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;",
|
|
553
|
+
' if (!setter) return false;',
|
|
554
|
+
" setter.call(input, ''); // clear-then-set: number inputs otherwise append",
|
|
555
|
+
" input.dispatchEvent(new Event('input', { bubbles: true }));",
|
|
556
|
+
' setter.call(input, val);',
|
|
557
|
+
" input.dispatchEvent(new Event('input', { bubbles: true }));",
|
|
558
|
+
" input.dispatchEvent(new Event('change', { bubbles: true }));",
|
|
559
|
+
' return true;',
|
|
560
|
+
' }, value);',
|
|
561
|
+
' if (!handled) {',
|
|
562
|
+
' // contenteditable or non-standard widget — fall back to Playwright fill',
|
|
563
|
+
' await loc.fill(value);',
|
|
564
|
+
' }',
|
|
565
|
+
'}',
|
|
566
|
+
],
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
token: 'await select(',
|
|
570
|
+
source: [
|
|
571
|
+
'/**',
|
|
572
|
+
' * An option chosen the way the daemon chooses it — mirror of',
|
|
573
|
+
' * daemon/inputs.ts reactSafeSelect, which is how replay executes every',
|
|
574
|
+
' * recorded `select`.',
|
|
575
|
+
' *',
|
|
576
|
+
' * The LABEL is what the procedure means ("the project I just created");',
|
|
577
|
+
" * the VALUE is whatever the app keys that option by, and apps mint those",
|
|
578
|
+
' * per record as often as not — fwat3 03-add selected a project by its id,',
|
|
579
|
+
' * and both replays timed out looking for an id that run never minted. So',
|
|
580
|
+
' * the recording carries the visible label as `option` and the value it saw',
|
|
581
|
+
' * only as `optionValue`, a last resort.',
|
|
582
|
+
' *',
|
|
583
|
+
' * Same order as the daemon, and it LOOKS before it waits: an option that',
|
|
584
|
+
' * is present right now is taken at once (by label, else by value, else by',
|
|
585
|
+
' * the recorded fallback value), so a stale fallback is never paid for with',
|
|
586
|
+
" * the label matcher's full timeout. Only when nothing matches yet does the",
|
|
587
|
+
' * label form wait for options that may still be loading, and a plain',
|
|
588
|
+
' * `selectOption(label)` — value/index matching — is the final attempt.',
|
|
589
|
+
' */',
|
|
590
|
+
'async function select(loc: Locator, label: string, fallbackValue?: string): Promise<void> {',
|
|
591
|
+
' const present = await loc',
|
|
592
|
+
' .evaluate(',
|
|
593
|
+
' (el: Element, [v, f]: [string, string]) => {',
|
|
594
|
+
' if (!(el instanceof HTMLSelectElement)) return null;',
|
|
595
|
+
' const opts = Array.from(el.options);',
|
|
596
|
+
" if (opts.some((o) => o.label.trim() === v)) return 'label';",
|
|
597
|
+
" if (opts.some((o) => o.value === v)) return 'value';",
|
|
598
|
+
" if (f && opts.some((o) => o.value === f)) return 'fallback';",
|
|
599
|
+
' return null;',
|
|
600
|
+
' },',
|
|
601
|
+
" [label, fallbackValue ?? ''] as [string, string],",
|
|
602
|
+
' )',
|
|
603
|
+
' .catch(() => null);',
|
|
604
|
+
" if (present === 'fallback') {",
|
|
605
|
+
' await loc.selectOption(fallbackValue!);',
|
|
606
|
+
' return;',
|
|
607
|
+
' }',
|
|
608
|
+
" if (present === 'value') {",
|
|
609
|
+
' await loc.selectOption(label);',
|
|
610
|
+
' return;',
|
|
611
|
+
' }',
|
|
612
|
+
' const result = await loc.selectOption({ label }).catch(() => null);',
|
|
613
|
+
' if (result) return;',
|
|
614
|
+
' await loc.selectOption(label); // fall back to value/index matching',
|
|
615
|
+
'}',
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
token: 'await hover(',
|
|
620
|
+
source: [
|
|
621
|
+
'/**',
|
|
622
|
+
' * A hover the widget actually notices — mirror of daemon/inputs.ts',
|
|
623
|
+
' * syntheticHover, which is how replay executes every recorded `hover`.',
|
|
624
|
+
' *',
|
|
625
|
+
" * Playwright's own hover moves the mouse, and CSS `:hover` follows; an",
|
|
626
|
+
' * autocomplete or listbox that opens on `mouseenter` (or a menu that keys',
|
|
627
|
+
' * off `pointerover`) may never see an event at all when the element is',
|
|
628
|
+
' * covered, or when the pointer was already inside it. So the real move is',
|
|
629
|
+
' * best-effort, and the events are then dispatched at the element itself.',
|
|
630
|
+
' * `mouseenter` does not bubble — that is the one difference between it and',
|
|
631
|
+
' * its neighbours, and a bubbling `mouseenter` would fire delegated',
|
|
632
|
+
' * handlers no real pointer ever fires.',
|
|
633
|
+
' */',
|
|
634
|
+
'async function hover(loc: Locator): Promise<void> {',
|
|
635
|
+
' await loc.hover().catch(() => {});',
|
|
636
|
+
' await loc.evaluate((el: Element) => {',
|
|
637
|
+
" for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) {",
|
|
638
|
+
" el.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));",
|
|
639
|
+
' }',
|
|
640
|
+
' });',
|
|
641
|
+
'}',
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
token: 'pick(',
|
|
646
|
+
source: [
|
|
647
|
+
'/**',
|
|
648
|
+
' * The first recorded way of naming the control that resolves to exactly ONE',
|
|
649
|
+
' * element, tried in the order the recording measured. Not `.or()`: that is a',
|
|
650
|
+
' * union, so a fallback matching several elements (a dialog-wide input selector,',
|
|
651
|
+
' * say) would make the action a strict-mode violation, where the replay it',
|
|
652
|
+
' * mirrors simply skips a candidate that is not unique and tries the next.',
|
|
653
|
+
' *',
|
|
654
|
+
' * It polls, because a spec has none of the observation turns that used to hide',
|
|
655
|
+
" * an app rendering a beat late (resolveChain's waitMs). `any` is for the two",
|
|
656
|
+
' * places ambiguity is the normal shape: reading across every match, and a loop',
|
|
657
|
+
' * body whose per-record locator matches every record.',
|
|
658
|
+
' *',
|
|
659
|
+
' * `where` (`"<stepId> <segmentId>/<stepIndex> target|source"`, baked in at each',
|
|
660
|
+
' * call site) is what turns a silent fallthrough into telemetry: when the',
|
|
661
|
+
' * winning candidate is not the primary (index 0), that IS drift — the recorded',
|
|
662
|
+
' * locator missed and a later one covered for it — so it is worth one stable,',
|
|
663
|
+
' * grep-able line, not a passing test that quietly stopped proving what it did',
|
|
664
|
+
' * on the day it was recorded.',
|
|
665
|
+
' *',
|
|
666
|
+
' * WHICH RESOLVER RULE THIS MIRRORS. replay never resolves a step against a',
|
|
667
|
+
' * DOM that is still painting: runOneStep awaits settleDom first, and',
|
|
668
|
+
" * resolveChain's own comment states the principle: a candidate that missed",
|
|
669
|
+
' * while the page was still painting and hits on the next poll is not',
|
|
670
|
+
' * volatile — it was early. A spec has no settleDom, and a pass here is not',
|
|
671
|
+
' * one instant: every count() is its own round trip, so candidate #1 is',
|
|
672
|
+
' * sampled several milliseconds before candidate #2. Measured on the',
|
|
673
|
+
' * repair-desk bench (fwrd42, which defers its parts refetch ~500ms BY',
|
|
674
|
+
" * DESIGN, landing on a poll boundary): the scoped primary",
|
|
675
|
+
" * `locator('tr', { hasText }).locator('td:nth-of-type(1)')` counted 0 at t,",
|
|
676
|
+
' * `getByText` counted 1 at t+3ms, and the',
|
|
677
|
+
' * recorded primary counted 1 again 3ms later — a phantom drift on ~40% of',
|
|
678
|
+
' * runs, twice taking a purely structural fallback. So a fallback wins only',
|
|
679
|
+
' * after everything ahead of it has had a SECOND, later look and still',
|
|
680
|
+
' * missed. That is the settleDom guarantee expressed with the only clock a',
|
|
681
|
+
' * plain spec has.',
|
|
682
|
+
' *',
|
|
683
|
+
' * WHAT IT CANNOT MIRROR. Four resolveChain rules need state a Tier 2 file',
|
|
684
|
+
' * does not carry, so a candidate that is merely ambiguous (count > 1) is',
|
|
685
|
+
' * still skipped here rather than narrowed:',
|
|
686
|
+
" * - `ambiguousNth` / the recorded `nth`: emitted as `.nth(n)` when the",
|
|
687
|
+
' * recording stored one, but replay can also invent one per loop pass.',
|
|
688
|
+
" * - `plausible()`: needs the recorded bounding box, and the `point`",
|
|
689
|
+
' * candidate that carries it is dropped (a spec cannot find an element by',
|
|
690
|
+
' * where it was).',
|
|
691
|
+
" * - the structural `held`/`guess` hold: needs to know which candidate is",
|
|
692
|
+
' * positional rather than named. Mirrored statically instead, and more',
|
|
693
|
+
' * strictly, by the identity `.filter({ hasText })` guards locators.ts',
|
|
694
|
+
' * puts on every non-identity candidate.',
|
|
695
|
+
" * - `byEvidence` (retired candidates last): per-candidate replay evidence",
|
|
696
|
+
' * lives in the skill store, not in the spec.',
|
|
697
|
+
' *',
|
|
698
|
+
' * `note` is passed only at a FLAGGED step (compile found the step itself',
|
|
699
|
+
' * wrong — a demoted pin, say — see spec/diagnostics.ts). Appended to the',
|
|
700
|
+
' * throw, it is what stops "none of 3 recorded locators resolved" from',
|
|
701
|
+
' * reading as app drift when the recording is what needs redoing.',
|
|
702
|
+
' */',
|
|
703
|
+
'async function pick(page: Page, candidates: Locator[], where: string, opts: { any?: boolean } = {}, note?: string): Promise<Locator> {',
|
|
704
|
+
' const enough = (n: number) => (opts.any ? n > 0 : n === 1);',
|
|
705
|
+
' const hits = async (i: number) => enough(await candidates[i].count().catch(() => 0));',
|
|
706
|
+
' /** The first candidate ahead of `i` that is there after all — see the re-check below. */',
|
|
707
|
+
' const ahead = async (i: number) => {',
|
|
708
|
+
' for (let j = 0; j < i; j++) if (await hits(j)) return j;',
|
|
709
|
+
' return -1;',
|
|
710
|
+
' };',
|
|
711
|
+
' for (let waited = 0; ; waited += PICK_POLL_MS) {',
|
|
712
|
+
' for (let i = 0; i < candidates.length; i++) {',
|
|
713
|
+
' if (await hits(i)) {',
|
|
714
|
+
' // Confirm the miss before demoting the recorded locator. Each',
|
|
715
|
+
' // count() is its own round trip, so one pass samples candidate #1',
|
|
716
|
+
' // some milliseconds BEFORE candidate #2 — and an app that paints',
|
|
717
|
+
' // in that gap makes the earlier candidate look absent when it was',
|
|
718
|
+
' // merely early. Re-sampling everything ahead of the winner gives',
|
|
719
|
+
' // them a second, later look, which is the guarantee replay gets',
|
|
720
|
+
' // for free by letting the DOM go quiet before it resolves at all.',
|
|
721
|
+
' const back = i === 0 ? -1 : await ahead(i);',
|
|
722
|
+
' const won = back >= 0 ? back : i;',
|
|
723
|
+
' if (won > 0) {',
|
|
724
|
+
' const line = `[sitelooper drift] ${where}: primary ${String(candidates[0])} missed; used #${won + 1} ${String(candidates[won])}`;',
|
|
725
|
+
' console.warn(line);',
|
|
726
|
+
' DRIFT.push(line);',
|
|
727
|
+
' }',
|
|
728
|
+
' return candidates[won];',
|
|
729
|
+
' }',
|
|
730
|
+
' }',
|
|
731
|
+
' if (waited >= PICK_WAIT_MS) break;',
|
|
732
|
+
' await page.waitForTimeout(PICK_POLL_MS);',
|
|
733
|
+
' }',
|
|
734
|
+
' throw new Error(',
|
|
735
|
+
' // The url and the recorded step are half the answer whenever a chain',
|
|
736
|
+
' // misses wholesale: a locator that named the control on the day it was',
|
|
737
|
+
' // recorded usually misses because the page is not the page the step',
|
|
738
|
+
' // expected, and the log otherwise says only that nothing resolved.',
|
|
739
|
+
' `none of ${candidates.length} recorded locators resolved at ${where} (page is at ${page.url()}): ` +',
|
|
740
|
+
" candidates.slice(0, 3).map((c) => String(c)).join(' | ') +",
|
|
741
|
+
" (note ? `\\n ${note}` : ''),",
|
|
742
|
+
' );',
|
|
743
|
+
'}',
|
|
744
|
+
],
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
token: 'readOptional(',
|
|
748
|
+
source: [
|
|
749
|
+
'/**',
|
|
750
|
+
' * A recorded READ, which never fails the flow.',
|
|
751
|
+
' *',
|
|
752
|
+
' * WHICH REPLAY RULE THIS MIRRORS. runOneStep treats `read`/`read_all` as an',
|
|
753
|
+
' * OBSERVATION, not a state change: a read whose target cannot be resolved —',
|
|
754
|
+
' * or whose read itself errors — is skipped with a warning and the replay',
|
|
755
|
+
' * CONTINUES ("skipped read — no element matched any known locator"). Failing',
|
|
756
|
+
' * to re-capture a value says nothing about whether the procedure ran; the',
|
|
757
|
+
' * step after it is exactly as valid as it was. A spec that threw here turned',
|
|
758
|
+
" * a missing observation into a failed test: grafana's `panel_content` read is",
|
|
759
|
+
' * a freshly applied text panel whose body the verifier goes on to confirm,',
|
|
760
|
+
' * and none of the three recorded ways of naming it resolved inside the pick',
|
|
761
|
+
' * window — one lost value, and the run reported as a broken procedure.',
|
|
762
|
+
' *',
|
|
763
|
+
' * So: the pick and the read together, and on any failure one grep-able line',
|
|
764
|
+
' * and an EMPTY value. Assertions and outputs built from an empty read are',
|
|
765
|
+
' * left exactly as they were — the emptiness is the honest report.',
|
|
766
|
+
' */',
|
|
767
|
+
'async function readOptional(',
|
|
768
|
+
' page: Page,',
|
|
769
|
+
' candidates: Locator[],',
|
|
770
|
+
' where: string,',
|
|
771
|
+
' read: (loc: Locator) => Promise<string>,',
|
|
772
|
+
' opts: { any?: boolean } = {},',
|
|
773
|
+
'): Promise<string> {',
|
|
774
|
+
' try {',
|
|
775
|
+
' return await read(await pick(page, candidates, where, opts));',
|
|
776
|
+
' } catch {',
|
|
777
|
+
' console.warn(`[sitelooper skip] ${where}: read target not found — value left empty`);',
|
|
778
|
+
" return '';",
|
|
779
|
+
' }',
|
|
780
|
+
'}',
|
|
781
|
+
],
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
token: 'present(page, ',
|
|
785
|
+
source: [
|
|
786
|
+
'/**',
|
|
787
|
+
' * Is `text` on the page — as TEXT, or as the current VALUE of a field?',
|
|
788
|
+
' *',
|
|
789
|
+
" * WHICH REPLAY RULE THIS MIRRORS. checkIdentity (src/skills/replay.ts) asks",
|
|
790
|
+
' * `presentOnPage`, which captures a fresh daemon SNAPSHOT and substring-matches',
|
|
791
|
+
' * the marker against its lines. Those lines carry field values —',
|
|
792
|
+
' * `textbox "Name": sp5odb Bench Customer` — so on a form in EDIT mode the',
|
|
793
|
+
" * marker is found in an <input>'s value, where `getByText` can never see it:",
|
|
794
|
+
' * the DOM has no text node for it at all. Cloud run sp5odb died on exactly',
|
|
795
|
+
' * that, at the very first gate of `03-open`, on an odoo customer form.',
|
|
796
|
+
' *',
|
|
797
|
+
' * So this is the snapshot line test in the two dialects a spec has: visible',
|
|
798
|
+
' * text, or the live value of a visible input/textarea/select (a select',
|
|
799
|
+
' * reports its selected option label, which is what the snapshot shows).',
|
|
800
|
+
' * Whitespace-normalised and case-insensitive on the value half, because the',
|
|
801
|
+
" * snapshot's own comparison is whitespace-insensitive and a rendered value",
|
|
802
|
+
' * is not always cased as it was typed.',
|
|
803
|
+
' */',
|
|
804
|
+
'async function present(page: Page, text: string): Promise<boolean> {',
|
|
805
|
+
" const want = text.replace(/\\s+/g, ' ').trim();",
|
|
806
|
+
' if (!want) return false;',
|
|
807
|
+
' if (await page.getByText(text).first().isVisible().catch(() => false)) return true;',
|
|
808
|
+
' return await page',
|
|
809
|
+
" .locator('input, textarea, select')",
|
|
810
|
+
' .evaluateAll((els, needle: string) => {',
|
|
811
|
+
" const norm = (s: string) => s.replace(/\\s+/g, ' ').trim().toLowerCase();",
|
|
812
|
+
' const target = norm(needle);',
|
|
813
|
+
' return els.some((el) => {',
|
|
814
|
+
' const e = el as HTMLElement & { checkVisibility?: () => boolean };',
|
|
815
|
+
" const shown = typeof e.checkVisibility === 'function' ? e.checkVisibility() : e.getClientRects().length > 0;",
|
|
816
|
+
' if (!shown) return false;',
|
|
817
|
+
" if (el instanceof HTMLSelectElement) return norm(el.selectedOptions[0]?.textContent ?? '').includes(target);",
|
|
818
|
+
' const v = (el as HTMLInputElement | HTMLTextAreaElement).value;',
|
|
819
|
+
" return typeof v === 'string' && norm(v).includes(target);",
|
|
820
|
+
' });',
|
|
821
|
+
' }, want)',
|
|
822
|
+
' .catch(() => false);',
|
|
823
|
+
'}',
|
|
824
|
+
],
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
token: 'satisfied(page, ',
|
|
828
|
+
source: [
|
|
829
|
+
'/**',
|
|
830
|
+
" * Is this step's work already DONE on the record it names?",
|
|
831
|
+
' *',
|
|
832
|
+
" * WHICH REPLAY RULE THIS MIRRORS. `goalSatisfied` (src/skills/replay.ts):",
|
|
833
|
+
' * two halves, and both are load-bearing. The IDENTITY texts say the page is',
|
|
834
|
+
' * showing THIS record — the url and the page shape only ever say "a page of',
|
|
835
|
+
' * this template" — and the GOAL texts say that record is already in the',
|
|
836
|
+
" * state this step exists to produce. Identity alone would skip a step",
|
|
837
|
+
' * because the right record is open; a goal alone would skip it because some',
|
|
838
|
+
' * OTHER record happens to read "Cancelled".',
|
|
839
|
+
' *',
|
|
840
|
+
' * Conservative by construction: no goal, or no identity, is never satisfied.',
|
|
841
|
+
' * Being wrong the other way costs one re-run of a step that had already',
|
|
842
|
+
' * happened; being wrong THIS way skips work that never happened at all.',
|
|
843
|
+
' */',
|
|
844
|
+
'async function satisfied(page: Page, identity: string[], goal: string[]): Promise<boolean> {',
|
|
845
|
+
' if (!identity.length || !goal.length) return false;',
|
|
846
|
+
' for (const want of [...identity, ...goal]) {',
|
|
847
|
+
' if (!(await present(page, want))) return false;',
|
|
848
|
+
' }',
|
|
849
|
+
' return true;',
|
|
850
|
+
'}',
|
|
851
|
+
],
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
token: 'escapeRe(',
|
|
855
|
+
source: [
|
|
856
|
+
'/** A value interpolated into a pattern is DATA: its own metacharacters must not become pattern. */',
|
|
857
|
+
'function escapeRe(s: string): string {',
|
|
858
|
+
" return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');",
|
|
859
|
+
'}',
|
|
860
|
+
],
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
token: 'looseText(',
|
|
864
|
+
source: [
|
|
865
|
+
'/**',
|
|
866
|
+
" * A recorded NAME as a `hasText` matcher that tolerates the daemon's spacing.",
|
|
867
|
+
' *',
|
|
868
|
+
" * WHY. The daemon names an element from `innerText` (describeInPage in",
|
|
869
|
+
' * src/daemon/diff.ts), which inserts a line break at every BLOCK boundary,',
|
|
870
|
+
' * and `clean()` collapses those to single spaces. Playwright matches',
|
|
871
|
+
' * `hasText` against `textContent`, which inserts NOTHING between block',
|
|
872
|
+
' * children. For a container named from its contents the two disagree by',
|
|
873
|
+
' * exactly the separators — measured against the bench grafana, the refresh',
|
|
874
|
+
' * menu (twelve <button> children) is',
|
|
875
|
+
' * innerText "Off\\nAuto\\n5s\\n10s\\n…" -> recorded "Off Auto 5s 10s …"',
|
|
876
|
+
' * textContent "OffAuto5s10s…"',
|
|
877
|
+
' * so `filter({ hasText: \'Off Auto 5s …\' })` counted 0 while',
|
|
878
|
+
" * `getByRole('menu')` counted 1. Whitespace NORMALISATION cannot bridge",
|
|
879
|
+
' * that, because the whitespace is not there to normalise; that is why cloud',
|
|
880
|
+
' * runs sp5gr/sp6gr still died at `04-add s_0c4807/5` on the union that',
|
|
881
|
+
' * already carried a hasText half.',
|
|
882
|
+
' *',
|
|
883
|
+
' * So every space the daemon put between two words becomes `\\s*`: it stood',
|
|
884
|
+
' * for real whitespace OR for a block boundary, and the pattern has to accept',
|
|
885
|
+
' * both. Everything else is escaped, so the matcher still says exactly what',
|
|
886
|
+
" * the line said, and `i` mirrors a string hasText's case-insensitivity.",
|
|
887
|
+
' */',
|
|
888
|
+
'function looseText(text: string | RegExp): RegExp {',
|
|
889
|
+
" const source = typeof text === 'string' ? escapeRe(text) : text.source;",
|
|
890
|
+
" return new RegExp(source.replace(/ +/g, '\\\\s*'), typeof text === 'string' ? 'i' : text.flags);",
|
|
891
|
+
'}',
|
|
892
|
+
],
|
|
893
|
+
},
|
|
894
|
+
];
|
|
895
|
+
/**
|
|
896
|
+
* The helpers this body needs, in declaration order — transitively, because a
|
|
897
|
+
* helper may use another (`urlPartWhen` reads `urlPart`; both poll on `pick`'s
|
|
898
|
+
* constants). Anything else would emit a file that references a function it
|
|
899
|
+
* does not carry, which is the one defect a generated spec cannot survive.
|
|
900
|
+
*/
|
|
901
|
+
function neededHelpers(body) {
|
|
902
|
+
const chosen = new Set();
|
|
903
|
+
for (;;) {
|
|
904
|
+
const text = [body, ...[...chosen].map((h) => h.source.join('\n'))].join('\n');
|
|
905
|
+
const added = HELPERS.filter((h) => !chosen.has(h) && text.includes(h.token));
|
|
906
|
+
if (!added.length)
|
|
907
|
+
return HELPERS.filter((h) => chosen.has(h));
|
|
908
|
+
for (const h of added)
|
|
909
|
+
chosen.add(h);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const src = (text) => stringSource(text, { slot: slotAsParam });
|
|
913
|
+
const match = (text) => matcherSource(text, { slot: slotAsParam });
|
|
914
|
+
/**
|
|
915
|
+
* A url pattern as a RegExp source for `toHaveURL`, mirroring `urlMatches`:
|
|
916
|
+
* `:id`/`:var` stand for any one segment, a slot for this run's own value
|
|
917
|
+
* (escaped — it is data), the query is not part of the identity of a page
|
|
918
|
+
* and the hash route is. Null when the pattern is not a url at all.
|
|
919
|
+
*/
|
|
920
|
+
function urlRegexSource(pattern) {
|
|
921
|
+
if (!/^[a-z]+:\/\//i.test(pattern))
|
|
922
|
+
return null;
|
|
923
|
+
const hashAt = pattern.indexOf('#');
|
|
924
|
+
const head = hashAt < 0 ? pattern : pattern.slice(0, hashAt);
|
|
925
|
+
const hash = hashAt < 0 ? '' : pattern.slice(hashAt);
|
|
926
|
+
const queryAt = head.indexOf('?');
|
|
927
|
+
const headSource = urlPatternBody(queryAt < 0 ? head : head.slice(0, queryAt));
|
|
928
|
+
// The query is dropped from the pattern, so the live url may still carry
|
|
929
|
+
// one: allow it exactly where it would sit, before the hash route.
|
|
930
|
+
return hash ? `^${headSource}(?:\\\\?[^#]*)?${urlPatternBody(hash)}$` : `^${headSource}(?:[?#].*)?$`;
|
|
931
|
+
}
|
|
932
|
+
/** One piece of a url pattern as regex source: `:id`/`:var` any segment, a slot this run's own value. */
|
|
933
|
+
function urlPatternBody(piece) {
|
|
934
|
+
let out = '';
|
|
935
|
+
let last = 0;
|
|
936
|
+
const token = /\{\{([vd]\d+)\}\}|:id\b|:var\b/g;
|
|
937
|
+
for (const m of piece.matchAll(token)) {
|
|
938
|
+
const at = m.index ?? 0;
|
|
939
|
+
out += templateSafe(escapeRe(piece.slice(last, at)));
|
|
940
|
+
out += m[1] ? '${escapeRe(p.' + m[1] + ')}' : '[^/]+';
|
|
941
|
+
last = at + m[0].length;
|
|
942
|
+
}
|
|
943
|
+
return out + templateSafe(escapeRe(piece.slice(last)));
|
|
944
|
+
}
|
|
945
|
+
const safeDecode = (s) => {
|
|
946
|
+
try {
|
|
947
|
+
return decodeURIComponent(s);
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
return s;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
/**
|
|
954
|
+
* The argument `toHaveURL` is given for a recorded url pattern, or null when
|
|
955
|
+
* the pattern is not a url at all.
|
|
956
|
+
*
|
|
957
|
+
* A regex for a path-shaped url, because a path IS ordered. But a QUERY-SHAPED
|
|
958
|
+
* hash (`#action=1&cids=2`, which is odoo) is application state, and `urlMatches`
|
|
959
|
+
* — the rule replay judges by — compares it as an unordered SET of pairs: every
|
|
960
|
+
* pair the recording named must be present with the same value, extra live pairs
|
|
961
|
+
* are fine, order means nothing. A regex cannot say that, and the whole of what
|
|
962
|
+
* was wrong with fwod34's second cloud run was the ordering: every value right,
|
|
963
|
+
* `#action=…&model=…&cids=…` where the recording saw `#action=…&cids=…&model=…`.
|
|
964
|
+
* Playwright takes a `(url: URL) => boolean` predicate, so that case is emitted
|
|
965
|
+
* as one, over the same inlined comparison replay makes.
|
|
966
|
+
*/
|
|
967
|
+
function urlExpectSource(pattern) {
|
|
968
|
+
if (!/^[a-z]+:\/\//i.test(pattern))
|
|
969
|
+
return null;
|
|
970
|
+
const hashAt = pattern.indexOf('#');
|
|
971
|
+
const body = hashAt < 0 ? '' : pattern.slice(hashAt + 1).split('?')[0];
|
|
972
|
+
if (!body || body.startsWith('/') || !body.includes('=')) {
|
|
973
|
+
const re = urlRegexSource(pattern);
|
|
974
|
+
return re ? `new RegExp(\`${re}\`)` : null;
|
|
975
|
+
}
|
|
976
|
+
const head = pattern.slice(0, hashAt);
|
|
977
|
+
const queryAt = head.indexOf('?');
|
|
978
|
+
const headSource = urlPatternBody(queryAt < 0 ? head : head.slice(0, queryAt));
|
|
979
|
+
const pairs = body
|
|
980
|
+
.split('&')
|
|
981
|
+
.filter(Boolean)
|
|
982
|
+
.map((pair) => {
|
|
983
|
+
const eq = pair.indexOf('=');
|
|
984
|
+
const k = eq < 0 ? pair : pair.slice(0, eq);
|
|
985
|
+
const value = eq < 0 ? '' : pair.slice(eq + 1);
|
|
986
|
+
// `:id`/`:var` is app-minted state: urlDiff lets such a key hold anything,
|
|
987
|
+
// or be absent altogether. Anything else — a literal, or a slot this run
|
|
988
|
+
// binds — must be there and equal.
|
|
989
|
+
if (value === ':id' || value === ':var')
|
|
990
|
+
return `[${q(k)}, null]`;
|
|
991
|
+
return `[${q(k)}, ${src(safeDecode(value))}]`;
|
|
992
|
+
});
|
|
993
|
+
return `(url: URL) => hashMatch(url, new RegExp(\`^${headSource}$\`), [${pairs.join(', ')}])`;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* The roles the daemon gives an <input>/<textarea> (roleOf in
|
|
997
|
+
* src/daemon/diff.ts), plus `combobox`, which an app can also put on an input
|
|
998
|
+
* by hand. These are the roles whose recorded NAME can disagree with
|
|
999
|
+
* Playwright's accessible name for the very same element.
|
|
1000
|
+
*
|
|
1001
|
+
* WHY. The daemon's `nameOf` names an element aria-label -> aria-labelledby ->
|
|
1002
|
+
* alt -> title -> placeholder -> ancestor <label> -> innerText, and never looks
|
|
1003
|
+
* at a `<label for=id>`. Playwright computes the real accessible name, which
|
|
1004
|
+
* PREFERS that label over title and placeholder. So an input carrying both —
|
|
1005
|
+
* grafana's tag field, `<label for>` "Tags" over placeholder "New tag (enter
|
|
1006
|
+
* key to add)" — is recorded under the placeholder and is unfindable by
|
|
1007
|
+
* `getByRole(name)`. That is exactly how the compiled spec died in cloud run
|
|
1008
|
+
* sp4gr, deterministically, on a step whose live replay passed (replay compares
|
|
1009
|
+
* daemon lines to daemon lines, so it never asks Playwright to find the name).
|
|
1010
|
+
*
|
|
1011
|
+
* The fix is a union over the three ways the name could have been minted, in
|
|
1012
|
+
* the daemon's own order of preference. Only these roles get it: a
|
|
1013
|
+
* button/link/heading name has no placeholder to disagree with.
|
|
1014
|
+
*/
|
|
1015
|
+
const INPUT_LIKE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton']);
|
|
1016
|
+
/**
|
|
1017
|
+
* WHY EVERY ROLED LINE GETS A `hasText` UNION.
|
|
1018
|
+
*
|
|
1019
|
+
* `describeInPage` (src/daemon/diff.ts) falls back to `innerText` (up to 80
|
|
1020
|
+
* chars) whenever aria-label/labelledby/alt/title/placeholder/<label> give it
|
|
1021
|
+
* nothing — for EVERY role, because a recorded line is a description for a
|
|
1022
|
+
* human and a diff, not a locator. Playwright's `getByRole(role, { name })`
|
|
1023
|
+
* matches the real ACCESSIBLE NAME. The two readings diverge in two distinct
|
|
1024
|
+
* ways, and a locator built off the recorded line has to survive both.
|
|
1025
|
+
*
|
|
1026
|
+
* 1. The role is not named from its contents at all. Grafana's
|
|
1027
|
+
* refresh-interval popup is recorded as
|
|
1028
|
+
* - menu "Off Auto 5s 10s 30s 1m 5m 15m 30m 1h 2h 1d"
|
|
1029
|
+
* and Playwright's accessible name for that `role=menu` is the empty
|
|
1030
|
+
* string: cloud run sp5gr failed at `04-add s_0c4807/5` on exactly that
|
|
1031
|
+
* locator, every attempt. The odoo home menu (`- menu "6 3 YourCompany"`)
|
|
1032
|
+
* is the same defect.
|
|
1033
|
+
*
|
|
1034
|
+
* 2. The role IS named from its contents — but the element contains FORM
|
|
1035
|
+
* CONTROLS, and the accessible-name computation substitutes each
|
|
1036
|
+
* control's VALUE where innerText renders nothing. This is why the first
|
|
1037
|
+
* cut of this restricted the union to a "name from author, contents"
|
|
1038
|
+
* allow-list and cloud run sp7od still died at `04-open s_059a1e/1`. That
|
|
1039
|
+
* step clicks an odoo quotation's quantity cell, which puts the order
|
|
1040
|
+
* line into inline edit mode; measured against the local bench odoo, the
|
|
1041
|
+
* same `<tr>` reads
|
|
1042
|
+
* innerText (daemon) "20% £ 36.00"
|
|
1043
|
+
* accessible name "Chair floor protection Chair floor protection
|
|
1044
|
+
* Office chairs can harm your floor: protect it.
|
|
1045
|
+
* 3.00 12.00 20% Delete £ 36.00 Delete row"
|
|
1046
|
+
* — the product combobox, the description textbox and the qty/price
|
|
1047
|
+
* textboxes contribute their values, and a Delete link even lands
|
|
1048
|
+
* BETWEEN "20%" and "£ 36.00", so the recorded name is not so much as a
|
|
1049
|
+
* SUBSTRING of the accessible name. `getByRole('row', { name: '20% £
|
|
1050
|
+
* 36.00' })` counted 0. The identical line shape passes in step 02-create
|
|
1051
|
+
* because the row is read-only there and the two readings agree.
|
|
1052
|
+
*
|
|
1053
|
+
* Both failures are repaired by the same union, so there is no allow-list any
|
|
1054
|
+
* more: any roled line may have been named from innerText, and hasText is the
|
|
1055
|
+
* only half that reads the element the way the daemon did.
|
|
1056
|
+
*
|
|
1057
|
+
* But hasText is NOT `clean(innerText)` either, which is what an earlier cut
|
|
1058
|
+
* assumed and why sp6gr failed identically with a union already in place.
|
|
1059
|
+
* Playwright matches hasText against `textContent`, which puts no separator
|
|
1060
|
+
* between block children, so the recorded "Off Auto 5s …" was compared against
|
|
1061
|
+
* "OffAuto5s…" and matched nothing — and the odoo row's textContent is
|
|
1062
|
+
* "20%£ 36.00", no space, so a plain string hasText misses it too. The hasText
|
|
1063
|
+
* half therefore goes through `looseText` (see the helper), which turns each
|
|
1064
|
+
* recorded space back into `\s*` — the only form that matches both readings
|
|
1065
|
+
* of the same subtree. Verified live: `getByRole('row').filter({ hasText:
|
|
1066
|
+
* /20%\s*£\s*36\.00/i })` counts 1 on that edit-mode row.
|
|
1067
|
+
*
|
|
1068
|
+
* The union is inherently a SUBSTRING test — `exact` cannot be expressed on
|
|
1069
|
+
* the hasText half — so the guard callers (wrapAlreadyInEffect, which reads
|
|
1070
|
+
* presence as a reason NOT to act) keep their anchored matcher on the role
|
|
1071
|
+
* half and accept the looser hasText half. That is deliberate: the alternative
|
|
1072
|
+
* is a guard whose role half never matches at all, which is the bug being
|
|
1073
|
+
* fixed. Guards are built from POPUP lines only (menu/dialog/listbox), whose
|
|
1074
|
+
* roles were already taking the union before this change, so widening the
|
|
1075
|
+
* allow-list does not widen any guard.
|
|
1076
|
+
*/
|
|
1077
|
+
/**
|
|
1078
|
+
* A Playwright locator for one recorded page line (`- role "name"`,
|
|
1079
|
+
* `- text: foo`). Null when the line names nothing findable — an unnamed
|
|
1080
|
+
* control, or a value with no role — in which case the caller leaves the
|
|
1081
|
+
* observation as a comment rather than inventing an assertion.
|
|
1082
|
+
*/
|
|
1083
|
+
function lineLocator(line, exact = false) {
|
|
1084
|
+
// `exact: false` by default, unlike an action's locator. A recorded line's name comes
|
|
1085
|
+
// from the daemon's own accessible-name walk (describeInPage in
|
|
1086
|
+
// src/daemon/diff.ts), which composes a name out of the subtree and can
|
|
1087
|
+
// disagree with Playwright's exact matcher on spacing, punctuation and
|
|
1088
|
+
// decorative children - `link "RD Repair Desk"` is a real example. An
|
|
1089
|
+
// ACTION must name one control exactly; a presence check only has to find
|
|
1090
|
+
// the evidence, and replay's own lineShows matches loosely too.
|
|
1091
|
+
//
|
|
1092
|
+
// `exact` is for the one caller that reads presence as a reason NOT to act
|
|
1093
|
+
// (the already-in-effect guard): there a false positive silently drops a
|
|
1094
|
+
// click, and replay's own check is line-exact — `lineShows` looks for the
|
|
1095
|
+
// whole rendered line, quotes and all, so a `button "6"` never matches a
|
|
1096
|
+
// button called "17.6". Loose is safe when it only widens the evidence a
|
|
1097
|
+
// step accepts; it is not safe when it decides the step is unnecessary.
|
|
1098
|
+
//
|
|
1099
|
+
// A recorded line has ALREADY been through maskVolatile, so its clock and
|
|
1100
|
+
// calendar tokens arrive as the `{{*}}` wildcard. Rendered as the literal
|
|
1101
|
+
// string it looks like, that names nothing on any page — kanboard's
|
|
1102
|
+
// `textbox "{{*}} {{*}}"` is a due-date field the app titles with the
|
|
1103
|
+
// current date and time — so it comes back as a RegExp instead (see
|
|
1104
|
+
// maskedMatcherSource), and a line that is nothing but wildcards names no
|
|
1105
|
+
// element at all and is left to the caller's observation comment.
|
|
1106
|
+
const roled = /^-?\s*([a-zA-Z]+)\s+"((?:[^"\\]|\\.)*)"/.exec(line);
|
|
1107
|
+
if (roled) {
|
|
1108
|
+
const name = roled[2].replace(/\\(.)/g, '$1');
|
|
1109
|
+
if (!name.trim())
|
|
1110
|
+
return null;
|
|
1111
|
+
const matcher = lineName(name, exact);
|
|
1112
|
+
if (!matcher)
|
|
1113
|
+
return null;
|
|
1114
|
+
// `exact` rides along as it always has; Playwright ignores it for a RegExp
|
|
1115
|
+
// name, where the anchoring above carries the same decision.
|
|
1116
|
+
const roleName = roled[1].toLowerCase();
|
|
1117
|
+
const role = `page.getByRole(${q(roled[1])}, { name: ${matcher}, exact: ${exact} })`;
|
|
1118
|
+
if (INPUT_LIKE_ROLES.has(roleName)) {
|
|
1119
|
+
return `${role}.or(page.getByTitle(${matcher}, { exact: ${exact} })).or(page.getByPlaceholder(${matcher}, { exact: ${exact} }))`;
|
|
1120
|
+
}
|
|
1121
|
+
// Every other role: the daemon named this element from innerText, so the
|
|
1122
|
+
// same text also has to be looked for as TEXT. Same matcher in both
|
|
1123
|
+
// halves, so the two can never disagree about what the line said; hasText
|
|
1124
|
+
// is a substring test whatever `exact` says.
|
|
1125
|
+
//
|
|
1126
|
+
// Not restricted to the roles ARIA names from their contents. A row/cell
|
|
1127
|
+
// that holds form controls IS named from its contents, and the accessible
|
|
1128
|
+
// name still diverges from innerText because the controls' VALUES are
|
|
1129
|
+
// substituted in — see the block comment above for the odoo edit-mode row
|
|
1130
|
+
// that killed sp7od with the allow-list in place.
|
|
1131
|
+
//
|
|
1132
|
+
// Through `looseText`, because the two halves read the element's text by
|
|
1133
|
+
// DIFFERENT rules: the daemon's innerText separates block children, the
|
|
1134
|
+
// hasText Playwright compares against (textContent) does not. See the
|
|
1135
|
+
// helper — this is the half that has to survive that gap.
|
|
1136
|
+
return `${role}.or(page.getByRole(${q(roled[1])}).filter({ hasText: looseText(${matcher}) }))`;
|
|
1137
|
+
}
|
|
1138
|
+
const text = /^-?\s*(?:text:)?\s*(.+?)\s*$/.exec(line);
|
|
1139
|
+
const value = text?.[1];
|
|
1140
|
+
if (!value || value.includes('"'))
|
|
1141
|
+
return null;
|
|
1142
|
+
const matcher = lineName(value, exact);
|
|
1143
|
+
return matcher ? `page.getByText(${matcher}, { exact: ${exact} })` : null;
|
|
1144
|
+
}
|
|
1145
|
+
/** The name matcher for one recorded line, wildcards included; null when the line names nothing. */
|
|
1146
|
+
function lineName(text, exact) {
|
|
1147
|
+
return maskedMatcherSource(text, { slot: slotAsParam, anchor: exact });
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* One any-of assertion for a group of recorded lines.
|
|
1151
|
+
*
|
|
1152
|
+
* `lineShows` is ANY-of: replay stops only when NONE of the parameterised
|
|
1153
|
+
* lines is on the page, and separately when NONE of the plain ones is. A spec
|
|
1154
|
+
* asserting each line on its own would be strictly stricter than the gate it
|
|
1155
|
+
* claims to mirror, and fails on the single line whose recorded name the
|
|
1156
|
+
* daemon composed differently — which is what `link "RD Repair Desk"` did on
|
|
1157
|
+
* the first real run. `.or()` is a union, so a union taken `.first()` is
|
|
1158
|
+
* exactly "at least one of these is showing".
|
|
1159
|
+
*/
|
|
1160
|
+
function anyOfAssertion(lines, label, out) {
|
|
1161
|
+
const { source, listed, unnameable, count } = lineUnion(lines);
|
|
1162
|
+
for (const line of unnameable)
|
|
1163
|
+
out.push(`// observed (nothing nameable in it): ${commentSafe(line)}`);
|
|
1164
|
+
if (!source)
|
|
1165
|
+
return;
|
|
1166
|
+
out.push(`// ${label} — any one of these, as replay's effect gate has it:`);
|
|
1167
|
+
for (const line of listed)
|
|
1168
|
+
out.push(`// ${commentSafe(line)}`);
|
|
1169
|
+
out.push(`await expect(${source}${count === 1 ? '' : `\n${CONT_INDENT}`}.first()).toBeVisible();`);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* A group of recorded lines as ONE union locator — the shape both the effect
|
|
1173
|
+
* gate above and the already-in-effect guard below need, built once so the
|
|
1174
|
+
* two can never disagree about what a recorded line means.
|
|
1175
|
+
*/
|
|
1176
|
+
function lineUnion(lines, exact = false) {
|
|
1177
|
+
const usable = [];
|
|
1178
|
+
const listed = [];
|
|
1179
|
+
const unnameable = [];
|
|
1180
|
+
for (const line of lines) {
|
|
1181
|
+
const loc = lineLocator(line, exact);
|
|
1182
|
+
if (!loc)
|
|
1183
|
+
unnameable.push(line);
|
|
1184
|
+
else if (!usable.includes(loc)) {
|
|
1185
|
+
usable.push(loc);
|
|
1186
|
+
listed.push(line);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const source = usable.length ? usable[0] + usable.slice(1).map((u) => `\n${CONT_INDENT}.or(${u})`).join('') : '';
|
|
1190
|
+
return { source, listed, unnameable, count: usable.length };
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* The step's recorded page changes as assertions, mirroring the
|
|
1194
|
+
* `expectedChanges` gate: the lines carrying a slot are HARD as a GROUP —
|
|
1195
|
+
* they are what distinguishes this run from the recorded one, so none of them
|
|
1196
|
+
* showing means the step acted on the wrong thing — and the plain lines are a
|
|
1197
|
+
* second group, because a step none of whose recorded effects appeared did
|
|
1198
|
+
* not have its recorded effect.
|
|
1199
|
+
*/
|
|
1200
|
+
function expectationLines(step, out) {
|
|
1201
|
+
const recorded = step.expect?.addedContains ?? [];
|
|
1202
|
+
let lines = recorded.filter((l) => !TRANSIENT_LINE.test(l));
|
|
1203
|
+
if (!lines.length)
|
|
1204
|
+
return;
|
|
1205
|
+
// A fill's own echo in a same-role element is no evidence — the WRONG
|
|
1206
|
+
// textbox produces it too. Same choice replay makes, made visible here.
|
|
1207
|
+
if (step.tool === 'fill' && typeof step.args.value === 'string') {
|
|
1208
|
+
lines = consequentialExpectations(lines, step.args.value);
|
|
1209
|
+
}
|
|
1210
|
+
const hard = lines.filter((l) => SLOT_LINE.test(l));
|
|
1211
|
+
const plain = lines.filter((l) => !SLOT_LINE.test(l));
|
|
1212
|
+
if (hard.length)
|
|
1213
|
+
anyOfAssertion(hard, "this run's own values must show", out);
|
|
1214
|
+
if (plain.length)
|
|
1215
|
+
anyOfAssertion(plain, "the step's recorded effect must show", out);
|
|
1216
|
+
}
|
|
1217
|
+
/** The url and alert halves of a step's expectation. */
|
|
1218
|
+
function effectLines(step, ctx, out) {
|
|
1219
|
+
const pattern = step.expect?.urlPattern;
|
|
1220
|
+
if (pattern && pattern !== ctx.lastUrl) {
|
|
1221
|
+
ctx.lastUrl = pattern;
|
|
1222
|
+
const check = urlExpectSource(pattern);
|
|
1223
|
+
if (check)
|
|
1224
|
+
out.push(`await expect(page).toHaveURL(${check});`);
|
|
1225
|
+
else
|
|
1226
|
+
out.push(`// expected url ${commentSafe(pattern)} (not a url pattern this compiler can express)`);
|
|
1227
|
+
}
|
|
1228
|
+
// Toasts are volatile: recorded soft in replay, and a spec that asserted
|
|
1229
|
+
// one would fail on timing rather than on behaviour.
|
|
1230
|
+
if (step.expect?.alertContains)
|
|
1231
|
+
out.push(`// expected alert containing ${JSON.stringify(commentSafe(step.expect.alertContains))}`);
|
|
1232
|
+
}
|
|
1233
|
+
/** Collect the slots a piece of recorded text needs from `p`. */
|
|
1234
|
+
function noteSlots(value, ctx) {
|
|
1235
|
+
if (typeof value === 'string') {
|
|
1236
|
+
for (const m of value.matchAll(/\{\{([vd]\d+)\}\}/g))
|
|
1237
|
+
ctx.slots.add(m[1]);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (Array.isArray(value))
|
|
1241
|
+
for (const v of value)
|
|
1242
|
+
noteSlots(v, ctx);
|
|
1243
|
+
else if (value && typeof value === 'object')
|
|
1244
|
+
for (const v of Object.values(value))
|
|
1245
|
+
noteSlots(v, ctx);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Wraps everything a click step emitted from `actionStart` on in the
|
|
1249
|
+
* already-in-effect guard, or leaves it alone when the recorded effect opens
|
|
1250
|
+
* no popup.
|
|
1251
|
+
*
|
|
1252
|
+
* WHICH REPLAY RULE THIS MIRRORS. runOneStep, before it acts: a click that
|
|
1253
|
+
* OPENS a popup is a TOGGLE, so re-clicking it while the popup is showing
|
|
1254
|
+
* closes the very thing the next step depends on — and the click that ought
|
|
1255
|
+
* to be a no-op is instead intercepted by the modal overlay it raised, which
|
|
1256
|
+
* on kanboard meant 60s of Playwright waiting for an `#modal-overlay` to stop
|
|
1257
|
+
* eating pointer events. Replay skips such a click ("already in effect"); the
|
|
1258
|
+
* spec asks the same question of the same recorded lines and skips the pick
|
|
1259
|
+
* and the click together, because resolving a control under an open modal is
|
|
1260
|
+
* no more meaningful than clicking it.
|
|
1261
|
+
*/
|
|
1262
|
+
function wrapAlreadyInEffect(step, ctx, out, actionStart) {
|
|
1263
|
+
const opener = openerExpectations(step);
|
|
1264
|
+
if (!opener.length)
|
|
1265
|
+
return;
|
|
1266
|
+
const { source, listed, count } = lineUnion(opener, true);
|
|
1267
|
+
if (!source)
|
|
1268
|
+
return;
|
|
1269
|
+
const where = `${ctx.stepId} ${ctx.segmentId}/${ctx.stepIndex}`;
|
|
1270
|
+
const acted = out
|
|
1271
|
+
.splice(actionStart)
|
|
1272
|
+
.flatMap((l) => l.split('\n'))
|
|
1273
|
+
.map((l) => (l ? ' ' + l : l));
|
|
1274
|
+
out.push("// This click OPENS a popup, which makes it a toggle: replay skips it when the", '// recorded effect is already showing (runOneStep, "skipped (already in effect)"),', '// because clicking again would close what the next step needs. Same rule here,', '// off the same recorded lines:', ...listed.map((l) => `// ${commentSafe(l)}`), `if (await ${source}${count === 1 ? '' : `\n${CONT_INDENT}`}.first().isVisible().catch(() => false)) {`, ' // already in effect: the popup is on the page, so the recorded click has nothing left to do.',
|
|
1275
|
+
// A skipped click is invisible in a passing-until-it-isn't spec, and a
|
|
1276
|
+
// guard that fires for the WRONG reason (one of these lines is on the page
|
|
1277
|
+
// for some other reason than "the popup is open") silently drops the step
|
|
1278
|
+
// that everything after it depends on. One line on stdout is what makes
|
|
1279
|
+
// that legible in a bench log.
|
|
1280
|
+
` console.log(${q(`[sitelooper skip] ${where}: recorded popup already showing — click skipped`)});`, '} else {', ...acted, '}');
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* The expression an action acts on, emitting the resolution above it when the
|
|
1284
|
+
* recording measured more than one way of naming the element.
|
|
1285
|
+
*
|
|
1286
|
+
* A single candidate is used inline. Several become one `pick(...)` call: the
|
|
1287
|
+
* chain is an ORDERED list of ways to name one control, not a union of
|
|
1288
|
+
* elements, and only `pick` preserves that. Returns null when nothing in the
|
|
1289
|
+
* chain could be expressed — the caller then emits a TODO rather than a
|
|
1290
|
+
* statement it cannot target.
|
|
1291
|
+
*/
|
|
1292
|
+
function actionTarget(step, key, ctx, out, opts = {}) {
|
|
1293
|
+
const chain = step.locators?.[key] ?? [];
|
|
1294
|
+
noteSlots(chain, ctx);
|
|
1295
|
+
const { sources } = candidateSources(chain, { slot: slotAsParam });
|
|
1296
|
+
if (!sources.length)
|
|
1297
|
+
return null;
|
|
1298
|
+
// In a loop the cursor is always the first match: the record this pass acts on.
|
|
1299
|
+
if (sources.length === 1)
|
|
1300
|
+
return opts.first ? `(${sources[0]}).first()` : sources[0];
|
|
1301
|
+
const name = `el${++ctx.picks}`;
|
|
1302
|
+
const where = `${ctx.stepId} ${ctx.segmentId}/${ctx.stepIndex} ${key}`;
|
|
1303
|
+
out.push(`const ${name} = await pick(page, [`);
|
|
1304
|
+
for (const source of sources)
|
|
1305
|
+
out.push(`${CONT_INDENT}${source},`);
|
|
1306
|
+
// `note` is the trailing argument, so a step with nothing wrong emits the
|
|
1307
|
+
// call exactly as it always did.
|
|
1308
|
+
const tail = ctx.note ? `, ${opts.any ? '{ any: true }' : '{}'}, ${q(ctx.note)}` : opts.any ? ', { any: true }' : '';
|
|
1309
|
+
out.push(`], ${q(where)}${tail});`);
|
|
1310
|
+
return opts.first ? `${name}.first()` : name;
|
|
1311
|
+
}
|
|
1312
|
+
/** The `point` candidates a step lost, as one honest comment. */
|
|
1313
|
+
function droppedNotes(step, out) {
|
|
1314
|
+
const lost = [];
|
|
1315
|
+
for (const key of ['target', 'source']) {
|
|
1316
|
+
for (const c of step.locators?.[key] ?? [])
|
|
1317
|
+
if (c.kind === 'point')
|
|
1318
|
+
lost.push(c);
|
|
1319
|
+
}
|
|
1320
|
+
if (!lost.length)
|
|
1321
|
+
return;
|
|
1322
|
+
const where = lost
|
|
1323
|
+
.map((c) => (c.kind === 'point' ? `${c.role ?? c.tag} at ${c.x},${c.y}` : ''))
|
|
1324
|
+
.filter(Boolean)
|
|
1325
|
+
.join(', ');
|
|
1326
|
+
out.push(`// TODO: dropped the recorded position fallback (${where}) — a spec cannot find an element by where it was.`);
|
|
1327
|
+
}
|
|
1328
|
+
/** The names this step mints, in the order the segment declares them. */
|
|
1329
|
+
function derivedHere(segment, index) {
|
|
1330
|
+
return Object.entries(segment.derived ?? {}).filter(([, d]) => d.step === index);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* `p.dN = …` for every value this step mints, bound before the assertions
|
|
1334
|
+
* read it.
|
|
1335
|
+
*
|
|
1336
|
+
* `settled` says the url is already the one the step navigated to — true
|
|
1337
|
+
* after a `goto`, which awaits its own navigation. Anywhere else the action
|
|
1338
|
+
* only STARTS the navigation, so the part has to be read the way replay reads
|
|
1339
|
+
* it: after the url changed and the page stopped moving (see `urlPartWhen`).
|
|
1340
|
+
* Read in the same tick instead, every part comes back empty and the
|
|
1341
|
+
* `toHaveURL` built from them can never match — which is what odoo's
|
|
1342
|
+
* `#action=&cids=&menu_id=` did on the first cloud run.
|
|
1343
|
+
*/
|
|
1344
|
+
function derivedLines(segment, index, ctx, out, urlBefore = '') {
|
|
1345
|
+
const here = derivedHere(segment, index);
|
|
1346
|
+
for (const [name] of here)
|
|
1347
|
+
ctx.slots.add(name);
|
|
1348
|
+
if (!here.length)
|
|
1349
|
+
return;
|
|
1350
|
+
const example = (d) => `// recorded example: ${commentSafe(d.example)}`;
|
|
1351
|
+
if (!urlBefore) {
|
|
1352
|
+
for (const [name, d] of here)
|
|
1353
|
+
out.push(`p.${name} = urlPart(page.url(), ${q(d.at)}); ${example(d)}`);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (here.length === 1) {
|
|
1357
|
+
out.push(`p.${here[0][0]} = await urlPartWhen(page, ${q(here[0][1].at)}, ${urlBefore}); ${example(here[0][1])}`);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
// ONE wait for ALL of them: a part bound the instant IT is non-empty can be
|
|
1361
|
+
// read off a half-built url while its neighbour is still missing, and the
|
|
1362
|
+
// pattern the three of them go into is then unmatchable by construction.
|
|
1363
|
+
const name = `bound${++ctx.binds}`;
|
|
1364
|
+
out.push(`const ${name} = await urlPartsWhen(page, [${here.map(([, d]) => q(d.at)).join(', ')}], ${urlBefore});`);
|
|
1365
|
+
here.forEach(([slot, d], i) => out.push(`p.${slot} = ${name}[${i}]; ${example(d)}`));
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The recorded popup lines that make a click a TOGGLE, or empty.
|
|
1369
|
+
*
|
|
1370
|
+
* Mirrors replay's `openerLines`: only the plain (unparameterised, non
|
|
1371
|
+
* transient) effects count, and they count only when at least one of them
|
|
1372
|
+
* names a popup — a dialog, menu, listbox or tooltip is the thing a second
|
|
1373
|
+
* click closes again, where another row of textboxes is an effect worth
|
|
1374
|
+
* re-producing. Widened to `dblclick` here for the same reason it applies to
|
|
1375
|
+
* `click`; replay only ever recorded the case for `click`.
|
|
1376
|
+
*/
|
|
1377
|
+
function openerExpectations(step) {
|
|
1378
|
+
if (step.tool !== 'click' && step.tool !== 'dblclick')
|
|
1379
|
+
return [];
|
|
1380
|
+
const lines = (step.expect?.addedContains ?? []).filter((l) => !TRANSIENT_LINE.test(l) && !SLOT_LINE.test(l));
|
|
1381
|
+
// Only the popup lines decide, as replay's openerLines: the other effects
|
|
1382
|
+
// a dialog-opening click recorded (the row it was about to fill, the
|
|
1383
|
+
// combobox it typed into) were on the page BEFORE the click too, and an
|
|
1384
|
+
// any-of guard over them skipped fwod34's product-option click on every
|
|
1385
|
+
// run, so the configurator dialog its next step confirms never opened.
|
|
1386
|
+
return lines.filter((l) => OPENER_LINE.test(l));
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* One recorded step as source. `first` marks a loop body, where every target
|
|
1390
|
+
* is taken at its first match (see emitLoop).
|
|
1391
|
+
*/
|
|
1392
|
+
function emitSkillStep(step, segment, index, ctx, first = false) {
|
|
1393
|
+
const out = [];
|
|
1394
|
+
// A location comment ahead of everything this step emits, so a Playwright
|
|
1395
|
+
// stack line (or a `[sitelooper drift]` warning, which shares this same
|
|
1396
|
+
// "<stepId> <segmentId>/<stepIndex>" shape) can be mapped back to the
|
|
1397
|
+
// recorded step that produced it.
|
|
1398
|
+
out.push(`// @step ${ctx.stepId} ${segment.id}/${index}`);
|
|
1399
|
+
// Ahead of EVERYTHING this step does — the already-in-effect guard, the pick,
|
|
1400
|
+
// a bare locator action — because that is where replay's own settleDom sits
|
|
1401
|
+
// (runOneStep). See the helper's comment for the odoo toggle sequence this
|
|
1402
|
+
// ordering is what saves.
|
|
1403
|
+
out.push('await settle(page);');
|
|
1404
|
+
ctx.segmentId = segment.id;
|
|
1405
|
+
ctx.stepIndex = index;
|
|
1406
|
+
const args = step.args ?? {};
|
|
1407
|
+
noteSlots(args, ctx);
|
|
1408
|
+
const str = (name, fallback = '') => String(args[name] ?? fallback);
|
|
1409
|
+
const num = (name) => (typeof args[name] === 'number' ? args[name] : undefined);
|
|
1410
|
+
// Steps that act on the page itself, before any locator is needed.
|
|
1411
|
+
switch (step.tool) {
|
|
1412
|
+
case 'goto':
|
|
1413
|
+
out.push(`await page.goto(${src(str('url'))});`);
|
|
1414
|
+
// A navigation renders a route skeleton first: replay lets it hydrate
|
|
1415
|
+
// before its effect gates look for the recorded content (runOneStep's
|
|
1416
|
+
// `if (page.url() !== urlBefore) await settleDom(page)`), and the
|
|
1417
|
+
// assertions and url reads below are exactly those gates.
|
|
1418
|
+
out.push('await settle(page);');
|
|
1419
|
+
effectLines(step, ctx, out);
|
|
1420
|
+
// page.goto awaits its own navigation: the url is already the landed one.
|
|
1421
|
+
derivedLines(segment, index, ctx, out);
|
|
1422
|
+
return out;
|
|
1423
|
+
case 'back':
|
|
1424
|
+
out.push('await page.goBack();');
|
|
1425
|
+
return out;
|
|
1426
|
+
case 'set_viewport':
|
|
1427
|
+
out.push(`await page.setViewportSize({ width: ${num('width') ?? 0}, height: ${num('height') ?? 0} });`);
|
|
1428
|
+
return out;
|
|
1429
|
+
case 'set_offline':
|
|
1430
|
+
out.push(`await page.context().setOffline(${Boolean(args.offline)});`);
|
|
1431
|
+
return out;
|
|
1432
|
+
case 'eval':
|
|
1433
|
+
out.push(`await page.evaluate(${src(str('expression'))});`);
|
|
1434
|
+
return out;
|
|
1435
|
+
case 'screenshot':
|
|
1436
|
+
out.push(`await page.screenshot({ path: ${src(args.path ? str('path') : 'screenshot.jpg')}${args.full_page ? ', fullPage: true' : ''} });`);
|
|
1437
|
+
return out;
|
|
1438
|
+
case 'dialog_expect': {
|
|
1439
|
+
const action = args.action === 'accept' ? 'accept' : 'dismiss';
|
|
1440
|
+
const arg = action === 'accept' && args.prompt_text ? src(str('prompt_text')) : '';
|
|
1441
|
+
const count = num('count') ?? 1;
|
|
1442
|
+
out.push(`page.${count > 1 ? 'on' : 'once'}('dialog', (dialog) => dialog.${action}(${arg}));`);
|
|
1443
|
+
return out;
|
|
1444
|
+
}
|
|
1445
|
+
case 'tabs':
|
|
1446
|
+
// A second page needs a real handle, and inventing one would silently
|
|
1447
|
+
// re-point every later `page.` line.
|
|
1448
|
+
out.push(`// TODO: the recording switched to tab ${String(args.switch_to)} here — take the handle yourself.`);
|
|
1449
|
+
return out;
|
|
1450
|
+
case 'press':
|
|
1451
|
+
if (!args.target) {
|
|
1452
|
+
out.push(`await page.keyboard.press(${src(str('key'))});`);
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
break;
|
|
1456
|
+
default:
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
droppedNotes(step, out);
|
|
1460
|
+
const isRead = step.tool === 'read' || step.tool === 'read_all';
|
|
1461
|
+
if (isRead && str('what') === 'url') {
|
|
1462
|
+
if (step.label)
|
|
1463
|
+
out.push(`outputs[${q(`${ctx.stepId}.${step.label}`)}] = page.url();`);
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
// An unlabelled read published nothing — it was the agent orienting itself —
|
|
1467
|
+
// so it needs no locator, and reporting one as missing would be a defect
|
|
1468
|
+
// where replay simply skips: a read is an observation, never a state change.
|
|
1469
|
+
if (isRead && !step.label) {
|
|
1470
|
+
out.push(`// observed: ${step.tool} ${commentSafe(str('what', 'text'))} (unlabelled — it published no value)`);
|
|
1471
|
+
return out;
|
|
1472
|
+
}
|
|
1473
|
+
// Ambiguity is the normal shape in exactly two places: a read across every
|
|
1474
|
+
// match, and a loop body whose per-record locator names every record.
|
|
1475
|
+
const any = step.tool === 'read_all' || first;
|
|
1476
|
+
// A wait for the target to be GONE cannot go through pick(): pick demands a
|
|
1477
|
+
// resolving candidate, and absence is the condition. The union of every
|
|
1478
|
+
// candidate with .first() is exactly "none of these is on the page" under
|
|
1479
|
+
// toBeHidden / toHaveCount(0) — the same rule replay applies (waitsForAbsence).
|
|
1480
|
+
if (waitsForAbsence(step, args)) {
|
|
1481
|
+
const chain = step.locators?.target ?? [];
|
|
1482
|
+
noteSlots(chain, ctx);
|
|
1483
|
+
const union = chainSource(chain, { slot: slotAsParam, indent: CONT_INDENT }).source;
|
|
1484
|
+
if (!union) {
|
|
1485
|
+
out.push(`// TODO: no locator this compiler can express for ${step.tool} — fill it in by hand.`);
|
|
1486
|
+
return out;
|
|
1487
|
+
}
|
|
1488
|
+
out.push(waitForLine(`(${union}).first()`, args, num('timeout_ms')));
|
|
1489
|
+
return out;
|
|
1490
|
+
}
|
|
1491
|
+
// A read resolves and reads through `readOptional`, which cannot throw: see
|
|
1492
|
+
// its comment. It never goes through `actionTarget`, because a `pick` emitted
|
|
1493
|
+
// as its own statement would throw before the read could catch anything.
|
|
1494
|
+
if (isRead) {
|
|
1495
|
+
out.push(...readLines(step, ctx, { any, first }));
|
|
1496
|
+
return out;
|
|
1497
|
+
}
|
|
1498
|
+
// The url this step starts from, so a value it mints is read off the url it
|
|
1499
|
+
// navigated TO and not off the one it left (see urlPartWhen / derivedLines).
|
|
1500
|
+
const minting = derivedHere(segment, index).length > 0 || Boolean(step.mints);
|
|
1501
|
+
const urlBefore = minting ? `urlBefore${++ctx.urls}` : '';
|
|
1502
|
+
if (urlBefore)
|
|
1503
|
+
out.push(`const ${urlBefore} = page.url();`);
|
|
1504
|
+
// Everything from here to the action itself is what the already-in-effect
|
|
1505
|
+
// guard wraps, so remember where it starts.
|
|
1506
|
+
const actionStart = out.length;
|
|
1507
|
+
const picksBefore = ctx.picks;
|
|
1508
|
+
const target = actionTarget(step, 'target', ctx, out, { first, any });
|
|
1509
|
+
if (!target) {
|
|
1510
|
+
ctx.warnings.push(`${ctx.stepId}: step ${index} (${step.tool}) has no locator a spec can express`);
|
|
1511
|
+
out.push(`// TODO: no locator this compiler can express for ${step.tool} — fill it in by hand.`);
|
|
1512
|
+
return out;
|
|
1513
|
+
}
|
|
1514
|
+
switch (step.tool) {
|
|
1515
|
+
case 'click':
|
|
1516
|
+
out.push(`await click(${target});`);
|
|
1517
|
+
break;
|
|
1518
|
+
case 'dblclick':
|
|
1519
|
+
out.push(`await click(${target}, { dbl: true });`);
|
|
1520
|
+
break;
|
|
1521
|
+
// Not through the tiers: tools.ts dispatches a right or modifier click as a
|
|
1522
|
+
// plain, single Playwright click too (only click/dblclick reach
|
|
1523
|
+
// robustClick), and a FORCED right click on the wrong layer would open
|
|
1524
|
+
// someone else's context menu.
|
|
1525
|
+
case 'right_click':
|
|
1526
|
+
out.push(`await ${target}.click({ button: 'right' }); // plain, as replay dispatches it — robustClick's tiers are for click/dblclick only`);
|
|
1527
|
+
break;
|
|
1528
|
+
case 'modifier_click': {
|
|
1529
|
+
const mods = Array.isArray(args.modifiers) ? args.modifiers : [];
|
|
1530
|
+
out.push(`await ${target}.click({ modifiers: [${mods.map(q).join(', ')}] }); // plain, as replay dispatches it — robustClick's tiers are for click/dblclick only`);
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
case 'fill':
|
|
1534
|
+
// Through the inlined helper, never `locator.fill`: see its comment.
|
|
1535
|
+
out.push(`await fill(${target}, ${src(str('value'))});`);
|
|
1536
|
+
break;
|
|
1537
|
+
case 'type': {
|
|
1538
|
+
const delay = num('delay_ms');
|
|
1539
|
+
out.push(`await ${target}.pressSequentially(${src(str('text'))}${delay === undefined ? '' : `, { delay: ${delay} }`});`);
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
case 'press':
|
|
1543
|
+
out.push(`await ${target}.press(${src(str('key'))});`);
|
|
1544
|
+
break;
|
|
1545
|
+
case 'select': {
|
|
1546
|
+
// By label first, not value: the recording watched a human pick the
|
|
1547
|
+
// option they could read, and an app is free to renumber its values.
|
|
1548
|
+
// Through the inlined helper, never `locator.selectOption` alone — the
|
|
1549
|
+
// recorded `optionValue` is the last resort replay itself keeps.
|
|
1550
|
+
const fallback = typeof args.optionValue === 'string' && args.optionValue ? `, ${src(str('optionValue'))}` : '';
|
|
1551
|
+
out.push(`await select(${target}, ${src(str('option'))}${fallback});`);
|
|
1552
|
+
break;
|
|
1553
|
+
}
|
|
1554
|
+
case 'check':
|
|
1555
|
+
out.push(`await ${target}.${args.checked === false ? 'uncheck' : 'check'}();`);
|
|
1556
|
+
break;
|
|
1557
|
+
case 'hover':
|
|
1558
|
+
// Through the inlined helper, never `locator.hover` alone: see its comment.
|
|
1559
|
+
out.push(`await hover(${target});`);
|
|
1560
|
+
break;
|
|
1561
|
+
case 'scroll_into_view':
|
|
1562
|
+
out.push(`await ${target}.scrollIntoViewIfNeeded();`);
|
|
1563
|
+
break;
|
|
1564
|
+
case 'upload': {
|
|
1565
|
+
const paths = Array.isArray(args.paths) ? args.paths : [];
|
|
1566
|
+
out.push(`await ${target}.setInputFiles([${paths.map(q).join(', ')}]);`);
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
case 'download': {
|
|
1570
|
+
const n = ++ctx.downloads;
|
|
1571
|
+
out.push(`const downloadPromise${n} = page.waitForEvent('download');`);
|
|
1572
|
+
out.push(`await ${target}.click();`);
|
|
1573
|
+
out.push(`const download${n} = await downloadPromise${n};`);
|
|
1574
|
+
out.push(`await download${n}.saveAs(${args.save_path ? src(str('save_path')) : `\`downloads/\${download${n}.suggestedFilename()}\``});`);
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
case 'drag': {
|
|
1578
|
+
const source = actionTarget(step, 'source', ctx, out);
|
|
1579
|
+
if (source)
|
|
1580
|
+
out.push(`await ${source}.dragTo(${target});`);
|
|
1581
|
+
else
|
|
1582
|
+
out.push(`// TODO: no locator this compiler can express for the drag source.`);
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
case 'wait_for':
|
|
1586
|
+
out.push(waitForLine(target, args, num('timeout_ms')));
|
|
1587
|
+
break;
|
|
1588
|
+
default:
|
|
1589
|
+
out.push(`// TODO: recorded tool ${step.tool} has no Tier 2 form.`);
|
|
1590
|
+
ctx.warnings.push(`${ctx.stepId}: step ${index} uses tool ${step.tool}, which has no Tier 2 form`);
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
wrapAlreadyInEffect(step, ctx, out, actionStart);
|
|
1594
|
+
// A flagged step whose target resolved to a SINGLE candidate emitted no
|
|
1595
|
+
// `pick`, so there is no throw to carry the note: the action is a bare
|
|
1596
|
+
// locator call whose Playwright timeout says only that a selector never
|
|
1597
|
+
// resolved. Rethrow with the diagnostic appended, so every way a flagged
|
|
1598
|
+
// step can fail says the same thing.
|
|
1599
|
+
if (ctx.note && ctx.picks === picksBefore)
|
|
1600
|
+
noteRethrow(out, actionStart, ctx.note);
|
|
1601
|
+
derivedLines(segment, index, ctx, out, urlBefore);
|
|
1602
|
+
if (step.mints) {
|
|
1603
|
+
out.push(`// This step CREATES a record (its id is url part ${q(step.mints.at)}) — clean it up in your teardown.`);
|
|
1604
|
+
// Nothing else read the post-action url here, so publish the id rather than
|
|
1605
|
+
// leave a teardown to re-derive it: replay keeps the same value (res.created)
|
|
1606
|
+
// for exactly this reason. Read the settled way, off the url the step
|
|
1607
|
+
// navigated TO — the same reason derived values cannot be read in the
|
|
1608
|
+
// click's own tick.
|
|
1609
|
+
if (!derivedHere(segment, index).length) {
|
|
1610
|
+
out.push(`outputs[${q(`${ctx.stepId}.minted`)}] = await urlPartWhen(page, ${q(step.mints.at)}, ${urlBefore});`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
if (!isRead) {
|
|
1614
|
+
effectLines(step, ctx, out);
|
|
1615
|
+
expectationLines(step, out);
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Wrap `out[from..]` in a try/catch that appends `note` to whatever it throws.
|
|
1621
|
+
*
|
|
1622
|
+
* The note is compile's diagnostic for this flow step, and a rethrow — not a
|
|
1623
|
+
* swallow — is the point: the step still fails, it just stops lying about why.
|
|
1624
|
+
*/
|
|
1625
|
+
function noteRethrow(out, from, note) {
|
|
1626
|
+
const inner = out.splice(from).map((l) => l
|
|
1627
|
+
.split('\n')
|
|
1628
|
+
.map((x) => (x ? ` ${x}` : x))
|
|
1629
|
+
.join('\n'));
|
|
1630
|
+
out.push('try {', ...inner, '} catch (err) {', ` if (err instanceof Error) err.message += ${q(`\n ${note}`)};`, ' throw err;', '}');
|
|
1631
|
+
}
|
|
1632
|
+
function waitForLine(target, args, timeout) {
|
|
1633
|
+
const only = timeout && timeout !== DEFAULT_WAIT_MS ? `{ timeout: ${timeout} }` : '';
|
|
1634
|
+
const opt = only ? `, ${only}` : '';
|
|
1635
|
+
switch (String(args.state)) {
|
|
1636
|
+
case 'visible':
|
|
1637
|
+
return `await expect(${target}).toBeVisible(${only});`;
|
|
1638
|
+
case 'hidden':
|
|
1639
|
+
return `await expect(${target}).toBeHidden(${only});`;
|
|
1640
|
+
case 'text_equals':
|
|
1641
|
+
return `await expect(${target}).toHaveText(${src(String(args.text ?? ''))}${opt});`;
|
|
1642
|
+
case 'text_contains':
|
|
1643
|
+
return `await expect(${target}).toContainText(${src(String(args.text ?? ''))}${opt});`;
|
|
1644
|
+
case 'count':
|
|
1645
|
+
return `await expect(${target}).toHaveCount(${Number(args.count ?? 0)}${opt});`;
|
|
1646
|
+
default:
|
|
1647
|
+
return `// TODO: recorded wait_for state ${String(args.state)} has no Tier 2 form.`;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* A read publishes the value later steps reference by `<stepId>.<label>`; an
|
|
1652
|
+
* unlabelled one never reaches here.
|
|
1653
|
+
*
|
|
1654
|
+
* Resolution and read go through `readOptional` TOGETHER, single candidate or
|
|
1655
|
+
* many, so that neither half can fail the flow: replay skips a read it cannot
|
|
1656
|
+
* resolve — and one whose read errors — and carries on, because an observation
|
|
1657
|
+
* that could not be re-captured says nothing about whether the procedure ran.
|
|
1658
|
+
* Emitting the single-candidate case as a bare `await loc.textContent()` would
|
|
1659
|
+
* have thrown on exactly the same page where the multi-candidate case does.
|
|
1660
|
+
*/
|
|
1661
|
+
function readLines(step, ctx, opts) {
|
|
1662
|
+
const what = String(step.args?.what ?? 'text');
|
|
1663
|
+
const out = `outputs[${q(`${ctx.stepId}.${step.label ?? ''}`)}]`;
|
|
1664
|
+
const loc = opts.first ? 'loc.first()' : 'loc';
|
|
1665
|
+
let read = null;
|
|
1666
|
+
if (what === 'value')
|
|
1667
|
+
read = `async (loc: Locator) => await ${loc}.inputValue()`;
|
|
1668
|
+
// read_all legitimately matches many elements, so textContent's strict mode
|
|
1669
|
+
// would throw where replay read every match.
|
|
1670
|
+
else if (what === 'text') {
|
|
1671
|
+
read =
|
|
1672
|
+
step.tool === 'read_all'
|
|
1673
|
+
? `async (loc: Locator) => (await ${loc}.allTextContents()).join('\\n')`
|
|
1674
|
+
: `async (loc: Locator) => (await ${loc}.textContent()) ?? ''`;
|
|
1675
|
+
}
|
|
1676
|
+
if (!read)
|
|
1677
|
+
return [`// TODO: read what=${commentSafe(what)} has no Tier 2 form (label ${commentSafe(step.label ?? '')}).`];
|
|
1678
|
+
const chain = step.locators?.target ?? [];
|
|
1679
|
+
noteSlots(chain, ctx);
|
|
1680
|
+
const { sources } = candidateSources(chain, { slot: slotAsParam });
|
|
1681
|
+
if (!sources.length) {
|
|
1682
|
+
ctx.warnings.push(`${ctx.stepId}: step ${ctx.stepIndex} (${step.tool}) has no locator a spec can express`);
|
|
1683
|
+
return [`// TODO: no locator this compiler can express for ${step.tool} — fill it in by hand.`];
|
|
1684
|
+
}
|
|
1685
|
+
const where = `${ctx.stepId} ${ctx.segmentId}/${ctx.stepIndex} target`;
|
|
1686
|
+
const lines = [`${out} = await readOptional(page, [`];
|
|
1687
|
+
for (const source of sources)
|
|
1688
|
+
lines.push(`${CONT_INDENT}${source},`);
|
|
1689
|
+
lines.push(`], ${q(where)}, ${read}${opts.any ? ', { any: true }' : ''});`);
|
|
1690
|
+
return lines;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* A folded loop: the recording did the same thing to record after record,
|
|
1694
|
+
* and replay repeats the body while the guard still matches, capped at
|
|
1695
|
+
* `max`. Tier 2 keeps the guard and the cap and always acts on the first
|
|
1696
|
+
* match — right for a list that shrinks, and the one place a spec cannot
|
|
1697
|
+
* follow replay's cursor, so it says so.
|
|
1698
|
+
*/
|
|
1699
|
+
function emitLoop(step, segment, index, ctx) {
|
|
1700
|
+
const body = step.body ?? [];
|
|
1701
|
+
const guardChain = step.while ?? body[0]?.locators?.target ?? [];
|
|
1702
|
+
noteSlots(guardChain, ctx);
|
|
1703
|
+
const guard = chainSource(guardChain, { slot: slotAsParam, indent: CONT_INDENT }).source;
|
|
1704
|
+
if (!guard || !body.length) {
|
|
1705
|
+
ctx.warnings.push(`${ctx.stepId}: step ${index} is a loop with no ${guard ? 'body' : 'guard a spec can express'}`);
|
|
1706
|
+
return [
|
|
1707
|
+
`// @step ${ctx.stepId} ${segment.id}/${index}`,
|
|
1708
|
+
`// TODO: recorded loop at step ${index} has no ${guard ? 'body' : 'expressible guard'}.`,
|
|
1709
|
+
];
|
|
1710
|
+
}
|
|
1711
|
+
const max = step.max ?? DEFAULT_LOOP_MAX;
|
|
1712
|
+
const name = `guard${++ctx.loops}`;
|
|
1713
|
+
ctx.segmentId = segment.id;
|
|
1714
|
+
ctx.stepIndex = index;
|
|
1715
|
+
const out = [
|
|
1716
|
+
`// @step ${ctx.stepId} ${segment.id}/${index}`,
|
|
1717
|
+
'// The recording folded a run of identical actions into a loop. Each pass acts on the',
|
|
1718
|
+
'// FIRST match: right for a list that shrinks, and all a spec can do — replay advances a',
|
|
1719
|
+
'// cursor here when the list stays the same length (see runLoop).',
|
|
1720
|
+
`const ${name} = ${guard};`,
|
|
1721
|
+
`for (let i = 0; i < ${max} && (await ${name}.count()) > 0; i++) {`,
|
|
1722
|
+
];
|
|
1723
|
+
for (const [k, bstep] of body.entries()) {
|
|
1724
|
+
for (const line of emitSkillStep(bstep, segment, index, ctx, true)) {
|
|
1725
|
+
out.push(...line.split('\n').map((l) => (l ? ' ' + l : l)));
|
|
1726
|
+
}
|
|
1727
|
+
if (k < body.length - 1)
|
|
1728
|
+
out.push('');
|
|
1729
|
+
}
|
|
1730
|
+
out.push('}');
|
|
1731
|
+
return out;
|
|
1732
|
+
}
|
|
1733
|
+
/** Whether every slot in a marker is bound by this segment's params or its derived values. */
|
|
1734
|
+
function markerBound(marker, segment) {
|
|
1735
|
+
const slots = [...marker.matchAll(/\{\{([vd]\d+)\}\}/g)].map((m) => m[1]);
|
|
1736
|
+
if (!slots.length)
|
|
1737
|
+
return Boolean(marker.trim());
|
|
1738
|
+
return slots.every((s) => s in segment.params || s in (segment.derived ?? {}));
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* A flow-step param value as it renders in the emitted body: a lone slot is the
|
|
1742
|
+
* param itself (`p.v1`), anything else is recorded text with its slots filled.
|
|
1743
|
+
*/
|
|
1744
|
+
function valueSource(text) {
|
|
1745
|
+
const only = /^\{\{([vd]\d+)\}\}$/.exec(text);
|
|
1746
|
+
return only ? `p.${only[1]}` : src(text);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* The "already satisfied" guard at the top of a step body, when the step's
|
|
1750
|
+
* procedure carries one.
|
|
1751
|
+
*
|
|
1752
|
+
* WHY A STEP MAY BE ASKED TO DO WHAT IS ALREADY DONE. A flow is a record of
|
|
1753
|
+
* what the orchestrator did, and it retries: fwod34's 06-open asked for an
|
|
1754
|
+
* order to be cancelled and its recording did not land the cancel, so 08-open
|
|
1755
|
+
* was recorded asking for the same cancel again. On REPLAY 06-open works —
|
|
1756
|
+
* and 08-open then goes looking for a Cancel button that a cancelled order
|
|
1757
|
+
* does not have. The recorded procedure is fine; it is simply being run on a
|
|
1758
|
+
* record that has already reached its destination.
|
|
1759
|
+
*
|
|
1760
|
+
* So: identity (this is the right record) AND the goal (it is already in the
|
|
1761
|
+
* state the step produces) short-circuit the step, publishing the values its
|
|
1762
|
+
* read-backs would have published so the steps after it see the same shape.
|
|
1763
|
+
* Emitted only where every marker is BOUND — an unbound `{{v1}}` proves
|
|
1764
|
+
* nothing, and a guard that cannot be sure is not emitted at all, which
|
|
1765
|
+
* simply leaves the step running exactly as it does today.
|
|
1766
|
+
*/
|
|
1767
|
+
function satisfiedGuard(step, ctx) {
|
|
1768
|
+
const head = step.segments[0];
|
|
1769
|
+
const last = step.segments[step.segments.length - 1];
|
|
1770
|
+
const goal = last?.goal?.requireText ?? [];
|
|
1771
|
+
if (!head || !goal.length)
|
|
1772
|
+
return [];
|
|
1773
|
+
const identity = (head.preconditions.requireText ?? []).filter((m) => markerBound(m, head));
|
|
1774
|
+
if (!identity.length)
|
|
1775
|
+
return [];
|
|
1776
|
+
if (!goal.every((g) => markerBound(g, last)))
|
|
1777
|
+
return [];
|
|
1778
|
+
noteSlots([...identity, ...goal], ctx);
|
|
1779
|
+
const shown = goal.map((g) => `"${g}"`).join(', ');
|
|
1780
|
+
const say = `[sitelooper satisfied] ${step.id} — page shows ${shown}; nothing to do`;
|
|
1781
|
+
// On the page template the goal was read on, or the words mean nothing
|
|
1782
|
+
// (replay's goalSatisfied checks the same url pattern first).
|
|
1783
|
+
const at = urlExpectSource(head.preconditions.urlPattern);
|
|
1784
|
+
const onPage = at ? (at.startsWith('(url') ? `(${at})(new URL(page.url())) && ` : `${at}.test(page.url()) && `) : '';
|
|
1785
|
+
const out = [
|
|
1786
|
+
`// goal: the page already showing ${shown} for this record means the step's work is done —`,
|
|
1787
|
+
'// the same check replay makes before it acts (goalSatisfied, src/skills/replay.ts).',
|
|
1788
|
+
`if (${onPage}await satisfied(page, [${identity.map(src).join(', ')}], [${goal.map(src).join(', ')}])) {`,
|
|
1789
|
+
` console.log(${src(say)});`,
|
|
1790
|
+
];
|
|
1791
|
+
// The read-backs never run, so the report template stands in for them: the
|
|
1792
|
+
// same output keys, filled from this run's own params.
|
|
1793
|
+
for (const [label, value] of Object.entries(last.report?.values ?? {})) {
|
|
1794
|
+
if (!label || !value || !markerBound(value, last))
|
|
1795
|
+
continue;
|
|
1796
|
+
noteSlots(value, ctx);
|
|
1797
|
+
out.push(` outputs[${q(`${step.id}.${label}`)}] = ${valueSource(value)};`);
|
|
1798
|
+
}
|
|
1799
|
+
out.push(' return;', '}');
|
|
1800
|
+
return out;
|
|
1801
|
+
}
|
|
1802
|
+
/** One segment: its preconditions, then its steps. */
|
|
1803
|
+
function emitSegment(segment, ctx) {
|
|
1804
|
+
const out = [];
|
|
1805
|
+
ctx.lastUrl = null;
|
|
1806
|
+
out.push(`// ${segment.id}: ${commentSafe(segment.template)}`);
|
|
1807
|
+
out.push(`// recorded on a page matching ${commentSafe(segment.preconditions.urlPattern)}`);
|
|
1808
|
+
for (const marker of segment.preconditions.requireText ?? []) {
|
|
1809
|
+
// Identity: the url and the page shape match every record of this
|
|
1810
|
+
// template, so only the marker can say this is the RIGHT record. An
|
|
1811
|
+
// unbound marker proves nothing and is skipped, exactly as replay skips it.
|
|
1812
|
+
if (!markerBound(marker, segment)) {
|
|
1813
|
+
out.push(`// identity marker ${commentSafe(marker)} is unbound here — nothing to check.`);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
noteSlots(marker, ctx);
|
|
1817
|
+
out.push(`// identity: this must be the record the flow is working on, not another of the same shape.`);
|
|
1818
|
+
// Polled, not asserted once: replay reaches this gate after its own
|
|
1819
|
+
// settleDom, and a spec arrives on a page that may still be rendering.
|
|
1820
|
+
out.push(`await expect.poll(() => present(page, ${src(marker)}), { timeout: ${IDENTITY_WAIT_MS}, message: ${q(`identity: ${commentSafe(marker)} is not on this page`)} }).toBe(true);`);
|
|
1821
|
+
}
|
|
1822
|
+
for (const [i, step] of segment.steps.entries()) {
|
|
1823
|
+
out.push('');
|
|
1824
|
+
const lines = step.tool === 'loop' ? emitLoop(step, segment, i + 1, ctx) : emitSkillStep(step, segment, i + 1, ctx);
|
|
1825
|
+
out.push(...lines);
|
|
1826
|
+
}
|
|
1827
|
+
return out;
|
|
1828
|
+
}
|
|
1829
|
+
/** The slots a step's `p` object carries: every param of every segment, plus what they mint. */
|
|
1830
|
+
function slotsOf(step, found) {
|
|
1831
|
+
const names = new Set(found);
|
|
1832
|
+
for (const seg of step.segments) {
|
|
1833
|
+
for (const name of Object.keys(seg.params))
|
|
1834
|
+
names.add(name);
|
|
1835
|
+
for (const name of Object.keys(seg.derived ?? {}))
|
|
1836
|
+
names.add(name);
|
|
1837
|
+
}
|
|
1838
|
+
return [...names].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* A flow-step param value as an expression: a literal, a run var, an earlier
|
|
1842
|
+
* step's output, or an environment secret. Secrets stay markers everywhere
|
|
1843
|
+
* until the moment they are used — see shared/secrets.ts — and that holds in
|
|
1844
|
+
* a compiled spec too: the emitted file names the variable, never the value.
|
|
1845
|
+
*/
|
|
1846
|
+
function paramExpr(template, vars) {
|
|
1847
|
+
const parts = [];
|
|
1848
|
+
let last = 0;
|
|
1849
|
+
for (const m of template.matchAll(/\{\{([\w.#:-]+)\}\}/g)) {
|
|
1850
|
+
const at = m.index ?? 0;
|
|
1851
|
+
if (at > last)
|
|
1852
|
+
parts.push({ lit: template.slice(last, at) });
|
|
1853
|
+
parts.push({ expr: refExpr(m[1], vars) });
|
|
1854
|
+
last = at + m[0].length;
|
|
1855
|
+
}
|
|
1856
|
+
if (last < template.length)
|
|
1857
|
+
parts.push({ lit: template.slice(last) });
|
|
1858
|
+
if (!parts.length)
|
|
1859
|
+
return q('');
|
|
1860
|
+
if (parts.length === 1 && parts[0].expr)
|
|
1861
|
+
return parts[0].expr;
|
|
1862
|
+
if (parts.every((p) => p.lit !== undefined))
|
|
1863
|
+
return q(parts.map((p) => p.lit).join(''));
|
|
1864
|
+
return '`' + parts.map((p) => (p.lit !== undefined ? templateSafe(p.lit) : '${' + p.expr + '}')).join('') + '`';
|
|
1865
|
+
}
|
|
1866
|
+
function refExpr(ref, vars) {
|
|
1867
|
+
const secret = /^env:([A-Za-z_][A-Za-z0-9_]*)$/.exec(ref);
|
|
1868
|
+
if (secret)
|
|
1869
|
+
return `process.env.${secret[1]} ?? ''`;
|
|
1870
|
+
if (ref.includes('.'))
|
|
1871
|
+
return `outputs[${q(ref)}] ?? ''`;
|
|
1872
|
+
if (vars.has(ref))
|
|
1873
|
+
return `vars.${key(ref)}`.replace(`vars.'${ref}'`, `vars[${q(ref)}]`);
|
|
1874
|
+
// A reference to something the flow never declared: honest at run time
|
|
1875
|
+
// rather than a compile-time guess at what the caller meant.
|
|
1876
|
+
return `(vars as Record<string, string>)[${q(ref)}] ?? ''`;
|
|
1877
|
+
}
|
|
1878
|
+
/** The `{ v1: …, d1: '' }` argument one step is called with. */
|
|
1879
|
+
function callArgs(step, slots, vars, warnings) {
|
|
1880
|
+
const derived = new Set(step.segments.flatMap((s) => Object.keys(s.derived ?? {})));
|
|
1881
|
+
const fields = slots.map((slot) => {
|
|
1882
|
+
// A minted value has no caller binding by construction: the body reads it
|
|
1883
|
+
// off the live url after the step that creates it.
|
|
1884
|
+
if (derived.has(slot))
|
|
1885
|
+
return `${slot}: ''`;
|
|
1886
|
+
const bound = step.params[slot];
|
|
1887
|
+
if (bound !== undefined)
|
|
1888
|
+
return `${slot}: ${paramExpr(bound, vars)}`;
|
|
1889
|
+
const example = step.segments.map((s) => s.params[slot]?.example).find((e) => typeof e === 'string');
|
|
1890
|
+
if (example === undefined)
|
|
1891
|
+
return `${slot}: ''`;
|
|
1892
|
+
// No flow binding: the recording's own value is the only one there is,
|
|
1893
|
+
// and inlining it silently is how a replay comes to work the recorded
|
|
1894
|
+
// run's record. Emitted, but the caller is told.
|
|
1895
|
+
warnings.push(`${step.id}: slot ${slot} has no flow binding — the recorded value is inlined`);
|
|
1896
|
+
return `${slot}: ${paramExpr(example, vars)} /* recorded value; no flow binding */`;
|
|
1897
|
+
});
|
|
1898
|
+
return `{ ${fields.join(', ')} }`;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* The `{{<stepId>.url}}` / `{{<stepId>.url.<part>}}` references this flow's own
|
|
1902
|
+
* params make, grouped by the step that has to publish them.
|
|
1903
|
+
*
|
|
1904
|
+
* WHICH REPLAY RULE THIS MIRRORS. The flow runner publishes every step's END
|
|
1905
|
+
* URL as outputs — the whole url and each identifier-like part (`urlOutputs`
|
|
1906
|
+
* in skills/flow.ts) — and that is how a later step's param
|
|
1907
|
+
* `http://…/d/{{02-create.url.p1}}/…` gets a value. A compiled body published
|
|
1908
|
+
* none of them: it binds what the step mints into its own `p.dN`, which is
|
|
1909
|
+
* segment-local, while `refExpr` resolves the flow-level reference out of
|
|
1910
|
+
* `outputs`. So fwgr27's 03-add did `page.goto('http://127.0.0.1:3000/d//fwgr27-…')`
|
|
1911
|
+
* — the uid segment simply empty — and every locator after it missed on a page
|
|
1912
|
+
* that was not the dashboard. Only the consumed refs are published: an output
|
|
1913
|
+
* nothing reads is noise, and this is exactly what `consumedUrlOutputs` asks.
|
|
1914
|
+
*/
|
|
1915
|
+
function consumedUrlRefs(spec) {
|
|
1916
|
+
const wanted = new Map();
|
|
1917
|
+
for (const step of spec.steps) {
|
|
1918
|
+
for (const value of Object.values(step.params)) {
|
|
1919
|
+
for (const m of value.matchAll(/\{\{([\w-]+)\.(url(?:\.[\w.-]+)?)\}\}/g)) {
|
|
1920
|
+
const set = wanted.get(m[1]) ?? new Set();
|
|
1921
|
+
set.add(m[2]);
|
|
1922
|
+
wanted.set(m[1], set);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
// `url` first: it is the one output every step can publish without a read.
|
|
1927
|
+
return new Map([...wanted].map(([id, outs]) => [id, [...outs].sort()]));
|
|
1928
|
+
}
|
|
1929
|
+
/** The lines that publish one step's end-url outputs, or none. */
|
|
1930
|
+
function urlOutputLines(stepId, outs) {
|
|
1931
|
+
if (!outs?.length)
|
|
1932
|
+
return [];
|
|
1933
|
+
const lines = ['// Later steps refer to this step by where it left the browser, so publish its'];
|
|
1934
|
+
lines.push('// end url the way the flow runner does (urlOutputs / consumedUrlOutputs in');
|
|
1935
|
+
lines.push('// src/skills/flow.ts) — unpublished, `{{' + stepId + '.' + outs[0] + '}}` resolves to nothing.');
|
|
1936
|
+
for (const out of outs) {
|
|
1937
|
+
const key = `${stepId}.${out}`;
|
|
1938
|
+
if (out === 'url')
|
|
1939
|
+
lines.push(`outputs[${q(key)}] = page.url();`);
|
|
1940
|
+
// No urlBefore: nothing here acted, so the wait is simply for the part to
|
|
1941
|
+
// be there at all — an SPA can update its url a beat after the page itself
|
|
1942
|
+
// settles, which is what consumedUrlOutputs waits out.
|
|
1943
|
+
else
|
|
1944
|
+
lines.push(`outputs[${q(key)}] = await urlPartWhen(page, ${q(out.slice('url.'.length))});`);
|
|
1945
|
+
}
|
|
1946
|
+
return lines;
|
|
1947
|
+
}
|
|
1948
|
+
export function emitFlowFile(spec, o) {
|
|
1949
|
+
if (o.tier !== 'plain')
|
|
1950
|
+
throw new Error(`unknown emit tier ${String(o.tier)}`);
|
|
1951
|
+
const warnings = [];
|
|
1952
|
+
const vars = new Set(spec.vars);
|
|
1953
|
+
const urlRefs = consumedUrlRefs(spec);
|
|
1954
|
+
const flagged = flaggedByStep(o.diagnostics);
|
|
1955
|
+
// Bodies first: which helpers the file needs is decided by what they use.
|
|
1956
|
+
const bodies = spec.steps.map((step) => {
|
|
1957
|
+
const ctx = { stepId: step.id, slots: new Set(), warnings, downloads: 0, lastUrl: null, loops: 0, picks: 0, urls: 0, binds: 0, segmentId: '', stepIndex: 0, note: stepNote(flagged.get(step.id)) };
|
|
1958
|
+
const lines = [];
|
|
1959
|
+
if (!step.segments.length) {
|
|
1960
|
+
lines.push(`// TODO: no converged procedure for ${JSON.stringify(commentSafe(step.instruction))}`);
|
|
1961
|
+
lines.push(`throw new Error(${q(`step ${step.id} has no converged procedure — record it with sitelooper, then compile again`)});`);
|
|
1962
|
+
}
|
|
1963
|
+
else {
|
|
1964
|
+
const guard = satisfiedGuard(step, ctx);
|
|
1965
|
+
if (guard.length)
|
|
1966
|
+
lines.push(...guard, '');
|
|
1967
|
+
for (const [i, segment] of step.segments.entries()) {
|
|
1968
|
+
if (i)
|
|
1969
|
+
lines.push('');
|
|
1970
|
+
lines.push(...emitSegment(segment, ctx));
|
|
1971
|
+
}
|
|
1972
|
+
const published = urlOutputLines(step.id, urlRefs.get(step.id));
|
|
1973
|
+
if (published.length)
|
|
1974
|
+
lines.push('', ...published);
|
|
1975
|
+
}
|
|
1976
|
+
return { step, lines, slots: slotsOf(step, ctx.slots) };
|
|
1977
|
+
});
|
|
1978
|
+
const body = bodies.flatMap((b) => b.lines).join('\n');
|
|
1979
|
+
const helpers = neededHelpers(body);
|
|
1980
|
+
const out = [
|
|
1981
|
+
'// @sitelooper-flow v1',
|
|
1982
|
+
`// Generated by sitelooper from flow ${JSON.stringify(spec.name)} — do not edit by hand.`,
|
|
1983
|
+
'// Repair drift with `sitelooper repair <this file>`; the FLOW constant below is the source of truth.',
|
|
1984
|
+
`import { expect, ${helpers.some((h) => h.source.some((l) => l.includes('Locator'))) ? 'type Locator, ' : ''}type Page } from '@playwright/test';`,
|
|
1985
|
+
'',
|
|
1986
|
+
BEGIN_MARKER,
|
|
1987
|
+
`export const FLOW = ${JSON.stringify(spec, null, 2)};`,
|
|
1988
|
+
END_MARKER,
|
|
1989
|
+
'',
|
|
1990
|
+
`export type Vars = ${spec.vars.length ? `{ ${spec.vars.map((v) => `${key(v)}: string`).join('; ')} }` : 'Record<string, never>'};`,
|
|
1991
|
+
'/** Values the steps read back, keyed "<stepId>.<output>". */',
|
|
1992
|
+
'export interface Outputs {',
|
|
1993
|
+
' [key: string]: string;',
|
|
1994
|
+
'}',
|
|
1995
|
+
'/**',
|
|
1996
|
+
' * The wall-clock budget one run of this flow needs under a test runner: every',
|
|
1997
|
+
' * recorded step may spend a settle window, a pick, three click tiers and a 5s',
|
|
1998
|
+
" * expectation, so a flow is budgeted per recorded step, never at the runner's",
|
|
1999
|
+
' * 60s default. fwod34 (odoo, 9 flow steps, ~80 recorded steps) ran ~63s to',
|
|
2000
|
+
" * its 06-open before that default cut a click's retry short of the force",
|
|
2001
|
+
' * tier that replay reaches. The generated `.spec.ts` applies it with',
|
|
2002
|
+
' * `test.setTimeout(BUDGET_MS)`; your own spec can do the same or override it.',
|
|
2003
|
+
' */',
|
|
2004
|
+
`export const BUDGET_MS = ${budgetMs(spec)};`,
|
|
2005
|
+
'/**',
|
|
2006
|
+
' * Every `[sitelooper drift] …` line this run logged (see `pick` below):',
|
|
2007
|
+
' * a primary locator that missed and the recorded fallback that covered for',
|
|
2008
|
+
' * it. Always exported — even a flow with no multi-candidate step today may',
|
|
2009
|
+
" * gain one after a repair — so a caller's assertion never has to guess",
|
|
2010
|
+
' * whether it exists. Attach it from the user spec if you want it in the',
|
|
2011
|
+
" * Playwright report: `test.info().attach('sitelooper-drift', { body: DRIFT.join('\\n') })`.",
|
|
2012
|
+
' */',
|
|
2013
|
+
'export const DRIFT: string[] = [];',
|
|
2014
|
+
];
|
|
2015
|
+
for (const helper of helpers)
|
|
2016
|
+
out.push('', ...helper.source);
|
|
2017
|
+
out.push('', 'export const steps = {');
|
|
2018
|
+
for (const [i, b] of bodies.entries()) {
|
|
2019
|
+
if (i)
|
|
2020
|
+
out.push('');
|
|
2021
|
+
// Compile's verdict on this step, above the step, before its instruction:
|
|
2022
|
+
// a reviewer reading the generated file sees WHY it is expected to fail
|
|
2023
|
+
// and what to run, not a locator error they will read as app drift.
|
|
2024
|
+
for (const d of flagged.get(b.step.id) ?? []) {
|
|
2025
|
+
for (const line of formatDiagnostic(d).split('\n'))
|
|
2026
|
+
out.push(` // ${commentSafe(line)}`);
|
|
2027
|
+
}
|
|
2028
|
+
out.push(` /** ${commentSafe(b.step.instruction)} */`);
|
|
2029
|
+
const p = b.slots.length ? `{ ${b.slots.map((s) => `${s}: string`).join('; ')} }` : 'Record<string, string>';
|
|
2030
|
+
out.push(` async ${q(b.step.id)}(page: Page, p: ${p}, outputs: Outputs): Promise<void> {`);
|
|
2031
|
+
for (const line of b.lines)
|
|
2032
|
+
out.push(...line.split('\n').map((l) => (l ? ' ' + l : '')));
|
|
2033
|
+
out.push(' },');
|
|
2034
|
+
}
|
|
2035
|
+
out.push('};');
|
|
2036
|
+
out.push('', '/** Runs every step in order. */', 'export async function runFlow(page: Page, vars: Vars): Promise<Outputs> {');
|
|
2037
|
+
out.push(' const outputs: Outputs = {};');
|
|
2038
|
+
out.push(` await page.goto(${q(spec.startUrl)});`);
|
|
2039
|
+
for (const b of bodies) {
|
|
2040
|
+
out.push(` await steps[${q(b.step.id)}](page, ${callArgs(b.step, b.slots, vars, warnings)}, outputs);`);
|
|
2041
|
+
}
|
|
2042
|
+
out.push(' return outputs;', '}', '');
|
|
2043
|
+
return { source: out.join('\n'), warnings };
|
|
2044
|
+
}
|
|
2045
|
+
/** An environment variable name for a run var, so the scaffold has something to pass. */
|
|
2046
|
+
function envName(name) {
|
|
2047
|
+
return name.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase();
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* The user's half. Written once and never rewritten, so it is deliberately
|
|
2051
|
+
* thin: the call, and an invitation to assert whatever this suite cares
|
|
2052
|
+
* about. Everything the tool regenerates lives in the `.flow.ts` beside it.
|
|
2053
|
+
*/
|
|
2054
|
+
/** Per-step budget: settle (≤2s) + pick + three 5s click tiers + a 5s expectation, with headroom. */
|
|
2055
|
+
const STEP_BUDGET_MS = 30_000;
|
|
2056
|
+
const MIN_BUDGET_MS = 120_000;
|
|
2057
|
+
/** The test budget for one run of the whole flow, from its recorded step count. */
|
|
2058
|
+
export function budgetMs(spec) {
|
|
2059
|
+
const steps = spec.steps.reduce((n, st) => n + st.segments.reduce((m, seg) => m + seg.steps.length, 0), 0);
|
|
2060
|
+
return Math.max(MIN_BUDGET_MS, steps * STEP_BUDGET_MS);
|
|
2061
|
+
}
|
|
2062
|
+
export function emitSpecFile(spec) {
|
|
2063
|
+
const varFields = spec.vars.map((v) => `${key(v)}: process.env.${envName(v)} ?? ''`).join(', ');
|
|
2064
|
+
return [
|
|
2065
|
+
"import { test, expect } from '@playwright/test';",
|
|
2066
|
+
`import { runFlow, steps, DRIFT, BUDGET_MS } from './${spec.name}.flow';`,
|
|
2067
|
+
'',
|
|
2068
|
+
`test(${q(spec.name)}, async ({ page }) => {`,
|
|
2069
|
+
' // One test runs the whole flow: budget it by its recorded steps, not the 60s default.',
|
|
2070
|
+
' test.setTimeout(BUDGET_MS);',
|
|
2071
|
+
` const outputs = await runFlow(page, ${varFields ? `{ ${varFields} }` : '{}'});`,
|
|
2072
|
+
' // Add your own assertions here; this file is yours and sitelooper never rewrites it.',
|
|
2073
|
+
' // `outputs` holds every value the flow read back, keyed "<stepId>.<output>";',
|
|
2074
|
+
' // `steps` lets you run one step on its own. `DRIFT` accumulates one line per',
|
|
2075
|
+
' // recorded locator that missed and fell through to a later candidate — attach',
|
|
2076
|
+
' // it to the report if you want it visible without reading stderr:',
|
|
2077
|
+
" // if (DRIFT.length) await test.info().attach('sitelooper-drift', { body: DRIFT.join('\\n') });",
|
|
2078
|
+
' expect(Object.keys(outputs).length >= 0).toBe(true);',
|
|
2079
|
+
' void steps;',
|
|
2080
|
+
'});',
|
|
2081
|
+
'',
|
|
2082
|
+
].join('\n');
|
|
2083
|
+
}
|
|
2084
|
+
//# sourceMappingURL=emit.js.map
|