autokap 1.5.5 → 1.6.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.
@@ -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
  ```
@@ -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` | Validation checkpoint |
165
- | `ASSERT_SURFACE` | no | `selectors[]`, `matchAll` | `always` | Validation checkpoint |
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` | — | After TYPE, PRESS_KEY, DOUBLE_CLICK (soft check) |
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;
@@ -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-testid',
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;
@@ -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[];
@@ -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
  }
@@ -12,4 +12,5 @@ export declare function runLocal(presetId: string, opts: {
12
12
  env?: string;
13
13
  allowUploadFailure?: boolean;
14
14
  dry?: boolean;
15
+ regenerateTts?: boolean;
15
16
  }): Promise<void>;
@@ -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}]`;
@@ -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) */
@@ -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
@@ -240,17 +240,60 @@ program
240
240
  }
241
241
  process.exit(0);
242
242
  });
243
+ async function runOutdatedPresetsLocally(opts) {
244
+ if (opts.output) {
245
+ fatal('`--output` is not supported with `--outdated`; run an individual preset when local copies are needed.');
246
+ }
247
+ const config = await requireConfig();
248
+ const search = new URLSearchParams({ freshness: 'outdated' });
249
+ const data = await requestJson(config, `/api/cli/projects/${opts.project}/auto-recapture-presets`, { headers: authHeaders(config) }, 'Failed to list outdated presets', search);
250
+ if (data.presets.length === 0) {
251
+ logger.info(`[capture] No outdated presets found for project ${opts.project}`);
252
+ process.exit(0);
253
+ }
254
+ const { runCapture } = await import('./cli-runner.js');
255
+ const failures = [];
256
+ logger.info(`[capture] Running ${data.presets.length} outdated preset(s)`);
257
+ for (const preset of data.presets) {
258
+ const label = preset.name ? `${preset.name} (${preset.id})` : preset.id;
259
+ logger.info(`[capture] Running outdated preset ${label}`);
260
+ const result = await runCapture({
261
+ presetId: preset.id,
262
+ env: opts.env,
263
+ headed: opts.headed,
264
+ allowUploadFailure: opts.allowUploadFailure,
265
+ dryRun: opts.dry,
266
+ regenerateTts: opts.regenerateTts,
267
+ });
268
+ if (!result.success) {
269
+ failures.push({
270
+ id: preset.id,
271
+ name: preset.name,
272
+ error: result.error ?? result.runResult?.error ?? 'capture failed',
273
+ });
274
+ }
275
+ }
276
+ if (failures.length > 0) {
277
+ logger.error(`[capture] ${failures.length}/${data.presets.length} outdated preset(s) failed: ${failures.map((failure) => failure.name ?? failure.id).join(', ')}`);
278
+ process.exit(1);
279
+ }
280
+ logger.success(`[capture] ${data.presets.length} outdated preset(s) captured successfully`);
281
+ process.exit(0);
282
+ }
243
283
  // ── run command (deterministic capture engine) ───────────────────────
244
284
  program
245
- .command('run <preset-id>')
285
+ .command('run [preset-id]')
246
286
  .description('Run a capture using the deterministic opcode engine (local Playwright)')
247
287
  .option('--headed', 'Show browser window for debugging', false)
248
288
  .option('--local', `Use the local AutoKap dev server (${LOCAL_API_BASE_URL})`, false)
289
+ .option('--project <id>', 'Project ID. Required with --outdated.')
290
+ .option('--outdated', 'Run all outdated presets in the project', false)
249
291
  .option('--env <name>', "Project environment to capture against. Falls back to the project's default environment when omitted.")
250
292
  .option('--allow-upload-failure', 'Keep a successful capture exit code even if artifact upload fails', false)
251
293
  .option('--output <dir>', 'Optional output directory for local artifact copies')
252
294
  .option('--program <file>', 'Path to a program JSON file')
253
295
  .option('--dry', 'Dry run: execute all opcodes without capturing or uploading artifacts (0 credits charged)', false)
