autokap 1.5.4 → 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.
@@ -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,19 +586,27 @@ 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) {
548
- return adapter.takeElementScreenshot(opcode.elementSelector);
609
+ return adapter.takeElementScreenshot(opcode.elementSelector, opcode.outscale);
549
610
  }
550
611
  if (opcode.elementSelector) {
551
612
  throw new Error(`element capture requires adapter support for selector "${opcode.elementSelector}"`);
@@ -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));
@@ -608,6 +608,16 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
608
608
  captureId: z.ZodOptional<z.ZodString>;
609
609
  captureName: z.ZodOptional<z.ZodString>;
610
610
  elementSelector: z.ZodOptional<z.ZodString>;
611
+ outscale: z.ZodOptional<z.ZodObject<{
612
+ padding: z.ZodOptional<z.ZodNumber>;
613
+ paddingTop: z.ZodOptional<z.ZodNumber>;
614
+ paddingRight: z.ZodOptional<z.ZodNumber>;
615
+ paddingBottom: z.ZodOptional<z.ZodNumber>;
616
+ paddingLeft: z.ZodOptional<z.ZodNumber>;
617
+ paddingPercent: z.ZodOptional<z.ZodNumber>;
618
+ clampToViewport: z.ZodOptional<z.ZodBoolean>;
619
+ backgroundColor: z.ZodOptional<z.ZodString>;
620
+ }, z.core.$strict>>;
611
621
  description: z.ZodString;
612
622
  postcondition: z.ZodObject<{
613
623
  type: z.ZodEnum<{
@@ -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
  }