autokap 1.5.5 → 1.5.7
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/assets/skill/OPCODE-REFERENCE.md +6 -0
- package/assets/skill/SKILL.md +4 -4
- package/dist/action-verifier.d.ts +1 -1
- package/dist/action-verifier.js +68 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +9 -6
- package/dist/cli-contract.d.ts +1 -0
- package/dist/cli-contract.js +1 -0
- package/dist/cli-runner-local.d.ts +1 -0
- package/dist/cli-runner-local.js +1 -0
- package/dist/cli-runner.d.ts +6 -0
- package/dist/cli-runner.js +6 -3
- package/dist/cli.js +1 -0
- package/dist/execution-schema.d.ts +45 -45
- package/dist/execution-schema.js +24 -2
- package/dist/opcode-runner.js +136 -35
- package/dist/overlay-engine.js +18 -4
- package/dist/postcondition.js +74 -41
- package/dist/recovery-chain.js +17 -32
- package/dist/selector-resolver.js +25 -4
- package/dist/smart-wait.js +11 -2
- package/package.json +1 -1
|
@@ -47,6 +47,8 @@ Fallback when CSS selector fails. The runtime resolves via Playwright semantic l
|
|
|
47
47
|
| `threshold` | number (0-1) | `screenshot_stable` | Pixel diff tolerance. Default: `0.01` |
|
|
48
48
|
| `waitMs` | number | all except `always` | Max polling time (ms). Default: `5000` |
|
|
49
49
|
|
|
50
|
+
`any_change` is verifier-backed: the runtime must observe a URL, DOM, element state, overlay, or scroll change after the action. No-op actions fail.
|
|
51
|
+
|
|
50
52
|
---
|
|
51
53
|
|
|
52
54
|
## NAVIGATE
|
|
@@ -353,6 +355,8 @@ Assert the current URL matches a pattern. Pure validation, no navigation.
|
|
|
353
355
|
**Can:** Validate the browser is on the expected page. Useful as a checkpoint before capture.
|
|
354
356
|
**Cannot:** Navigate or change the URL. Use NAVIGATE for that.
|
|
355
357
|
|
|
358
|
+
`urlPattern` is the assertion source of truth. Use `postcondition: { "type": "always" }` or repeat the same `route_matches` pattern.
|
|
359
|
+
|
|
356
360
|
```json
|
|
357
361
|
{ "kind": "ASSERT_ROUTE", "urlPattern": "/dashboard/**", "postcondition": { "type": "route_matches", "pattern": "/dashboard/**" } }
|
|
358
362
|
```
|
|
@@ -369,6 +373,8 @@ Assert that specific elements are visible on the page. Pure validation.
|
|
|
369
373
|
**Can:** Validate page state before capture. Confirm multiple elements are rendered.
|
|
370
374
|
**Cannot:** Interact with elements or wait for them. Use WAIT_FOR for dynamic elements.
|
|
371
375
|
|
|
376
|
+
`selectors` and `matchAll` are the assertion source of truth. Use `postcondition: { "type": "always" }` or an `element_visible` postcondition for one of the asserted selectors.
|
|
377
|
+
|
|
372
378
|
```json
|
|
373
379
|
{ "kind": "ASSERT_SURFACE", "selectors": ["[data-ak=\"header\"]", "[data-ak=\"sidebar\"]"], "matchAll": true, "postcondition": { "type": "always" } }
|
|
374
380
|
```
|
package/assets/skill/SKILL.md
CHANGED
|
@@ -150,7 +150,7 @@ interface VariantSpec {
|
|
|
150
150
|
| `DISMISS_OVERLAYS` | no | — | `overlay_dismissed` | Always after NAVIGATE |
|
|
151
151
|
| `CLICK` | yes | `button?` | `element_visible` / `route_matches` | Postcondition = what CHANGED |
|
|
152
152
|
| `TYPE` | yes | `text`, `clearFirst` | `any_change` | `{{email}}` / `{{password}}` for creds |
|
|
153
|
-
| `PRESS_KEY` | no | `key` | `any_change` | `"Enter"`, `"Escape"`, `"Tab"`, etc
|
|
153
|
+
| `PRESS_KEY` | no | `key` | specific result / `any_change` | `"Enter"`, `"Escape"`, `"Tab"`, etc.; use `any_change` only when the key must visibly change state |
|
|
154
154
|
| `WAIT_FOR` | yes* | `state` | `element_visible` | `"visible"` or `"attached"` (DOM only) |
|
|
155
155
|
| `SLEEP` | no | `durationMs` (1..60000), `narrationTextByLocale?` | `always` | Pause N ms. Reserved for video narration anchors. Use `durationMs: 1` as a placeholder; AutoKap rewrites it during `autokap run` after generating TTS. |
|
|
156
156
|
| `SCROLL` | no | `direction`, `targetSelector?`, `amount?` | `element_visible` | Use `targetSelector` for precise scroll |
|
|
@@ -161,8 +161,8 @@ interface VariantSpec {
|
|
|
161
161
|
| `DRAG` | yes | `toSelector?` / `toTarget?` / `offset?` | `element_visible` / `any_change` | Animated cursor A→B. Use `toSelector` for Kanban-style drops, `offset` for sliders / canvas |
|
|
162
162
|
| `SET_LOCALE` | no | `locale`, `method`, `storageHints?` | `always` | Use `"$variant"`. Prefer `method: "storage"` |
|
|
163
163
|
| `SET_THEME` | no | `theme`, `method`, `storageHints?` | `always` | Use `"$variant"`. Prefer `method: "storage"` |
|
|
164
|
-
| `ASSERT_ROUTE` | no | `urlPattern` | `route_matches` |
|
|
165
|
-
| `ASSERT_SURFACE` | no | `selectors[]`, `matchAll` | `always` |
|
|
164
|
+
| `ASSERT_ROUTE` | no | `urlPattern` | `always` / matching `route_matches` | `urlPattern` is the source of truth |
|
|
165
|
+
| `ASSERT_SURFACE` | no | `selectors[]`, `matchAll` | `always` / matching `element_visible` | `selectors` + `matchAll` are the source of truth |
|
|
166
166
|
| `CAPTURE_SCREENSHOT` | no | `captureId`, `captureName`, `elementSelector?`, `outscale?` | `always` | `elementSelector` for element-level crop. `outscale` adds padding around the element (user-edited post-generation; omit by default) |
|
|
167
167
|
| `BEGIN_CLIP` | no | `clipId`, `clipName` | `always` | Start recording |
|
|
168
168
|
| `END_CLIP` | no | `clipId`, `clipName` | `always` | Stop recording. Same `clipId` as BEGIN_CLIP |
|
|
@@ -183,7 +183,7 @@ interface VariantSpec {
|
|
|
183
183
|
| `text_contains` | `selector`, `text` | After TYPE, SELECT_OPTION |
|
|
184
184
|
| `overlay_dismissed` | — | After DISMISS_OVERLAYS |
|
|
185
185
|
| `screenshot_stable` | `threshold?` (0-1), `waitMs?` | Before CAPTURE_SCREENSHOT when page has animations |
|
|
186
|
-
| `any_change` | — |
|
|
186
|
+
| `any_change` | — | Verifier-backed state change after TYPE, CLICK, SELECT_OPTION, DOUBLE_CLICK, DRAG, etc.; no-op actions fail |
|
|
187
187
|
| `always` | — | CAPTURE_SCREENSHOT, SET_LOCALE, SET_THEME, CHECK, BEGIN/END_CLIP |
|
|
188
188
|
|
|
189
189
|
## What Presets Can and Cannot Do
|
|
@@ -13,7 +13,7 @@ export interface ActionVerification {
|
|
|
13
13
|
/** Summary for logging */
|
|
14
14
|
summary: string;
|
|
15
15
|
}
|
|
16
|
-
export type ActionChangeKind = 'url_changed' | 'tree_structure_changed' | 'node_appeared' | 'node_disappeared' | 'overlay_changed' | 'no_change';
|
|
16
|
+
export type ActionChangeKind = 'url_changed' | 'tree_structure_changed' | 'node_appeared' | 'node_disappeared' | 'node_state_changed' | 'scroll_changed' | 'overlay_changed' | 'no_change';
|
|
17
17
|
export interface ActionChange {
|
|
18
18
|
kind: ActionChangeKind;
|
|
19
19
|
detail: string;
|
package/dist/action-verifier.js
CHANGED
|
@@ -99,6 +99,38 @@ export class ActionVerifier {
|
|
|
99
99
|
detail: `title: "${this.beforeTree.page.title}" -> "${afterTree.page.title}"`,
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
+
// 6. Check focused/value/selection/text state on stable nodes. This is
|
|
103
|
+
// what makes `any_change` meaningful for TYPE, CHECK, SELECT_OPTION and
|
|
104
|
+
// tab-like controls that update state without changing the DOM shape.
|
|
105
|
+
const beforeState = collectVisibleNodeState(this.beforeTree.root);
|
|
106
|
+
const afterState = collectVisibleNodeState(afterTree.root);
|
|
107
|
+
const changedStates = [];
|
|
108
|
+
for (const [key, before] of beforeState) {
|
|
109
|
+
const after = afterState.get(key);
|
|
110
|
+
if (!after)
|
|
111
|
+
continue;
|
|
112
|
+
if (before !== after) {
|
|
113
|
+
changedStates.push(key);
|
|
114
|
+
if (changedStates.length >= 5)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (changedStates.length > 0) {
|
|
119
|
+
changes.push({
|
|
120
|
+
kind: 'node_state_changed',
|
|
121
|
+
detail: `${changedStates.length} visible node state change(s)`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// 7. Check document or nested scroll movement. SCROLL often changes only
|
|
125
|
+
// scroll offsets; without this, a valid scroll can look like a no-op.
|
|
126
|
+
const beforeScroll = collectScrollState(this.beforeTree);
|
|
127
|
+
const afterScroll = collectScrollState(afterTree);
|
|
128
|
+
if (beforeScroll !== afterScroll) {
|
|
129
|
+
changes.push({
|
|
130
|
+
kind: 'scroll_changed',
|
|
131
|
+
detail: `${beforeScroll} -> ${afterScroll}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
102
134
|
// If no changes detected at all
|
|
103
135
|
if (changes.length === 0) {
|
|
104
136
|
changes.push({ kind: 'no_change', detail: 'no detectable changes in URL, tree, or overlays' });
|
|
@@ -130,4 +162,40 @@ function collectVisibleInteractiveIds(node) {
|
|
|
130
162
|
walk(node);
|
|
131
163
|
return ids;
|
|
132
164
|
}
|
|
165
|
+
function collectVisibleNodeState(root) {
|
|
166
|
+
const states = new Map();
|
|
167
|
+
function keyFor(node) {
|
|
168
|
+
return node.sourceRef || node.id;
|
|
169
|
+
}
|
|
170
|
+
function walk(node) {
|
|
171
|
+
if (node.visible) {
|
|
172
|
+
states.set(keyFor(node), JSON.stringify({
|
|
173
|
+
label: node.label,
|
|
174
|
+
value: node.value ?? '',
|
|
175
|
+
disabled: node.state.disabled,
|
|
176
|
+
focused: node.state.focused,
|
|
177
|
+
checked: node.state.checked ?? null,
|
|
178
|
+
expanded: node.state.expanded ?? null,
|
|
179
|
+
selected: node.state.selected ?? null,
|
|
180
|
+
ownText: node.attributes.__ownText ?? '',
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
node.children.forEach(walk);
|
|
184
|
+
}
|
|
185
|
+
walk(root);
|
|
186
|
+
return states;
|
|
187
|
+
}
|
|
188
|
+
function collectScrollState(tree) {
|
|
189
|
+
const parts = [
|
|
190
|
+
`page:${tree.page.scroll.scrollTop},${tree.page.scroll.scrollLeft}`,
|
|
191
|
+
];
|
|
192
|
+
function walk(node) {
|
|
193
|
+
if (node.scroll) {
|
|
194
|
+
parts.push(`${node.sourceRef || node.id}:${node.scroll.scrollTop},${node.scroll.scrollLeft}`);
|
|
195
|
+
}
|
|
196
|
+
node.children.forEach(walk);
|
|
197
|
+
}
|
|
198
|
+
walk(tree.root);
|
|
199
|
+
return parts.join('|');
|
|
200
|
+
}
|
|
133
201
|
//# sourceMappingURL=action-verifier.js.map
|
package/dist/browser.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StableSelectorSnapshot {
|
|
|
14
14
|
ariaLabel?: string | null;
|
|
15
15
|
title?: string | null;
|
|
16
16
|
placeholder?: string | null;
|
|
17
|
+
dataAk?: string | null;
|
|
18
|
+
dataAkInteract?: string | null;
|
|
17
19
|
dataTestId?: string | null;
|
|
18
20
|
dataTest?: string | null;
|
|
19
21
|
dataQa?: string | null;
|
package/dist/browser.js
CHANGED
|
@@ -746,6 +746,8 @@ export function buildStableCssSelectorCandidates(snapshot) {
|
|
|
746
746
|
push(`#${escapeCssIdentifier(snapshot.id)}`);
|
|
747
747
|
}
|
|
748
748
|
for (const [attr, value] of [
|
|
749
|
+
['data-ak', snapshot.dataAk],
|
|
750
|
+
['data-ak-interact', snapshot.dataAkInteract],
|
|
749
751
|
['data-testid', snapshot.dataTestId],
|
|
750
752
|
['data-test', snapshot.dataTest],
|
|
751
753
|
['data-qa', snapshot.dataQa],
|
|
@@ -1740,7 +1742,7 @@ export class Browser {
|
|
|
1740
1742
|
if (id) {
|
|
1741
1743
|
push(`#${CSS.escape(id)}`);
|
|
1742
1744
|
}
|
|
1743
|
-
for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
1745
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
1744
1746
|
const value = node.getAttribute(attr);
|
|
1745
1747
|
if (!value)
|
|
1746
1748
|
continue;
|
|
@@ -1922,7 +1924,7 @@ export class Browser {
|
|
|
1922
1924
|
try {
|
|
1923
1925
|
return await page.evaluate(() => {
|
|
1924
1926
|
const SKIP_TAGS = new Set(['STYLE', 'SCRIPT', 'SVG', 'NOSCRIPT', 'LINK', 'META', 'BR', 'HR', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'SOURCE', 'PICTURE', 'TEMPLATE']);
|
|
1925
|
-
const KEEP_ATTRS = new Set(['id', 'role', 'aria-label', 'aria-expanded', 'aria-haspopup', 'aria-controls', 'aria-selected', 'aria-checked', 'aria-disabled', 'href', 'type', 'name', 'placeholder', 'alt', 'title', 'value', 'for', 'action', 'method', 'data-testid']);
|
|
1927
|
+
const KEEP_ATTRS = new Set(['id', 'role', 'aria-label', 'aria-expanded', 'aria-haspopup', 'aria-controls', 'aria-selected', 'aria-checked', 'aria-disabled', 'href', 'type', 'name', 'placeholder', 'alt', 'title', 'value', 'for', 'action', 'method', 'data-ak', 'data-ak-interact', 'data-ak-fill-input', 'data-ak-fill-trigger', 'data-testid']);
|
|
1926
1928
|
const MAX_CHARS = 4000;
|
|
1927
1929
|
let output = '';
|
|
1928
1930
|
let stopped = false;
|
|
@@ -2335,7 +2337,7 @@ export class Browser {
|
|
|
2335
2337
|
const snapshot = await page.evaluate(() => {
|
|
2336
2338
|
const SKIP_TAGS = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'NOSCRIPT', 'SOURCE', 'PATH', 'G', 'DEFS', 'CLIPPATH']);
|
|
2337
2339
|
const SEMANTIC_TAGS = new Set(['NAV', 'MAIN', 'HEADER', 'FOOTER', 'SECTION', 'ARTICLE', 'ASIDE', 'FORM']);
|
|
2338
|
-
const TEST_ID_ATTRS = ['data-testid', 'data-test', 'data-qa', 'data-cy'];
|
|
2340
|
+
const TEST_ID_ATTRS = ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy'];
|
|
2339
2341
|
const quoteForSelector = (value) => value
|
|
2340
2342
|
.replace(/\\/g, '\\\\')
|
|
2341
2343
|
.replace(/"/g, '\\"');
|
|
@@ -2537,7 +2539,8 @@ export class Browser {
|
|
|
2537
2539
|
const attrs = {};
|
|
2538
2540
|
const copyAttrs = [
|
|
2539
2541
|
'id', 'name', 'type', 'href', 'placeholder', 'title', 'role', 'aria-label',
|
|
2540
|
-
'aria-controls', 'aria-expanded', 'aria-haspopup', 'aria-modal', 'data-
|
|
2542
|
+
'aria-controls', 'aria-expanded', 'aria-haspopup', 'aria-modal', 'data-ak',
|
|
2543
|
+
'data-ak-interact', 'data-ak-fill-input', 'data-ak-fill-trigger', 'data-testid',
|
|
2541
2544
|
'data-test', 'data-qa', 'data-cy', 'alt',
|
|
2542
2545
|
];
|
|
2543
2546
|
for (const attr of copyAttrs) {
|
|
@@ -2983,7 +2986,7 @@ export class Browser {
|
|
|
2983
2986
|
const id = node.getAttribute('id');
|
|
2984
2987
|
if (id)
|
|
2985
2988
|
return `#${CSS.escape(id)}`;
|
|
2986
|
-
for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
2989
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
2987
2990
|
const value = node.getAttribute(attr);
|
|
2988
2991
|
if (value)
|
|
2989
2992
|
return `[${attr}="${quoteForSelector(value)}"]`;
|
|
@@ -4588,7 +4591,7 @@ export class Browser {
|
|
|
4588
4591
|
if (id) {
|
|
4589
4592
|
push(`#${CSS.escape(id)}`);
|
|
4590
4593
|
}
|
|
4591
|
-
for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
4594
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
4592
4595
|
const value = node.getAttribute(attr);
|
|
4593
4596
|
if (!value)
|
|
4594
4597
|
continue;
|
package/dist/cli-contract.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export declare function buildCliRunCommand(presetId: string, options?: {
|
|
|
17
17
|
local?: boolean;
|
|
18
18
|
env?: string;
|
|
19
19
|
dry?: boolean;
|
|
20
|
+
regenerateTts?: boolean;
|
|
20
21
|
}): string;
|
|
21
22
|
export declare function buildCliInstalledSetupCommand(cliKey: string): string;
|
|
22
23
|
export declare const CLI_PUBLIC_COMMANDS: CliPublicCommandDescriptor[];
|
package/dist/cli-contract.js
CHANGED
|
@@ -12,6 +12,7 @@ export function buildCliRunCommand(presetId, options = {}) {
|
|
|
12
12
|
options.local ? " --local" : "",
|
|
13
13
|
options.env ? ` --env ${options.env}` : "",
|
|
14
14
|
options.dry ? " --dry" : "",
|
|
15
|
+
options.regenerateTts ? " --regenerate-tts" : "",
|
|
15
16
|
].join("");
|
|
16
17
|
return `autokap run${flags} ${presetId}`;
|
|
17
18
|
}
|
package/dist/cli-runner-local.js
CHANGED
|
@@ -31,6 +31,7 @@ export async function runLocal(presetId, opts) {
|
|
|
31
31
|
program,
|
|
32
32
|
allowUploadFailure: opts.allowUploadFailure,
|
|
33
33
|
dryRun: opts.dry,
|
|
34
|
+
regenerateTts: opts.regenerateTts,
|
|
34
35
|
headed: opts.headed,
|
|
35
36
|
onProgress: (event) => {
|
|
36
37
|
const prefix = `[capture][${event.variantId}]`;
|
package/dist/cli-runner.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface CLIRunnerOptions {
|
|
|
31
31
|
allowUploadFailure?: boolean;
|
|
32
32
|
/** Dry run: skip capture opcodes (CAPTURE_SCREENSHOT/BEGIN_CLIP/END_CLIP) and upload. 0 credits charged. */
|
|
33
33
|
dryRun?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Force fresh TTS synthesis for every narration segment instead of reusing
|
|
36
|
+
* cached audio. Only meaningful when `mediaMode === 'video'`. Every reused
|
|
37
|
+
* segment becomes billable at full rate.
|
|
38
|
+
*/
|
|
39
|
+
regenerateTts?: boolean;
|
|
34
40
|
/** Selector memory map (fetched from server or cached locally) */
|
|
35
41
|
selectorMemory?: Record<string, string[]>;
|
|
36
42
|
/** Show browser window. Default: false (headless) */
|
package/dist/cli-runner.js
CHANGED
|
@@ -176,7 +176,7 @@ export async function runCapture(options) {
|
|
|
176
176
|
return { success: false, runId, error: error instanceof Error ? error.message : String(error) };
|
|
177
177
|
}
|
|
178
178
|
if (!options.program && program.mediaMode === 'video') {
|
|
179
|
-
const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId);
|
|
179
|
+
const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId, options.regenerateTts ?? false);
|
|
180
180
|
if (!prepareResult.success) {
|
|
181
181
|
return { success: false, runId, error: prepareResult.error };
|
|
182
182
|
}
|
|
@@ -396,7 +396,10 @@ async function fetchProgram(config, presetId, environmentName) {
|
|
|
396
396
|
}
|
|
397
397
|
return { success: false, error: 'failed to fetch program: retry attempts exhausted' };
|
|
398
398
|
}
|
|
399
|
-
async function prepareVideoSpeechForRun(config, videoId, runId) {
|
|
399
|
+
async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts) {
|
|
400
|
+
if (regenerateTts) {
|
|
401
|
+
logger.info('[capture] Forcing TTS regeneration — all cached segments will be re-synthesized and billed.');
|
|
402
|
+
}
|
|
400
403
|
logger.info('[capture] Generating speech, may take a few seconds...');
|
|
401
404
|
const url = `${config.apiBaseUrl}/api/cli/video-prepare`;
|
|
402
405
|
let response;
|
|
@@ -408,7 +411,7 @@ async function prepareVideoSpeechForRun(config, videoId, runId) {
|
|
|
408
411
|
'Content-Type': 'application/json',
|
|
409
412
|
[CLI_VERSION_HEADER]: APP_VERSION,
|
|
410
413
|
},
|
|
411
|
-
body: JSON.stringify({ videoId, runId }),
|
|
414
|
+
body: JSON.stringify(regenerateTts ? { videoId, runId, regenerateTts: true } : { videoId, runId }),
|
|
412
415
|
});
|
|
413
416
|
}
|
|
414
417
|
catch (err) {
|
package/dist/cli.js
CHANGED
|
@@ -251,6 +251,7 @@ program
|
|
|
251
251
|
.option('--output <dir>', 'Optional output directory for local artifact copies')
|
|
252
252
|
.option('--program <file>', 'Path to a program JSON file')
|
|
253
253
|
.option('--dry', 'Dry run: execute all opcodes without capturing or uploading artifacts (0 credits charged)', false)
|
|
254
|
+
.option('--regenerate-tts', 'Force fresh TTS synthesis for video presets — ignore cached audio segments. Reused segments become billable at full rate.', false)
|
|
254
255
|
.option('--debug', 'Verbose logging: per-substep timing, opcode dumps, recovery strategy traces', false)
|
|
255
256
|
.action(async (presetId, opts) => {
|
|
256
257
|
if (opts.debug) {
|
|
@@ -4201,6 +4201,51 @@ export declare function safeParseProgramResult(data: unknown): z.ZodSafeParseRes
|
|
|
4201
4201
|
}[] | undefined;
|
|
4202
4202
|
};
|
|
4203
4203
|
steps: ({
|
|
4204
|
+
urlPattern: string;
|
|
4205
|
+
description: string;
|
|
4206
|
+
postcondition: {
|
|
4207
|
+
type: "route_matches" | "element_visible" | "element_absent" | "text_contains" | "overlay_dismissed" | "screenshot_stable" | "any_change" | "always";
|
|
4208
|
+
pattern?: string | undefined;
|
|
4209
|
+
selector?: string | undefined;
|
|
4210
|
+
text?: string | undefined;
|
|
4211
|
+
threshold?: number | undefined;
|
|
4212
|
+
waitMs?: number | undefined;
|
|
4213
|
+
};
|
|
4214
|
+
recovery: {
|
|
4215
|
+
retries: number;
|
|
4216
|
+
useSelectorMemory: boolean;
|
|
4217
|
+
useAltInteraction: boolean;
|
|
4218
|
+
allowReload: boolean;
|
|
4219
|
+
allowHealer: boolean;
|
|
4220
|
+
};
|
|
4221
|
+
timeoutMs: number;
|
|
4222
|
+
maxFailures: number;
|
|
4223
|
+
kind: "ASSERT_ROUTE";
|
|
4224
|
+
stepId?: string | undefined;
|
|
4225
|
+
} | {
|
|
4226
|
+
selectors: string[];
|
|
4227
|
+
matchAll: boolean;
|
|
4228
|
+
description: string;
|
|
4229
|
+
postcondition: {
|
|
4230
|
+
type: "route_matches" | "element_visible" | "element_absent" | "text_contains" | "overlay_dismissed" | "screenshot_stable" | "any_change" | "always";
|
|
4231
|
+
pattern?: string | undefined;
|
|
4232
|
+
selector?: string | undefined;
|
|
4233
|
+
text?: string | undefined;
|
|
4234
|
+
threshold?: number | undefined;
|
|
4235
|
+
waitMs?: number | undefined;
|
|
4236
|
+
};
|
|
4237
|
+
recovery: {
|
|
4238
|
+
retries: number;
|
|
4239
|
+
useSelectorMemory: boolean;
|
|
4240
|
+
useAltInteraction: boolean;
|
|
4241
|
+
allowReload: boolean;
|
|
4242
|
+
allowHealer: boolean;
|
|
4243
|
+
};
|
|
4244
|
+
timeoutMs: number;
|
|
4245
|
+
maxFailures: number;
|
|
4246
|
+
kind: "ASSERT_SURFACE";
|
|
4247
|
+
stepId?: string | undefined;
|
|
4248
|
+
} | {
|
|
4204
4249
|
state: "visible" | "attached";
|
|
4205
4250
|
description: string;
|
|
4206
4251
|
postcondition: {
|
|
@@ -4470,51 +4515,6 @@ export declare function safeParseProgramResult(data: unknown): z.ZodSafeParseRes
|
|
|
4470
4515
|
maxFailures: number;
|
|
4471
4516
|
kind: "DISMISS_OVERLAYS";
|
|
4472
4517
|
stepId?: string | undefined;
|
|
4473
|
-
} | {
|
|
4474
|
-
urlPattern: string;
|
|
4475
|
-
description: string;
|
|
4476
|
-
postcondition: {
|
|
4477
|
-
type: "route_matches" | "element_visible" | "element_absent" | "text_contains" | "overlay_dismissed" | "screenshot_stable" | "any_change" | "always";
|
|
4478
|
-
pattern?: string | undefined;
|
|
4479
|
-
selector?: string | undefined;
|
|
4480
|
-
text?: string | undefined;
|
|
4481
|
-
threshold?: number | undefined;
|
|
4482
|
-
waitMs?: number | undefined;
|
|
4483
|
-
};
|
|
4484
|
-
recovery: {
|
|
4485
|
-
retries: number;
|
|
4486
|
-
useSelectorMemory: boolean;
|
|
4487
|
-
useAltInteraction: boolean;
|
|
4488
|
-
allowReload: boolean;
|
|
4489
|
-
allowHealer: boolean;
|
|
4490
|
-
};
|
|
4491
|
-
timeoutMs: number;
|
|
4492
|
-
maxFailures: number;
|
|
4493
|
-
kind: "ASSERT_ROUTE";
|
|
4494
|
-
stepId?: string | undefined;
|
|
4495
|
-
} | {
|
|
4496
|
-
selectors: string[];
|
|
4497
|
-
matchAll: boolean;
|
|
4498
|
-
description: string;
|
|
4499
|
-
postcondition: {
|
|
4500
|
-
type: "route_matches" | "element_visible" | "element_absent" | "text_contains" | "overlay_dismissed" | "screenshot_stable" | "any_change" | "always";
|
|
4501
|
-
pattern?: string | undefined;
|
|
4502
|
-
selector?: string | undefined;
|
|
4503
|
-
text?: string | undefined;
|
|
4504
|
-
threshold?: number | undefined;
|
|
4505
|
-
waitMs?: number | undefined;
|
|
4506
|
-
};
|
|
4507
|
-
recovery: {
|
|
4508
|
-
retries: number;
|
|
4509
|
-
useSelectorMemory: boolean;
|
|
4510
|
-
useAltInteraction: boolean;
|
|
4511
|
-
allowReload: boolean;
|
|
4512
|
-
allowHealer: boolean;
|
|
4513
|
-
};
|
|
4514
|
-
timeoutMs: number;
|
|
4515
|
-
maxFailures: number;
|
|
4516
|
-
kind: "ASSERT_SURFACE";
|
|
4517
|
-
stepId?: string | undefined;
|
|
4518
4518
|
} | {
|
|
4519
4519
|
selector: string;
|
|
4520
4520
|
description: string;
|
package/dist/execution-schema.js
CHANGED
|
@@ -102,13 +102,35 @@ const AssertRouteOpcodeSchema = z.object({
|
|
|
102
102
|
kind: z.literal('ASSERT_ROUTE'),
|
|
103
103
|
...opcodeBase,
|
|
104
104
|
urlPattern: z.string().min(1),
|
|
105
|
-
}).strict()
|
|
105
|
+
}).strict().superRefine((value, ctx) => {
|
|
106
|
+
const post = value.postcondition;
|
|
107
|
+
if (post.type === 'always')
|
|
108
|
+
return;
|
|
109
|
+
if (post.type === 'route_matches' && post.pattern === value.urlPattern)
|
|
110
|
+
return;
|
|
111
|
+
ctx.addIssue({
|
|
112
|
+
code: z.ZodIssueCode.custom,
|
|
113
|
+
message: 'ASSERT_ROUTE uses `urlPattern` as its source of truth; postcondition must be `always` or `route_matches` with the same pattern',
|
|
114
|
+
path: ['postcondition'],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
106
117
|
const AssertSurfaceOpcodeSchema = z.object({
|
|
107
118
|
kind: z.literal('ASSERT_SURFACE'),
|
|
108
119
|
...opcodeBase,
|
|
109
120
|
selectors: z.array(z.string().min(1)).min(1),
|
|
110
121
|
matchAll: z.boolean(),
|
|
111
|
-
}).strict()
|
|
122
|
+
}).strict().superRefine((value, ctx) => {
|
|
123
|
+
const post = value.postcondition;
|
|
124
|
+
if (post.type === 'always')
|
|
125
|
+
return;
|
|
126
|
+
if (post.type === 'element_visible' && post.selector && value.selectors.includes(post.selector))
|
|
127
|
+
return;
|
|
128
|
+
ctx.addIssue({
|
|
129
|
+
code: z.ZodIssueCode.custom,
|
|
130
|
+
message: 'ASSERT_SURFACE uses `selectors`/`matchAll` as its source of truth; postcondition must be `always` or `element_visible` for one of the asserted selectors',
|
|
131
|
+
path: ['postcondition'],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
112
134
|
const SemanticTargetSchema = z.object({
|
|
113
135
|
text: z.string().optional(),
|
|
114
136
|
role: z.string().optional(),
|
package/dist/opcode-runner.js
CHANGED
|
@@ -65,6 +65,39 @@ function resolveRecordingCaptureResolution(artifactPlan) {
|
|
|
65
65
|
}
|
|
66
66
|
return artifactPlan.format?.captureResolution;
|
|
67
67
|
}
|
|
68
|
+
const ACTION_EFFECT_OPCODE_KINDS = new Set([
|
|
69
|
+
'CLICK',
|
|
70
|
+
'TYPE',
|
|
71
|
+
'PRESS_KEY',
|
|
72
|
+
'SCROLL',
|
|
73
|
+
'HOVER',
|
|
74
|
+
'SELECT_OPTION',
|
|
75
|
+
'CHECK',
|
|
76
|
+
'DOUBLE_CLICK',
|
|
77
|
+
'DRAG',
|
|
78
|
+
]);
|
|
79
|
+
function getOpcodeActionEffectPolicy(opcode) {
|
|
80
|
+
const captureBefore = ACTION_EFFECT_OPCODE_KINDS.has(opcode.kind);
|
|
81
|
+
if (!captureBefore) {
|
|
82
|
+
return { captureBefore: false, requireEffect: false, noEffectMode: 'allow' };
|
|
83
|
+
}
|
|
84
|
+
if (opcode.postcondition.type === 'any_change') {
|
|
85
|
+
return { captureBefore: true, requireEffect: true, noEffectMode: 'fail' };
|
|
86
|
+
}
|
|
87
|
+
if (opcode.postcondition.type === 'always') {
|
|
88
|
+
return { captureBefore: true, requireEffect: false, noEffectMode: 'allow' };
|
|
89
|
+
}
|
|
90
|
+
if (opcode.kind === 'PRESS_KEY') {
|
|
91
|
+
return { captureBefore: true, requireEffect: false, noEffectMode: 'allow' };
|
|
92
|
+
}
|
|
93
|
+
return { captureBefore: true, requireEffect: true, noEffectMode: 'fail' };
|
|
94
|
+
}
|
|
95
|
+
function resolveRuntimePostcondition(opcode) {
|
|
96
|
+
if (opcode.kind === 'ASSERT_ROUTE' || opcode.kind === 'ASSERT_SURFACE') {
|
|
97
|
+
return { type: 'always' };
|
|
98
|
+
}
|
|
99
|
+
return opcode.postcondition;
|
|
100
|
+
}
|
|
68
101
|
// ── Main execution function ─────────────────────────────────────────
|
|
69
102
|
export async function executeProgram(program, createAdapter, options = {}) {
|
|
70
103
|
const recoveryChain = options.recoveryChain ?? new NoOpRecoveryChain();
|
|
@@ -254,7 +287,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
254
287
|
const startTime = Date.now();
|
|
255
288
|
const effectiveTimeoutMs = resolveOpcodeTimeoutMs(opcode);
|
|
256
289
|
const deadlineMs = startTime + effectiveTimeoutMs;
|
|
257
|
-
const
|
|
290
|
+
const actionEffectPolicy = getOpcodeActionEffectPolicy(opcode);
|
|
258
291
|
const isSoft = isSoftOpcodeKind(opcode.kind);
|
|
259
292
|
// Track page context for circuit breaker
|
|
260
293
|
try {
|
|
@@ -265,7 +298,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
265
298
|
// Execute with timeout
|
|
266
299
|
try {
|
|
267
300
|
logger.debug(`[opcode ${index}] ${opcode.kind} start — budget ${effectiveTimeoutMs}ms${formatOpcodeDebug(opcode)}`);
|
|
268
|
-
if (
|
|
301
|
+
if (actionEffectPolicy.captureBefore) {
|
|
269
302
|
const beforeStart = Date.now();
|
|
270
303
|
await verifier.captureBeforeState(adapter);
|
|
271
304
|
logger.debug(`[opcode ${index}] captureBeforeState took ${Date.now() - beforeStart}ms`);
|
|
@@ -276,7 +309,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
276
309
|
logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${deadlineMs}, now=${Date.now()})`);
|
|
277
310
|
if (isSoft)
|
|
278
311
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
279
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
312
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
280
313
|
}
|
|
281
314
|
// For mediaMode='video', capture pre-action timing + bbox metadata inside
|
|
282
315
|
// the active clip window only. Opcodes outside a clip are not part of the
|
|
@@ -310,7 +343,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
310
343
|
const reason = result.error ?? 'action failed';
|
|
311
344
|
if (isSoft)
|
|
312
345
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
313
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
346
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
314
347
|
}
|
|
315
348
|
// Verify postcondition
|
|
316
349
|
const postconditionBudgetMs = getRemainingTimeMs(deadlineMs);
|
|
@@ -319,39 +352,30 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
319
352
|
logger.debug(`[opcode ${index}] no budget left for postcondition check`);
|
|
320
353
|
if (isSoft)
|
|
321
354
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
322
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
355
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
323
356
|
}
|
|
357
|
+
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
324
358
|
const postStart = Date.now();
|
|
325
|
-
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(
|
|
326
|
-
logger.debug(`[opcode ${index}] postcondition (${
|
|
359
|
+
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(runtimePostcondition, postconditionBudgetMs));
|
|
360
|
+
logger.debug(`[opcode ${index}] postcondition (${runtimePostcondition.type}) took ${Date.now() - postStart}ms — passed=${postcondition.passed}, reason="${postcondition.reason}"`);
|
|
327
361
|
if (!postcondition.passed) {
|
|
328
362
|
const reason = `postcondition failed: ${postcondition.reason}`;
|
|
329
363
|
if (isSoft)
|
|
330
364
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
331
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
365
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
332
366
|
}
|
|
333
|
-
// Verify action
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
// change after the action means the action didn't really happen.
|
|
338
|
-
//
|
|
339
|
-
// PRESS_KEY is the exception. Keys are CONDITIONAL by nature: Escape
|
|
340
|
-
// is a no-op when no modal is open; Enter is a no-op when no form is
|
|
341
|
-
// focused; Tab is a no-op when no focusable target exists. A preset
|
|
342
|
-
// may legitimately script PRESS_KEY against a page that has already
|
|
343
|
-
// reached the target state via earlier opcodes (e.g. SLEEP let an
|
|
344
|
-
// auto-close timer run). The postcondition has already passed by this
|
|
345
|
-
// point — trust it for PRESS_KEY and skip the no-effect penalty.
|
|
346
|
-
if (isInteraction) {
|
|
367
|
+
// Verify action effects through the shared policy. Weak `any_change`
|
|
368
|
+
// postconditions are only meaningful if this verifier observes a real
|
|
369
|
+
// URL/tree/state/scroll change.
|
|
370
|
+
if (actionEffectPolicy.captureBefore) {
|
|
347
371
|
const verification = await verifier.verifyAfterAction(adapter);
|
|
348
|
-
if (!verification.hadEffect &&
|
|
349
|
-
if (opcode.kind === 'PRESS_KEY') {
|
|
372
|
+
if (!verification.hadEffect && actionEffectPolicy.requireEffect) {
|
|
373
|
+
if (opcode.kind === 'PRESS_KEY' && actionEffectPolicy.noEffectMode === 'allow') {
|
|
350
374
|
logger.debug(`[opcode ${index}] PRESS_KEY had no DOM effect (${verification.summary}) — ` +
|
|
351
375
|
`postcondition passed, treating as redundant-but-successful`);
|
|
352
376
|
}
|
|
353
377
|
else {
|
|
354
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
378
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, `action had no effect: ${verification.summary}`);
|
|
355
379
|
}
|
|
356
380
|
}
|
|
357
381
|
}
|
|
@@ -385,7 +409,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
385
409
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
386
410
|
if (isSoft)
|
|
387
411
|
return softSkipResult(opcode, index, startTime, errorMsg, telemetry);
|
|
388
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
412
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg);
|
|
389
413
|
}
|
|
390
414
|
}
|
|
391
415
|
/** Post-action breathing room (ms) injected between visible interactions
|
|
@@ -411,7 +435,8 @@ function sleep(ms) {
|
|
|
411
435
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
412
436
|
}
|
|
413
437
|
// ── Failure handling with recovery ──────────────────────────────────
|
|
414
|
-
async function handleFailure(opcode, index, adapter, verifier,
|
|
438
|
+
async function handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg) {
|
|
439
|
+
const actionEffectPolicy = getOpcodeActionEffectPolicy(opcode);
|
|
415
440
|
const breakerState = breaker.recordFailure(index, opcode.maxFailures);
|
|
416
441
|
if (breakerState.tripped) {
|
|
417
442
|
telemetry.circuitBreakerTrips++;
|
|
@@ -443,6 +468,11 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
|
|
|
443
468
|
opcodeKind: opcode.kind,
|
|
444
469
|
message: `recovering from: ${errorMsg}`,
|
|
445
470
|
});
|
|
471
|
+
if (actionEffectPolicy.captureBefore) {
|
|
472
|
+
const beforeRecoveryStart = Date.now();
|
|
473
|
+
await verifier.captureBeforeState(adapter);
|
|
474
|
+
logger.debug(`[opcode ${index}] recovery captureBeforeState took ${Date.now() - beforeRecoveryStart}ms`);
|
|
475
|
+
}
|
|
446
476
|
const recovery = await recoveryChain.attempt(opcode, index, adapter, {
|
|
447
477
|
remainingTimeMs,
|
|
448
478
|
maxDeterministicRetries: Math.max(0, opcode.maxFailures - breakerState.opcodeFailures),
|
|
@@ -466,9 +496,32 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
|
|
|
466
496
|
if (recovery.patch) {
|
|
467
497
|
healerPatches.push(recovery.patch);
|
|
468
498
|
}
|
|
469
|
-
|
|
499
|
+
const postconditionBudgetMs = getRemainingTimeMs(deadlineMs);
|
|
500
|
+
if (postconditionBudgetMs <= 0) {
|
|
501
|
+
return {
|
|
502
|
+
opcodeIndex: index,
|
|
503
|
+
kind: opcode.kind,
|
|
504
|
+
status: 'failed',
|
|
505
|
+
durationMs: Date.now() - startTime,
|
|
506
|
+
recoveryAttempts: 1,
|
|
507
|
+
error: `${errorMsg} (recovery succeeded but postcondition could not be rechecked before timeout after ${effectiveTimeoutMs}ms)`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
511
|
+
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(runtimePostcondition, postconditionBudgetMs));
|
|
512
|
+
if (!postcondition.passed) {
|
|
513
|
+
return {
|
|
514
|
+
opcodeIndex: index,
|
|
515
|
+
kind: opcode.kind,
|
|
516
|
+
status: 'failed',
|
|
517
|
+
durationMs: Date.now() - startTime,
|
|
518
|
+
recoveryAttempts: 1,
|
|
519
|
+
error: `${errorMsg} (recovery succeeded but postcondition still failed: ${postcondition.reason})`,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (actionEffectPolicy.captureBefore) {
|
|
470
523
|
const verification = await verifier.verifyAfterAction(adapter);
|
|
471
|
-
if (!verification.hadEffect &&
|
|
524
|
+
if (!verification.hadEffect && actionEffectPolicy.requireEffect) {
|
|
472
525
|
return {
|
|
473
526
|
opcodeIndex: index,
|
|
474
527
|
kind: opcode.kind,
|
|
@@ -533,15 +586,23 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
|
|
|
533
586
|
suppressPageReloads: Boolean(executionState.activeClip),
|
|
534
587
|
});
|
|
535
588
|
case 'ASSERT_ROUTE':
|
|
589
|
+
assertRoutePostconditionSource(opcode);
|
|
536
590
|
return evaluateImmediateAssertion(await evaluatePostcondition(adapter, {
|
|
537
591
|
type: 'route_matches',
|
|
538
592
|
pattern: opcode.urlPattern,
|
|
539
593
|
waitMs: 1,
|
|
540
594
|
}), 'ASSERT_ROUTE failed');
|
|
541
595
|
case 'ASSERT_SURFACE':
|
|
596
|
+
assertSurfacePostconditionSource(opcode);
|
|
542
597
|
return evaluateSurfaceAssertion(adapter, opcode.selectors, opcode.matchAll);
|
|
543
598
|
case 'CAPTURE_SCREENSHOT': {
|
|
544
|
-
await smartWaitForStability(adapter, { maxWaitMs: 5000 });
|
|
599
|
+
const stability = await smartWaitForStability(adapter, { maxWaitMs: 5000 });
|
|
600
|
+
if (!stability.stable) {
|
|
601
|
+
return {
|
|
602
|
+
success: false,
|
|
603
|
+
error: `page not stable before screenshot; unresolved loaders: ${stability.waitedFor.join(', ') || 'unknown'}`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
545
606
|
const captureUrl = await adapter.getCurrentUrl();
|
|
546
607
|
const takeBuffer = async () => {
|
|
547
608
|
if (opcode.elementSelector && adapter.takeElementScreenshot) {
|
|
@@ -760,13 +821,37 @@ function getZoomTargetSelector(opcode) {
|
|
|
760
821
|
}
|
|
761
822
|
}
|
|
762
823
|
async function withTimeout(fn, timeoutMs) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
824
|
+
const operation = Promise.resolve().then(fn);
|
|
825
|
+
let timedOut = false;
|
|
826
|
+
let timer = null;
|
|
827
|
+
const timeout = new Promise((_, reject) => {
|
|
828
|
+
timer = setTimeout(() => {
|
|
829
|
+
timedOut = true;
|
|
830
|
+
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
831
|
+
}, timeoutMs);
|
|
768
832
|
});
|
|
833
|
+
try {
|
|
834
|
+
return await Promise.race([operation, timeout]);
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
if (!timedOut)
|
|
838
|
+
throw err;
|
|
839
|
+
// Playwright actions cannot be cancelled through the current adapter API.
|
|
840
|
+
// Before recovery starts, give the in-flight action a short chance to
|
|
841
|
+
// settle so it does not mutate the page concurrently with recovery or the
|
|
842
|
+
// next opcode. This is deadline-safe without changing adapter contracts.
|
|
843
|
+
await Promise.race([
|
|
844
|
+
operation.catch(() => undefined),
|
|
845
|
+
sleep(TIMED_OUT_ACTION_DRAIN_MS),
|
|
846
|
+
]);
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
if (timer)
|
|
851
|
+
clearTimeout(timer);
|
|
852
|
+
}
|
|
769
853
|
}
|
|
854
|
+
const TIMED_OUT_ACTION_DRAIN_MS = 2000;
|
|
770
855
|
function getRemainingTimeMs(deadlineMs) {
|
|
771
856
|
return Math.max(0, deadlineMs - Date.now());
|
|
772
857
|
}
|
|
@@ -790,6 +875,22 @@ function evaluateImmediateAssertion(result, prefix) {
|
|
|
790
875
|
? { success: true }
|
|
791
876
|
: { success: false, error: `${prefix}: ${result.reason}` };
|
|
792
877
|
}
|
|
878
|
+
function assertRoutePostconditionSource(opcode) {
|
|
879
|
+
const post = opcode.postcondition;
|
|
880
|
+
if (post.type === 'always')
|
|
881
|
+
return;
|
|
882
|
+
if (post.type === 'route_matches' && post.pattern === opcode.urlPattern)
|
|
883
|
+
return;
|
|
884
|
+
throw new Error(`ASSERT_ROUTE uses urlPattern as the source of truth; postcondition must be always or route_matches with the same pattern "${opcode.urlPattern}"`);
|
|
885
|
+
}
|
|
886
|
+
function assertSurfacePostconditionSource(opcode) {
|
|
887
|
+
const post = opcode.postcondition;
|
|
888
|
+
if (post.type === 'always')
|
|
889
|
+
return;
|
|
890
|
+
if (post.type === 'element_visible' && post.selector && opcode.selectors.includes(post.selector))
|
|
891
|
+
return;
|
|
892
|
+
throw new Error('ASSERT_SURFACE uses selectors/matchAll as the source of truth; postcondition must be always or element_visible for one of the asserted selectors');
|
|
893
|
+
}
|
|
793
894
|
async function evaluateSurfaceAssertion(adapter, selectors, matchAll) {
|
|
794
895
|
const checks = await Promise.all(selectors.map(async (selector) => ({
|
|
795
896
|
selector,
|
package/dist/overlay-engine.js
CHANGED
|
@@ -149,10 +149,16 @@ function findNodeById(node, id) {
|
|
|
149
149
|
return null;
|
|
150
150
|
}
|
|
151
151
|
function buildSelectorFromSourceRef(sourceRef) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
const trimmed = sourceRef.trim();
|
|
153
|
+
if (isSelectorLikeSourceRef(trimmed)) {
|
|
154
|
+
return trimmed;
|
|
155
|
+
}
|
|
156
|
+
// Extract AutoKap/test automation attributes
|
|
157
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
158
|
+
const attrMatch = sourceRef.match(new RegExp(`${attr}="([^"]+)"`));
|
|
159
|
+
if (attrMatch)
|
|
160
|
+
return `[${attr}="${attrMatch[1].replace(/"/g, '\\"')}"]`;
|
|
161
|
+
}
|
|
156
162
|
// Extract id
|
|
157
163
|
const idMatch = sourceRef.match(/ id="([^"]+)"/);
|
|
158
164
|
if (idMatch)
|
|
@@ -170,6 +176,14 @@ function buildSelectorFromSourceRef(sourceRef) {
|
|
|
170
176
|
}
|
|
171
177
|
return null;
|
|
172
178
|
}
|
|
179
|
+
function isSelectorLikeSourceRef(sourceRef) {
|
|
180
|
+
if (!sourceRef || sourceRef.startsWith('<'))
|
|
181
|
+
return false;
|
|
182
|
+
return sourceRef.startsWith('#')
|
|
183
|
+
|| sourceRef.startsWith('.')
|
|
184
|
+
|| sourceRef.startsWith('[')
|
|
185
|
+
|| /^[a-z][a-z0-9-]*(?:[#.[\s>:]|$)/i.test(sourceRef);
|
|
186
|
+
}
|
|
173
187
|
function sleep(ms) {
|
|
174
188
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
175
189
|
}
|
package/dist/postcondition.js
CHANGED
|
@@ -14,12 +14,13 @@ export async function evaluatePostcondition(adapter, spec) {
|
|
|
14
14
|
const maxWait = spec.waitMs ?? 5000;
|
|
15
15
|
const pollInterval = 500;
|
|
16
16
|
const deadline = Date.now() + maxWait;
|
|
17
|
+
const context = {};
|
|
17
18
|
// 'always' postcondition always passes immediately
|
|
18
19
|
if (spec.type === 'always') {
|
|
19
20
|
return { passed: true, reason: 'always passes' };
|
|
20
21
|
}
|
|
21
22
|
while (Date.now() < deadline) {
|
|
22
|
-
const result = await checkOnce(adapter, spec);
|
|
23
|
+
const result = await checkOnce(adapter, spec, context);
|
|
23
24
|
if (result.passed)
|
|
24
25
|
return result;
|
|
25
26
|
const remaining = deadline - Date.now();
|
|
@@ -28,9 +29,9 @@ export async function evaluatePostcondition(adapter, spec) {
|
|
|
28
29
|
await sleep(Math.min(pollInterval, remaining));
|
|
29
30
|
}
|
|
30
31
|
// Final check after timeout
|
|
31
|
-
return checkOnce(adapter, spec);
|
|
32
|
+
return checkOnce(adapter, spec, context);
|
|
32
33
|
}
|
|
33
|
-
async function checkOnce(adapter, spec) {
|
|
34
|
+
async function checkOnce(adapter, spec, context) {
|
|
34
35
|
switch (spec.type) {
|
|
35
36
|
case 'route_matches':
|
|
36
37
|
return checkRouteMatches(adapter, spec.pattern);
|
|
@@ -43,11 +44,11 @@ async function checkOnce(adapter, spec) {
|
|
|
43
44
|
case 'overlay_dismissed':
|
|
44
45
|
return checkOverlayDismissed(adapter);
|
|
45
46
|
case 'screenshot_stable':
|
|
46
|
-
return checkScreenshotStable(adapter, spec.threshold ?? 0.01);
|
|
47
|
+
return checkScreenshotStable(adapter, spec.threshold ?? 0.01, context);
|
|
47
48
|
case 'any_change':
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
return { passed: true, reason: 'any_change
|
|
49
|
+
// The runner makes this verifier-backed by requiring a detected action
|
|
50
|
+
// effect after this structural postcondition succeeds.
|
|
51
|
+
return { passed: true, reason: 'any_change deferred to action verifier' };
|
|
51
52
|
case 'always':
|
|
52
53
|
return { passed: true, reason: 'always passes' };
|
|
53
54
|
default:
|
|
@@ -152,8 +153,13 @@ async function checkTextContains(adapter, selector, expectedText) {
|
|
|
152
153
|
if (!node) {
|
|
153
154
|
return { passed: false, reason: `element "${selector}" not found for text check` };
|
|
154
155
|
}
|
|
155
|
-
const nodeText = (
|
|
156
|
-
|
|
156
|
+
const nodeText = normalizeText([
|
|
157
|
+
node.label || '',
|
|
158
|
+
node.value || '',
|
|
159
|
+
node.attributes.__ownText || '',
|
|
160
|
+
].join(' '));
|
|
161
|
+
const expected = normalizeText(expectedText);
|
|
162
|
+
if (nodeText.includes(expected)) {
|
|
157
163
|
return { passed: true, reason: `element "${selector}" contains "${expectedText}"` };
|
|
158
164
|
}
|
|
159
165
|
return { passed: false, reason: `element "${selector}" text "${nodeText}" does not contain "${expectedText}"` };
|
|
@@ -182,33 +188,29 @@ async function checkOverlayDismissed(adapter) {
|
|
|
182
188
|
return { passed: true, reason: 'overlay check skipped (AKTree unavailable), assuming dismissed' };
|
|
183
189
|
}
|
|
184
190
|
}
|
|
185
|
-
|
|
186
|
-
async function checkScreenshotStable(adapter, threshold) {
|
|
191
|
+
async function checkScreenshotStable(adapter, threshold, context) {
|
|
187
192
|
try {
|
|
188
193
|
const screenshot = await adapter.takeScreenshot();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Wait and take another screenshot
|
|
193
|
-
await sleep(500);
|
|
194
|
+
if (!context.lastScreenshot) {
|
|
195
|
+
context.lastScreenshot = screenshot;
|
|
196
|
+
await sleep(250);
|
|
194
197
|
const screenshot2 = await adapter.takeScreenshot();
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
return { passed: true, reason:
|
|
198
|
+
const diff = sampledBufferDiffRatio(screenshot, screenshot2);
|
|
199
|
+
context.lastScreenshot = screenshot2;
|
|
200
|
+
if (diff <= threshold) {
|
|
201
|
+
return { passed: true, reason: `consecutive screenshots stable (diff=${diff.toFixed(4)})` };
|
|
199
202
|
}
|
|
200
|
-
return { passed: false, reason:
|
|
203
|
+
return { passed: false, reason: `consecutive screenshots differ (diff=${diff.toFixed(4)})` };
|
|
201
204
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return { passed: true, reason:
|
|
205
|
+
const diff = sampledBufferDiffRatio(context.lastScreenshot, screenshot);
|
|
206
|
+
context.lastScreenshot = screenshot;
|
|
207
|
+
if (diff <= threshold) {
|
|
208
|
+
return { passed: true, reason: `screenshot stable (diff=${diff.toFixed(4)})` };
|
|
206
209
|
}
|
|
207
|
-
|
|
208
|
-
return { passed: false, reason: 'screenshot changed from previous check' };
|
|
210
|
+
return { passed: false, reason: `screenshot changed from previous check (diff=${diff.toFixed(4)})` };
|
|
209
211
|
}
|
|
210
212
|
catch (err) {
|
|
211
|
-
|
|
213
|
+
context.lastScreenshot = undefined;
|
|
212
214
|
return { passed: false, reason: `error checking screenshot stability: ${err}` };
|
|
213
215
|
}
|
|
214
216
|
}
|
|
@@ -216,7 +218,7 @@ async function checkScreenshotStable(adapter, threshold) {
|
|
|
216
218
|
function hasVisibleNodeWithSelector(tree, selector) {
|
|
217
219
|
// Walk the AKTree looking for a visible node whose sourceRef matches the selector
|
|
218
220
|
function walk(node) {
|
|
219
|
-
if (node.visible &&
|
|
221
|
+
if (node.visible && matchesNodeSelectorHeuristic(node, selector)) {
|
|
220
222
|
return true;
|
|
221
223
|
}
|
|
222
224
|
return node.children.some(walk);
|
|
@@ -225,7 +227,7 @@ function hasVisibleNodeWithSelector(tree, selector) {
|
|
|
225
227
|
}
|
|
226
228
|
function findNodeBySelector(tree, selector) {
|
|
227
229
|
function walk(node) {
|
|
228
|
-
if (
|
|
230
|
+
if (matchesNodeSelectorHeuristic(node, selector)) {
|
|
229
231
|
return node;
|
|
230
232
|
}
|
|
231
233
|
for (const child of node.children) {
|
|
@@ -237,6 +239,20 @@ function findNodeBySelector(tree, selector) {
|
|
|
237
239
|
}
|
|
238
240
|
return walk(tree.root);
|
|
239
241
|
}
|
|
242
|
+
function matchesNodeSelectorHeuristic(node, selector) {
|
|
243
|
+
if (node.sourceRef && matchesSelectorHeuristic(node.sourceRef, selector)) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
247
|
+
if (attrMatch) {
|
|
248
|
+
const [, attr, rawValue] = attrMatch;
|
|
249
|
+
return (node.attributes[attr] ?? '').toLowerCase() === rawValue.toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
if (selector.startsWith('#')) {
|
|
252
|
+
return (node.attributes.id ?? '').toLowerCase() === selector.slice(1).toLowerCase();
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
240
256
|
/**
|
|
241
257
|
* Heuristic match between an AKTree sourceRef and a CSS-like selector.
|
|
242
258
|
* Not a full CSS selector engine — handles common patterns:
|
|
@@ -248,10 +264,18 @@ function findNodeBySelector(tree, selector) {
|
|
|
248
264
|
function matchesSelectorHeuristic(sourceRef, selector) {
|
|
249
265
|
const lower = sourceRef.toLowerCase();
|
|
250
266
|
const selectorLower = selector.toLowerCase();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
267
|
+
if (selectorLower === lower) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
// Generic attribute selector match, including AutoKap's canonical data-ak
|
|
271
|
+
// selectors and legacy test-id selectors.
|
|
272
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
273
|
+
if (attrMatch) {
|
|
274
|
+
const [, attr, rawValue] = attrMatch;
|
|
275
|
+
const value = rawValue.toLowerCase();
|
|
276
|
+
return lower === `[${attr.toLowerCase()}="${value}"]`
|
|
277
|
+
|| lower.includes(`[${attr.toLowerCase()}="${value}"]`)
|
|
278
|
+
|| lower.includes(`${attr.toLowerCase()}="${value}"`);
|
|
255
279
|
}
|
|
256
280
|
// ID match
|
|
257
281
|
if (selector.startsWith('#')) {
|
|
@@ -264,14 +288,23 @@ function matchesSelectorHeuristic(sourceRef, selector) {
|
|
|
264
288
|
// Fallback: contains
|
|
265
289
|
return lower.includes(selectorLower);
|
|
266
290
|
}
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
function sampledBufferDiffRatio(a, b) {
|
|
292
|
+
const maxLength = Math.max(a.length, b.length);
|
|
293
|
+
if (maxLength === 0)
|
|
294
|
+
return 0;
|
|
295
|
+
const sampleCount = Math.min(10000, maxLength);
|
|
296
|
+
const step = Math.max(1, Math.floor(maxLength / sampleCount));
|
|
297
|
+
let checked = 0;
|
|
298
|
+
let changed = 0;
|
|
299
|
+
for (let i = 0; i < maxLength; i += step) {
|
|
300
|
+
checked++;
|
|
301
|
+
if (a[i] !== b[i])
|
|
302
|
+
changed++;
|
|
273
303
|
}
|
|
274
|
-
return
|
|
304
|
+
return checked === 0 ? 0 : changed / checked;
|
|
305
|
+
}
|
|
306
|
+
function normalizeText(value) {
|
|
307
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
275
308
|
}
|
|
276
309
|
function sleep(ms) {
|
|
277
310
|
return new Promise(resolve => setTimeout(resolve, ms));
|
package/dist/recovery-chain.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveSelector } from './selector-resolver.js';
|
|
|
12
12
|
import { evaluatePostcondition } from './postcondition.js';
|
|
13
13
|
import { LLMHealer } from './llm-healer.js';
|
|
14
14
|
import { serializeAKTree } from './ak-tree.js';
|
|
15
|
-
import { executeOpcodeCoreAction
|
|
15
|
+
import { executeOpcodeCoreAction } from './opcode-actions.js';
|
|
16
16
|
import { dismissAllOverlays } from './overlay-engine.js';
|
|
17
17
|
import { logger } from './logger.js';
|
|
18
18
|
export class RecoveryChainImpl {
|
|
@@ -43,7 +43,7 @@ export class RecoveryChainImpl {
|
|
|
43
43
|
// Strategy 2: Selector memory + alternate selectors
|
|
44
44
|
if (recovery.useSelectorMemory && hasSelector(failedOpcode)) {
|
|
45
45
|
logger.debug(`[recovery ${opcodeIndex}] strategy 2 (selector memory)`);
|
|
46
|
-
const result = await trySelectorAlternatives(failedOpcode, opcodeIndex, adapter, this.selectorMemory, this.credentials);
|
|
46
|
+
const result = await trySelectorAlternatives(failedOpcode, opcodeIndex, adapter, this.selectorMemory, this.credentials, options.currentVariant);
|
|
47
47
|
logger.debug(`[recovery ${opcodeIndex}] strategy 2 → recovered=${result.recovered}, reason=${result.reason}`);
|
|
48
48
|
if (result.recovered)
|
|
49
49
|
return result;
|
|
@@ -109,7 +109,7 @@ async function retryOpcode(opcode, adapter, maxRetries, remainingTimeMs, current
|
|
|
109
109
|
return { recovered: false, reason: `${maxRetries} retries exhausted` };
|
|
110
110
|
}
|
|
111
111
|
// ── Strategy 2: Selector memory ─────────────────────────────────────
|
|
112
|
-
async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMemory, credentials) {
|
|
112
|
+
async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMemory, credentials, currentVariant) {
|
|
113
113
|
if (!hasSelector(opcode)) {
|
|
114
114
|
return { recovered: false, reason: 'opcode has no selector' };
|
|
115
115
|
}
|
|
@@ -129,7 +129,7 @@ async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMem
|
|
|
129
129
|
}
|
|
130
130
|
// Try the resolved selector
|
|
131
131
|
try {
|
|
132
|
-
await executeWithSelector(opcode, adapter, resolved.selector, credentials);
|
|
132
|
+
await executeWithSelector(opcode, adapter, resolved.selector, credentials, currentVariant);
|
|
133
133
|
const postcondition = await evaluatePostcondition(adapter, opcode.postcondition);
|
|
134
134
|
if (postcondition.passed) {
|
|
135
135
|
return {
|
|
@@ -312,40 +312,25 @@ async function executeHealerPatchedAction(opcode, adapter, interactionMode, curr
|
|
|
312
312
|
}
|
|
313
313
|
await executeRawAction(opcode, adapter, currentVariant, credentials, suppressPageReloads);
|
|
314
314
|
}
|
|
315
|
-
async function executeWithSelector(opcode, adapter, newSelector, credentials) {
|
|
315
|
+
async function executeWithSelector(opcode, adapter, newSelector, credentials, currentVariant) {
|
|
316
|
+
const opcodeWithSelector = cloneOpcodeWithSelector(opcode, newSelector);
|
|
317
|
+
const result = await executeOpcodeCoreAction(opcodeWithSelector, adapter, {
|
|
318
|
+
credentials,
|
|
319
|
+
currentVariant,
|
|
320
|
+
});
|
|
321
|
+
if (!result.success) {
|
|
322
|
+
throw new Error(result.error ?? `selector recovery failed for ${opcode.kind}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function cloneOpcodeWithSelector(opcode, newSelector) {
|
|
316
326
|
switch (opcode.kind) {
|
|
317
327
|
case 'CLICK':
|
|
318
|
-
|
|
319
|
-
break;
|
|
320
|
-
case 'TYPE': {
|
|
321
|
-
const text = substituteCredentialPlaceholders(opcode.text, credentials);
|
|
322
|
-
await adapter.type(newSelector, text, opcode.clearFirst);
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
328
|
+
case 'TYPE':
|
|
325
329
|
case 'HOVER':
|
|
326
|
-
if (!adapter.hover)
|
|
327
|
-
throw new Error('adapter does not support HOVER');
|
|
328
|
-
await adapter.hover(newSelector);
|
|
329
|
-
break;
|
|
330
330
|
case 'SELECT_OPTION':
|
|
331
|
-
if (!adapter.selectOption)
|
|
332
|
-
throw new Error('adapter does not support SELECT_OPTION');
|
|
333
|
-
await adapter.selectOption(newSelector, {
|
|
334
|
-
label: opcode.optionLabel,
|
|
335
|
-
value: opcode.optionValue,
|
|
336
|
-
index: opcode.optionIndex,
|
|
337
|
-
});
|
|
338
|
-
break;
|
|
339
331
|
case 'CHECK':
|
|
340
|
-
if (!adapter.check)
|
|
341
|
-
throw new Error('adapter does not support CHECK');
|
|
342
|
-
await adapter.check(newSelector, opcode.checked);
|
|
343
|
-
break;
|
|
344
332
|
case 'DOUBLE_CLICK':
|
|
345
|
-
|
|
346
|
-
throw new Error('adapter does not support DOUBLE_CLICK');
|
|
347
|
-
await adapter.doubleClick(newSelector);
|
|
348
|
-
break;
|
|
333
|
+
return { ...opcode, selector: newSelector };
|
|
349
334
|
default:
|
|
350
335
|
throw new Error(`cannot swap selector for opcode kind: ${opcode.kind}`);
|
|
351
336
|
}
|
|
@@ -142,8 +142,20 @@ function findNodeByFuzzyText(tree, description) {
|
|
|
142
142
|
return null;
|
|
143
143
|
}
|
|
144
144
|
function buildSelectorFromNode(node) {
|
|
145
|
+
if (isUsableSourceRefSelector(node.sourceRef)) {
|
|
146
|
+
return node.sourceRef;
|
|
147
|
+
}
|
|
145
148
|
// Try to build the most specific selector from node attributes
|
|
146
149
|
const ref = node.sourceRef;
|
|
150
|
+
// Extract data-ak / data-testid-style automation hooks
|
|
151
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
152
|
+
const attrMatch = ref.match(new RegExp(`${attr}="([^"]+)"`));
|
|
153
|
+
if (attrMatch)
|
|
154
|
+
return `[${attr}="${escapeText(attrMatch[1])}"]`;
|
|
155
|
+
const value = node.attributes[attr];
|
|
156
|
+
if (value)
|
|
157
|
+
return `[${attr}="${escapeText(value)}"]`;
|
|
158
|
+
}
|
|
147
159
|
// Extract data-testid
|
|
148
160
|
const testIdMatch = ref.match(/data-testid="([^"]+)"/);
|
|
149
161
|
if (testIdMatch)
|
|
@@ -159,11 +171,20 @@ function buildSelectorFromNode(node) {
|
|
|
159
171
|
if (role) {
|
|
160
172
|
return `${tag}[role="${role}"]`;
|
|
161
173
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return `[data-ak-id="${node.id}"]`;
|
|
174
|
+
if (node.label) {
|
|
175
|
+
return `${tag}:has-text("${escapeText(node.label.slice(0, 50))}")`;
|
|
165
176
|
}
|
|
166
|
-
return
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
function isUsableSourceRefSelector(sourceRef) {
|
|
180
|
+
const ref = (sourceRef ?? '').trim();
|
|
181
|
+
if (!ref || ref.startsWith('<'))
|
|
182
|
+
return false;
|
|
183
|
+
return ref === 'body'
|
|
184
|
+
|| ref.startsWith('#')
|
|
185
|
+
|| ref.startsWith('.')
|
|
186
|
+
|| ref.startsWith('[')
|
|
187
|
+
|| /^[a-z][a-z0-9-]*(?:[#.[\s>:]|$)/i.test(ref);
|
|
167
188
|
}
|
|
168
189
|
function tokenize(text) {
|
|
169
190
|
return text
|
package/dist/smart-wait.js
CHANGED
|
@@ -17,6 +17,7 @@ export async function smartWaitForStability(adapter, options) {
|
|
|
17
17
|
const maxWait = options?.maxWaitMs ?? 10000;
|
|
18
18
|
const startTime = Date.now();
|
|
19
19
|
const waitedFor = [];
|
|
20
|
+
const unresolvedLoaders = [];
|
|
20
21
|
// 1. Wait for no visible spinners/skeletons
|
|
21
22
|
const spinnerSelectors = [
|
|
22
23
|
'[class*="spinner"]',
|
|
@@ -57,6 +58,14 @@ export async function smartWaitForStability(adapter, options) {
|
|
|
57
58
|
await sleep(300);
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
const stillVisible = await adapter.waitFor({
|
|
62
|
+
selector,
|
|
63
|
+
state: 'visible',
|
|
64
|
+
timeoutMs: 100,
|
|
65
|
+
}).catch(() => false);
|
|
66
|
+
if (stillVisible) {
|
|
67
|
+
unresolvedLoaders.push(selector);
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
catch {
|
|
@@ -70,8 +79,8 @@ export async function smartWaitForStability(adapter, options) {
|
|
|
70
79
|
waitedFor.push('stability-pause');
|
|
71
80
|
}
|
|
72
81
|
return {
|
|
73
|
-
stable:
|
|
74
|
-
waitedFor,
|
|
82
|
+
stable: unresolvedLoaders.length === 0,
|
|
83
|
+
waitedFor: unresolvedLoaders.length > 0 ? unresolvedLoaders : waitedFor,
|
|
75
84
|
waitedMs: Date.now() - startTime,
|
|
76
85
|
};
|
|
77
86
|
}
|