296
+ .option('--regenerate-tts', 'Force fresh TTS synthesis for video presets — ignore cached audio segments. Reused segments become billable at full rate.', false)
254
297
  .option('--debug', 'Verbose logging: per-substep timing, opcode dumps, recovery strategy traces', false)
255
298
  .action(async (presetId, opts) => {
256
299
  if (opts.debug) {
@@ -263,6 +306,19 @@ program
263
306
  process.env[WS_URL_ENV_VAR] = LOCAL_WS_URL;
264
307
  logger.info(`Using local AutoKap dev server: ${LOCAL_API_BASE_URL}`);
265
308
  }
309
+ if (opts.outdated) {
310
+ if (!opts.project) {
311
+ fatal('Missing --project <id> for `autokap run --outdated`.');
312
+ }
313
+ if (opts.program) {
314
+ fatal('`--program` cannot be combined with `--outdated`.');
315
+ }
316
+ await runOutdatedPresetsLocally(opts);
317
+ return;
318
+ }
319
+ if (!presetId) {
320
+ fatal('Missing preset ID. Use `autokap run <preset-id>` or `autokap run --outdated --project <project-id>`.');
321
+ }
266
322
  const { runLocal } = await import('./cli-runner-local.js');
267
323
  await runLocal(presetId, opts);
268
324
  });
@@ -278,6 +334,7 @@ program
278
334
  .option('--debug', 'Verbose logging: per-substep timing, opcode dumps, recovery strategy traces', false)
279
335
  .option('--cloud', 'Cloud runner mode: signals 4+ vCPU available, unblocks the conservative Linux FPS default (8 → 30)', false)
280
336
  .option('--preset-ids <ids>', 'Comma-separated preset IDs to capture. When omitted, captures all presets with auto_recapture_enabled=true.')
337
+ .option('--outdated', 'Capture only outdated auto-recapture presets.', false)
281
338
  .action(async (opts) => {
282
339
  if (opts.debug) {
283
340
  const { setDebugEnabled } = await import('./logger.js');
@@ -392,9 +449,10 @@ program
392
449
  // When `--preset-ids` is provided, restrict the run to that subset
393
450
  // (per-preset recapture launched from the dashboard).
394
451
  const presetIdsArg = opts.presetIds?.trim();
452
+ const freshnessSuffix = opts.outdated ? '?freshness=outdated' : '';
395
453
  const presetsPath = presetIdsArg
396
454
  ? `/api/cli/projects/${opts.project}/auto-recapture-presets?preset_ids=${encodeURIComponent(presetIdsArg)}`
397
- : `/api/cli/projects/${opts.project}/auto-recapture-presets`;
455
+ : `/api/cli/projects/${opts.project}/auto-recapture-presets${freshnessSuffix}`;
398
456
  let data;
399
457
  try {
400
458
  const response = await fetch(buildApiUrl(config, presetsPath), {
@@ -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;
@@ -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(),
@@ -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 isInteraction = ['CLICK', 'TYPE', 'PRESS_KEY', 'SCROLL'].includes(opcode.kind);
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 (isInteraction) {
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
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(opcode.postcondition, postconditionBudgetMs));
326
- logger.debug(`[opcode ${index}] postcondition (${opcode.postcondition.type}) took ${Date.now() - postStart}ms — passed=${postcondition.passed}, reason="${postcondition.reason}"`);
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
365
+ return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
332
366
  }
333
- // Verify action had effect (for interaction opcodes). The verifier
334
- // catches silent failures: e.g. CLICK on a stale selector that found
335
- // nothing and the page-state-passing postcondition is coincidentally
336
- // satisfied by an unrelated state. For most interactions, no DOM
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 && opcode.postcondition.type !== 'always' && opcode.postcondition.type !== 'any_change') {
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, `action had no effect: ${verification.summary}`);
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg);
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, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg) {
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
- if (isInteraction) {
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 && opcode.postcondition.type !== 'always' && opcode.postcondition.type !== 'any_change') {
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
- return new Promise((resolve, reject) => {
764
- const timer = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
765
- fn()
766
- .then(result => { clearTimeout(timer); resolve(result); })
767
- .catch(err => { clearTimeout(timer); reject(err); });
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,
@@ -149,10 +149,16 @@ function findNodeById(node, id) {
149
149
  return null;
150
150
  }
151
151
  function buildSelectorFromSourceRef(sourceRef) {
152
- // Extract data-testid
153
- const testIdMatch = sourceRef.match(/data-testid="([^"]+)"/);
154
- if (testIdMatch)
155
- return `[data-testid="${testIdMatch[1]}"]`;
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
  }
@@ -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
- // 'any_change' is a soft postcondition we just assume the action did something.
49
- // The action-verifier handles real change detection via AKTree diff.
50
- return { passed: true, reason: 'any_change always passes (action verifier handles real detection)' };
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 = (node.label || '') + (node.value || '');
156
- if (nodeText.includes(expectedText)) {
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
- let lastScreenshotHash = null;
186
- async function checkScreenshotStable(adapter, threshold) {
191
+ async function checkScreenshotStable(adapter, threshold, context) {
187
192
  try {
188
193
  const screenshot = await adapter.takeScreenshot();
189
- const currentHash = simpleHash(screenshot);
190
- if (lastScreenshotHash === null) {
191
- lastScreenshotHash = currentHash;
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 hash2 = simpleHash(screenshot2);
196
- lastScreenshotHash = null;
197
- if (currentHash === hash2) {
198
- return { passed: true, reason: 'consecutive screenshots are identical' };
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: 'consecutive screenshots differ (page still changing)' };
203
+ return { passed: false, reason: `consecutive screenshots differ (diff=${diff.toFixed(4)})` };
201
204
  }
202
- // Compare with previous
203
- if (currentHash === lastScreenshotHash) {
204
- lastScreenshotHash = null;
205
- return { passed: true, reason: 'screenshot matches previous' };
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
- lastScreenshotHash = currentHash;
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
- lastScreenshotHash = null;
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 && node.sourceRef && matchesSelectorHeuristic(node.sourceRef, selector)) {
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 (node.sourceRef && matchesSelectorHeuristic(node.sourceRef, selector)) {
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
- // data-testid match
252
- const testIdMatch = selector.match(/\[data-testid=["'](.+?)["']\]/);
253
- if (testIdMatch) {
254
- return lower.includes(`data-testid="${testIdMatch[1]}"`);
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 simpleHash(buffer) {
268
- // Fast non-crypto hash for screenshot comparison
269
- let hash = 0;
270
- const step = Math.max(1, Math.floor(buffer.length / 10000));
271
- for (let i = 0; i < buffer.length; i += step) {
272
- hash = ((hash << 5) - hash + buffer[i]) | 0;
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 hash.toString(36);
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));
@@ -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, substituteCredentialPlaceholders } from './opcode-actions.js';
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
- await adapter.click(newSelector, opcode.button ? { button: opcode.button } : undefined);
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
- if (!adapter.doubleClick)
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
- // Fallback: use AKNode id as a CSS selector via data attribute
163
- if (node.id) {
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 `${tag}:has-text("${escapeText(node.label.slice(0, 50))}")`;
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
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from '@supabase/supabase-js';
2
- export type CreditUsageType = 'screenshot' | 'clip' | 'video' | 'preset_analysis' | 'cloud_recapture' | 'ai_chat' | 'studio_creation' | 'studio_iteration';
2
+ export type CreditUsageType = 'screenshot' | 'clip' | 'video' | 'cloud_recapture' | 'ai_chat' | 'studio_creation' | 'studio_iteration';
3
3
  export declare function recordCreditUsage(supabase: SupabaseClient, params: {
4
4
  userId: string;
5
5
  projectId: string | null;
@@ -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: true,
74
- waitedFor,
82
+ stable: unresolvedLoaders.length === 0,
83
+ waitedFor: unresolvedLoaders.length > 0 ? unresolvedLoaders : waitedFor,
75
84
  waitedMs: Date.now() - startTime,
76
85
  };
77
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.5.5",
3
+ "version": "1.6.0",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",