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.
- package/assets/skill/OPCODE-REFERENCE.md +13 -0
- package/assets/skill/SKILL.md +5 -5
- package/dist/action-verifier.d.ts +1 -1
- package/dist/action-verifier.js +68 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +9 -6
- package/dist/cli-contract.d.ts +1 -0
- package/dist/cli-contract.js +1 -0
- package/dist/cli-runner-local.d.ts +1 -0
- package/dist/cli-runner-local.js +1 -0
- package/dist/cli-runner.d.ts +6 -0
- package/dist/cli-runner.js +6 -3
- package/dist/cli.js +1 -0
- package/dist/execution-schema.d.ts +105 -45
- package/dist/execution-schema.js +35 -2
- package/dist/execution-types.d.ts +4 -2
- package/dist/opcode-runner.js +137 -36
- package/dist/overlay-engine.js +18 -4
- package/dist/postcondition.js +74 -41
- package/dist/program-signing.d.ts +10 -0
- package/dist/recovery-chain.js +17 -32
- package/dist/selector-resolver.js +25 -4
- package/dist/smart-wait.js +11 -2
- package/dist/video-narration-schema.d.ts +10 -0
- package/dist/web-playwright-local.d.ts +2 -2
- package/dist/web-playwright-local.js +2 -2
- package/package.json +1 -1
package/dist/opcode-runner.js
CHANGED
|
@@ -65,6 +65,39 @@ function resolveRecordingCaptureResolution(artifactPlan) {
|
|
|
65
65
|
}
|
|
66
66
|
return artifactPlan.format?.captureResolution;
|
|
67
67
|
}
|
|
68
|
+
const ACTION_EFFECT_OPCODE_KINDS = new Set([
|
|
69
|
+
'CLICK',
|
|
70
|
+
'TYPE',
|
|
71
|
+
'PRESS_KEY',
|
|
72
|
+
'SCROLL',
|
|
73
|
+
'HOVER',
|
|
74
|
+
'SELECT_OPTION',
|
|
75
|
+
'CHECK',
|
|
76
|
+
'DOUBLE_CLICK',
|
|
77
|
+
'DRAG',
|
|
78
|
+
]);
|
|
79
|
+
function getOpcodeActionEffectPolicy(opcode) {
|
|
80
|
+
const captureBefore = ACTION_EFFECT_OPCODE_KINDS.has(opcode.kind);
|
|
81
|
+
if (!captureBefore) {
|
|
82
|
+
return { captureBefore: false, requireEffect: false, noEffectMode: 'allow' };
|
|
83
|
+
}
|
|
84
|
+
if (opcode.postcondition.type === 'any_change') {
|
|
85
|
+
return { captureBefore: true, requireEffect: true, noEffectMode: 'fail' };
|
|
86
|
+
}
|
|
87
|
+
if (opcode.postcondition.type === 'always') {
|
|
88
|
+
return { captureBefore: true, requireEffect: false, noEffectMode: 'allow' };
|
|
89
|
+
}
|
|
90
|
+
if (opcode.kind === 'PRESS_KEY') {
|
|
91
|
+
return { captureBefore: true, requireEffect: false, noEffectMode: 'allow' };
|
|
92
|
+
}
|
|
93
|
+
return { captureBefore: true, requireEffect: true, noEffectMode: 'fail' };
|
|
94
|
+
}
|
|
95
|
+
function resolveRuntimePostcondition(opcode) {
|
|
96
|
+
if (opcode.kind === 'ASSERT_ROUTE' || opcode.kind === 'ASSERT_SURFACE') {
|
|
97
|
+
return { type: 'always' };
|
|
98
|
+
}
|
|
99
|
+
return opcode.postcondition;
|
|
100
|
+
}
|
|
68
101
|
// ── Main execution function ─────────────────────────────────────────
|
|
69
102
|
export async function executeProgram(program, createAdapter, options = {}) {
|
|
70
103
|
const recoveryChain = options.recoveryChain ?? new NoOpRecoveryChain();
|
|
@@ -254,7 +287,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
254
287
|
const startTime = Date.now();
|
|
255
288
|
const effectiveTimeoutMs = resolveOpcodeTimeoutMs(opcode);
|
|
256
289
|
const deadlineMs = startTime + effectiveTimeoutMs;
|
|
257
|
-
const
|
|
290
|
+
const actionEffectPolicy = getOpcodeActionEffectPolicy(opcode);
|
|
258
291
|
const isSoft = isSoftOpcodeKind(opcode.kind);
|
|
259
292
|
// Track page context for circuit breaker
|
|
260
293
|
try {
|
|
@@ -265,7 +298,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
265
298
|
// Execute with timeout
|
|
266
299
|
try {
|
|
267
300
|
logger.debug(`[opcode ${index}] ${opcode.kind} start — budget ${effectiveTimeoutMs}ms${formatOpcodeDebug(opcode)}`);
|
|
268
|
-
if (
|
|
301
|
+
if (actionEffectPolicy.captureBefore) {
|
|
269
302
|
const beforeStart = Date.now();
|
|
270
303
|
await verifier.captureBeforeState(adapter);
|
|
271
304
|
logger.debug(`[opcode ${index}] captureBeforeState took ${Date.now() - beforeStart}ms`);
|
|
@@ -276,7 +309,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
276
309
|
logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${deadlineMs}, now=${Date.now()})`);
|
|
277
310
|
if (isSoft)
|
|
278
311
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
279
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
312
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
280
313
|
}
|
|
281
314
|
// For mediaMode='video', capture pre-action timing + bbox metadata inside
|
|
282
315
|
// the active clip window only. Opcodes outside a clip are not part of the
|
|
@@ -310,7 +343,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
310
343
|
const reason = result.error ?? 'action failed';
|
|
311
344
|
if (isSoft)
|
|
312
345
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
313
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
346
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
314
347
|
}
|
|
315
348
|
// Verify postcondition
|
|
316
349
|
const postconditionBudgetMs = getRemainingTimeMs(deadlineMs);
|
|
@@ -319,39 +352,30 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
319
352
|
logger.debug(`[opcode ${index}] no budget left for postcondition check`);
|
|
320
353
|
if (isSoft)
|
|
321
354
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
322
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
355
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
323
356
|
}
|
|
357
|
+
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
324
358
|
const postStart = Date.now();
|
|
325
|
-
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(
|
|
326
|
-
logger.debug(`[opcode ${index}] postcondition (${
|
|
359
|
+
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(runtimePostcondition, postconditionBudgetMs));
|
|
360
|
+
logger.debug(`[opcode ${index}] postcondition (${runtimePostcondition.type}) took ${Date.now() - postStart}ms — passed=${postcondition.passed}, reason="${postcondition.reason}"`);
|
|
327
361
|
if (!postcondition.passed) {
|
|
328
362
|
const reason = `postcondition failed: ${postcondition.reason}`;
|
|
329
363
|
if (isSoft)
|
|
330
364
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
331
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
365
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
|
|
332
366
|
}
|
|
333
|
-
// Verify action
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
// change after the action means the action didn't really happen.
|
|
338
|
-
//
|
|
339
|
-
// PRESS_KEY is the exception. Keys are CONDITIONAL by nature: Escape
|
|
340
|
-
// is a no-op when no modal is open; Enter is a no-op when no form is
|
|
341
|
-
// focused; Tab is a no-op when no focusable target exists. A preset
|
|
342
|
-
// may legitimately script PRESS_KEY against a page that has already
|
|
343
|
-
// reached the target state via earlier opcodes (e.g. SLEEP let an
|
|
344
|
-
// auto-close timer run). The postcondition has already passed by this
|
|
345
|
-
// point — trust it for PRESS_KEY and skip the no-effect penalty.
|
|
346
|
-
if (isInteraction) {
|
|
367
|
+
// Verify action effects through the shared policy. Weak `any_change`
|
|
368
|
+
// postconditions are only meaningful if this verifier observes a real
|
|
369
|
+
// URL/tree/state/scroll change.
|
|
370
|
+
if (actionEffectPolicy.captureBefore) {
|
|
347
371
|
const verification = await verifier.verifyAfterAction(adapter);
|
|
348
|
-
if (!verification.hadEffect &&
|
|
349
|
-
if (opcode.kind === 'PRESS_KEY') {
|
|
372
|
+
if (!verification.hadEffect && actionEffectPolicy.requireEffect) {
|
|
373
|
+
if (opcode.kind === 'PRESS_KEY' && actionEffectPolicy.noEffectMode === 'allow') {
|
|
350
374
|
logger.debug(`[opcode ${index}] PRESS_KEY had no DOM effect (${verification.summary}) — ` +
|
|
351
375
|
`postcondition passed, treating as redundant-but-successful`);
|
|
352
376
|
}
|
|
353
377
|
else {
|
|
354
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
378
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, `action had no effect: ${verification.summary}`);
|
|
355
379
|
}
|
|
356
380
|
}
|
|
357
381
|
}
|
|
@@ -385,7 +409,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
385
409
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
386
410
|
if (isSoft)
|
|
387
411
|
return softSkipResult(opcode, index, startTime, errorMsg, telemetry);
|
|
388
|
-
return handleFailure(opcode, index, adapter, verifier,
|
|
412
|
+
return handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg);
|
|
389
413
|
}
|
|
390
414
|
}
|
|
391
415
|
/** Post-action breathing room (ms) injected between visible interactions
|
|
@@ -411,7 +435,8 @@ function sleep(ms) {
|
|
|
411
435
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
412
436
|
}
|
|
413
437
|
// ── Failure handling with recovery ──────────────────────────────────
|
|
414
|
-
async function handleFailure(opcode, index, adapter, verifier,
|
|
438
|
+
async function handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg) {
|
|
439
|
+
const actionEffectPolicy = getOpcodeActionEffectPolicy(opcode);
|
|
415
440
|
const breakerState = breaker.recordFailure(index, opcode.maxFailures);
|
|
416
441
|
if (breakerState.tripped) {
|
|
417
442
|
telemetry.circuitBreakerTrips++;
|
|
@@ -443,6 +468,11 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
|
|
|
443
468
|
opcodeKind: opcode.kind,
|
|
444
469
|
message: `recovering from: ${errorMsg}`,
|
|
445
470
|
});
|
|
471
|
+
if (actionEffectPolicy.captureBefore) {
|
|
472
|
+
const beforeRecoveryStart = Date.now();
|
|
473
|
+
await verifier.captureBeforeState(adapter);
|
|
474
|
+
logger.debug(`[opcode ${index}] recovery captureBeforeState took ${Date.now() - beforeRecoveryStart}ms`);
|
|
475
|
+
}
|
|
446
476
|
const recovery = await recoveryChain.attempt(opcode, index, adapter, {
|
|
447
477
|
remainingTimeMs,
|
|
448
478
|
maxDeterministicRetries: Math.max(0, opcode.maxFailures - breakerState.opcodeFailures),
|
|
@@ -466,9 +496,32 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
|
|
|
466
496
|
if (recovery.patch) {
|
|
467
497
|
healerPatches.push(recovery.patch);
|
|
468
498
|
}
|
|
469
|
-
|
|
499
|
+
const postconditionBudgetMs = getRemainingTimeMs(deadlineMs);
|
|
500
|
+
if (postconditionBudgetMs <= 0) {
|
|
501
|
+
return {
|
|
502
|
+
opcodeIndex: index,
|
|
503
|
+
kind: opcode.kind,
|
|
504
|
+
status: 'failed',
|
|
505
|
+
durationMs: Date.now() - startTime,
|
|
506
|
+
recoveryAttempts: 1,
|
|
507
|
+
error: `${errorMsg} (recovery succeeded but postcondition could not be rechecked before timeout after ${effectiveTimeoutMs}ms)`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
511
|
+
const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(runtimePostcondition, postconditionBudgetMs));
|
|
512
|
+
if (!postcondition.passed) {
|
|
513
|
+
return {
|
|
514
|
+
opcodeIndex: index,
|
|
515
|
+
kind: opcode.kind,
|
|
516
|
+
status: 'failed',
|
|
517
|
+
durationMs: Date.now() - startTime,
|
|
518
|
+
recoveryAttempts: 1,
|
|
519
|
+
error: `${errorMsg} (recovery succeeded but postcondition still failed: ${postcondition.reason})`,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (actionEffectPolicy.captureBefore) {
|
|
470
523
|
const verification = await verifier.verifyAfterAction(adapter);
|
|
471
|
-
if (!verification.hadEffect &&
|
|
524
|
+
if (!verification.hadEffect && actionEffectPolicy.requireEffect) {
|
|
472
525
|
return {
|
|
473
526
|
opcodeIndex: index,
|
|
474
527
|
kind: opcode.kind,
|
|
@@ -533,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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
824
|
+
const operation = Promise.resolve().then(fn);
|
|
825
|
+
let timedOut = false;
|
|
826
|
+
let timer = null;
|
|
827
|
+
const timeout = new Promise((_, reject) => {
|
|
828
|
+
timer = setTimeout(() => {
|
|
829
|
+
timedOut = true;
|
|
830
|
+
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
831
|
+
}, timeoutMs);
|
|
768
832
|
});
|
|
833
|
+
try {
|
|
834
|
+
return await Promise.race([operation, timeout]);
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
if (!timedOut)
|
|
838
|
+
throw err;
|
|
839
|
+
// Playwright actions cannot be cancelled through the current adapter API.
|
|
840
|
+
// Before recovery starts, give the in-flight action a short chance to
|
|
841
|
+
// settle so it does not mutate the page concurrently with recovery or the
|
|
842
|
+
// next opcode. This is deadline-safe without changing adapter contracts.
|
|
843
|
+
await Promise.race([
|
|
844
|
+
operation.catch(() => undefined),
|
|
845
|
+
sleep(TIMED_OUT_ACTION_DRAIN_MS),
|
|
846
|
+
]);
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
if (timer)
|
|
851
|
+
clearTimeout(timer);
|
|
852
|
+
}
|
|
769
853
|
}
|
|
854
|
+
const TIMED_OUT_ACTION_DRAIN_MS = 2000;
|
|
770
855
|
function getRemainingTimeMs(deadlineMs) {
|
|
771
856
|
return Math.max(0, deadlineMs - Date.now());
|
|
772
857
|
}
|
|
@@ -790,6 +875,22 @@ function evaluateImmediateAssertion(result, prefix) {
|
|
|
790
875
|
? { success: true }
|
|
791
876
|
: { success: false, error: `${prefix}: ${result.reason}` };
|
|
792
877
|
}
|
|
878
|
+
function assertRoutePostconditionSource(opcode) {
|
|
879
|
+
const post = opcode.postcondition;
|
|
880
|
+
if (post.type === 'always')
|
|
881
|
+
return;
|
|
882
|
+
if (post.type === 'route_matches' && post.pattern === opcode.urlPattern)
|
|
883
|
+
return;
|
|
884
|
+
throw new Error(`ASSERT_ROUTE uses urlPattern as the source of truth; postcondition must be always or route_matches with the same pattern "${opcode.urlPattern}"`);
|
|
885
|
+
}
|
|
886
|
+
function assertSurfacePostconditionSource(opcode) {
|
|
887
|
+
const post = opcode.postcondition;
|
|
888
|
+
if (post.type === 'always')
|
|
889
|
+
return;
|
|
890
|
+
if (post.type === 'element_visible' && post.selector && opcode.selectors.includes(post.selector))
|
|
891
|
+
return;
|
|
892
|
+
throw new Error('ASSERT_SURFACE uses selectors/matchAll as the source of truth; postcondition must be always or element_visible for one of the asserted selectors');
|
|
893
|
+
}
|
|
793
894
|
async function evaluateSurfaceAssertion(adapter, selectors, matchAll) {
|
|
794
895
|
const checks = await Promise.all(selectors.map(async (selector) => ({
|
|
795
896
|
selector,
|
package/dist/overlay-engine.js
CHANGED
|
@@ -149,10 +149,16 @@ function findNodeById(node, id) {
|
|
|
149
149
|
return null;
|
|
150
150
|
}
|
|
151
151
|
function buildSelectorFromSourceRef(sourceRef) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
const trimmed = sourceRef.trim();
|
|
153
|
+
if (isSelectorLikeSourceRef(trimmed)) {
|
|
154
|
+
return trimmed;
|
|
155
|
+
}
|
|
156
|
+
// Extract AutoKap/test automation attributes
|
|
157
|
+
for (const attr of ['data-ak', 'data-ak-interact', 'data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
158
|
+
const attrMatch = sourceRef.match(new RegExp(`${attr}="([^"]+)"`));
|
|
159
|
+
if (attrMatch)
|
|
160
|
+
return `[${attr}="${attrMatch[1].replace(/"/g, '\\"')}"]`;
|
|
161
|
+
}
|
|
156
162
|
// Extract id
|
|
157
163
|
const idMatch = sourceRef.match(/ id="([^"]+)"/);
|
|
158
164
|
if (idMatch)
|
|
@@ -170,6 +176,14 @@ function buildSelectorFromSourceRef(sourceRef) {
|
|
|
170
176
|
}
|
|
171
177
|
return null;
|
|
172
178
|
}
|
|
179
|
+
function isSelectorLikeSourceRef(sourceRef) {
|
|
180
|
+
if (!sourceRef || sourceRef.startsWith('<'))
|
|
181
|
+
return false;
|
|
182
|
+
return sourceRef.startsWith('#')
|
|
183
|
+
|| sourceRef.startsWith('.')
|
|
184
|
+
|| sourceRef.startsWith('[')
|
|
185
|
+
|| /^[a-z][a-z0-9-]*(?:[#.[\s>:]|$)/i.test(sourceRef);
|
|
186
|
+
}
|
|
173
187
|
function sleep(ms) {
|
|
174
188
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
175
189
|
}
|
package/dist/postcondition.js
CHANGED
|
@@ -14,12 +14,13 @@ export async function evaluatePostcondition(adapter, spec) {
|
|
|
14
14
|
const maxWait = spec.waitMs ?? 5000;
|
|
15
15
|
const pollInterval = 500;
|
|
16
16
|
const deadline = Date.now() + maxWait;
|
|
17
|
+
const context = {};
|
|
17
18
|
// 'always' postcondition always passes immediately
|
|
18
19
|
if (spec.type === 'always') {
|
|
19
20
|
return { passed: true, reason: 'always passes' };
|
|
20
21
|
}
|
|
21
22
|
while (Date.now() < deadline) {
|
|
22
|
-
const result = await checkOnce(adapter, spec);
|
|
23
|
+
const result = await checkOnce(adapter, spec, context);
|
|
23
24
|
if (result.passed)
|
|
24
25
|
return result;
|
|
25
26
|
const remaining = deadline - Date.now();
|
|
@@ -28,9 +29,9 @@ export async function evaluatePostcondition(adapter, spec) {
|
|
|
28
29
|
await sleep(Math.min(pollInterval, remaining));
|
|
29
30
|
}
|
|
30
31
|
// Final check after timeout
|
|
31
|
-
return checkOnce(adapter, spec);
|
|
32
|
+
return checkOnce(adapter, spec, context);
|
|
32
33
|
}
|
|
33
|
-
async function checkOnce(adapter, spec) {
|
|
34
|
+
async function checkOnce(adapter, spec, context) {
|
|
34
35
|
switch (spec.type) {
|
|
35
36
|
case 'route_matches':
|
|
36
37
|
return checkRouteMatches(adapter, spec.pattern);
|
|
@@ -43,11 +44,11 @@ async function checkOnce(adapter, spec) {
|
|
|
43
44
|
case 'overlay_dismissed':
|
|
44
45
|
return checkOverlayDismissed(adapter);
|
|
45
46
|
case 'screenshot_stable':
|
|
46
|
-
return checkScreenshotStable(adapter, spec.threshold ?? 0.01);
|
|
47
|
+
return checkScreenshotStable(adapter, spec.threshold ?? 0.01, context);
|
|
47
48
|
case 'any_change':
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
return { passed: true, reason: 'any_change
|
|
49
|
+
// The runner makes this verifier-backed by requiring a detected action
|
|
50
|
+
// effect after this structural postcondition succeeds.
|
|
51
|
+
return { passed: true, reason: 'any_change deferred to action verifier' };
|
|
51
52
|
case 'always':
|
|
52
53
|
return { passed: true, reason: 'always passes' };
|
|
53
54
|
default:
|
|
@@ -152,8 +153,13 @@ async function checkTextContains(adapter, selector, expectedText) {
|
|
|
152
153
|
if (!node) {
|
|
153
154
|
return { passed: false, reason: `element "${selector}" not found for text check` };
|
|
154
155
|
}
|
|
155
|
-
const nodeText = (
|
|
156
|
-
|
|
156
|
+
const nodeText = normalizeText([
|
|
157
|
+
node.label || '',
|
|
158
|
+
node.value || '',
|
|
159
|
+
node.attributes.__ownText || '',
|
|
160
|
+
].join(' '));
|
|
161
|
+
const expected = normalizeText(expectedText);
|
|
162
|
+
if (nodeText.includes(expected)) {
|
|
157
163
|
return { passed: true, reason: `element "${selector}" contains "${expectedText}"` };
|
|
158
164
|
}
|
|
159
165
|
return { passed: false, reason: `element "${selector}" text "${nodeText}" does not contain "${expectedText}"` };
|
|
@@ -182,33 +188,29 @@ async function checkOverlayDismissed(adapter) {
|
|
|
182
188
|
return { passed: true, reason: 'overlay check skipped (AKTree unavailable), assuming dismissed' };
|
|
183
189
|
}
|
|
184
190
|
}
|
|
185
|
-
|
|
186
|
-
async function checkScreenshotStable(adapter, threshold) {
|
|
191
|
+
async function checkScreenshotStable(adapter, threshold, context) {
|
|
187
192
|
try {
|
|
188
193
|
const screenshot = await adapter.takeScreenshot();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Wait and take another screenshot
|
|
193
|
-
await sleep(500);
|
|
194
|
+
if (!context.lastScreenshot) {
|
|
195
|
+
context.lastScreenshot = screenshot;
|
|
196
|
+
await sleep(250);
|
|
194
197
|
const screenshot2 = await adapter.takeScreenshot();
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
return { passed: true, reason:
|
|
198
|
+
const diff = sampledBufferDiffRatio(screenshot, screenshot2);
|
|
199
|
+
context.lastScreenshot = screenshot2;
|
|
200
|
+
if (diff <= threshold) {
|
|
201
|
+
return { passed: true, reason: `consecutive screenshots stable (diff=${diff.toFixed(4)})` };
|
|
199
202
|
}
|
|
200
|
-
return { passed: false, reason:
|
|
203
|
+
return { passed: false, reason: `consecutive screenshots differ (diff=${diff.toFixed(4)})` };
|
|
201
204
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return { passed: true, reason:
|
|
205
|
+
const diff = sampledBufferDiffRatio(context.lastScreenshot, screenshot);
|
|
206
|
+
context.lastScreenshot = screenshot;
|
|
207
|
+
if (diff <= threshold) {
|
|
208
|
+
return { passed: true, reason: `screenshot stable (diff=${diff.toFixed(4)})` };
|
|
206
209
|
}
|
|
207
|
-
|
|
208
|
-
return { passed: false, reason: 'screenshot changed from previous check' };
|
|
210
|
+
return { passed: false, reason: `screenshot changed from previous check (diff=${diff.toFixed(4)})` };
|
|
209
211
|
}
|
|
210
212
|
catch (err) {
|
|
211
|
-
|
|
213
|
+
context.lastScreenshot = undefined;
|
|
212
214
|
return { passed: false, reason: `error checking screenshot stability: ${err}` };
|
|
213
215
|
}
|
|
214
216
|
}
|
|
@@ -216,7 +218,7 @@ async function checkScreenshotStable(adapter, threshold) {
|
|
|
216
218
|
function hasVisibleNodeWithSelector(tree, selector) {
|
|
217
219
|
// Walk the AKTree looking for a visible node whose sourceRef matches the selector
|
|
218
220
|
function walk(node) {
|
|
219
|
-
if (node.visible &&
|
|
221
|
+
if (node.visible && matchesNodeSelectorHeuristic(node, selector)) {
|
|
220
222
|
return true;
|
|
221
223
|
}
|
|
222
224
|
return node.children.some(walk);
|
|
@@ -225,7 +227,7 @@ function hasVisibleNodeWithSelector(tree, selector) {
|
|
|
225
227
|
}
|
|
226
228
|
function findNodeBySelector(tree, selector) {
|
|
227
229
|
function walk(node) {
|
|
228
|
-
if (
|
|
230
|
+
if (matchesNodeSelectorHeuristic(node, selector)) {
|
|
229
231
|
return node;
|
|
230
232
|
}
|
|
231
233
|
for (const child of node.children) {
|
|
@@ -237,6 +239,20 @@ function findNodeBySelector(tree, selector) {
|
|
|
237
239
|
}
|
|
238
240
|
return walk(tree.root);
|
|
239
241
|
}
|
|
242
|
+
function matchesNodeSelectorHeuristic(node, selector) {
|
|
243
|
+
if (node.sourceRef && matchesSelectorHeuristic(node.sourceRef, selector)) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
247
|
+
if (attrMatch) {
|
|
248
|
+
const [, attr, rawValue] = attrMatch;
|
|
249
|
+
return (node.attributes[attr] ?? '').toLowerCase() === rawValue.toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
if (selector.startsWith('#')) {
|
|
252
|
+
return (node.attributes.id ?? '').toLowerCase() === selector.slice(1).toLowerCase();
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
240
256
|
/**
|
|
241
257
|
* Heuristic match between an AKTree sourceRef and a CSS-like selector.
|
|
242
258
|
* Not a full CSS selector engine — handles common patterns:
|
|
@@ -248,10 +264,18 @@ function findNodeBySelector(tree, selector) {
|
|
|
248
264
|
function matchesSelectorHeuristic(sourceRef, selector) {
|
|
249
265
|
const lower = sourceRef.toLowerCase();
|
|
250
266
|
const selectorLower = selector.toLowerCase();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
267
|
+
if (selectorLower === lower) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
// Generic attribute selector match, including AutoKap's canonical data-ak
|
|
271
|
+
// selectors and legacy test-id selectors.
|
|
272
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
273
|
+
if (attrMatch) {
|
|
274
|
+
const [, attr, rawValue] = attrMatch;
|
|
275
|
+
const value = rawValue.toLowerCase();
|
|
276
|
+
return lower === `[${attr.toLowerCase()}="${value}"]`
|
|
277
|
+
|| lower.includes(`[${attr.toLowerCase()}="${value}"]`)
|
|
278
|
+
|| lower.includes(`${attr.toLowerCase()}="${value}"`);
|
|
255
279
|
}
|
|
256
280
|
// ID match
|
|
257
281
|
if (selector.startsWith('#')) {
|
|
@@ -264,14 +288,23 @@ function matchesSelectorHeuristic(sourceRef, selector) {
|
|
|
264
288
|
// Fallback: contains
|
|
265
289
|
return lower.includes(selectorLower);
|
|
266
290
|
}
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
function sampledBufferDiffRatio(a, b) {
|
|
292
|
+
const maxLength = Math.max(a.length, b.length);
|
|
293
|
+
if (maxLength === 0)
|
|
294
|
+
return 0;
|
|
295
|
+
const sampleCount = Math.min(10000, maxLength);
|
|
296
|
+
const step = Math.max(1, Math.floor(maxLength / sampleCount));
|
|
297
|
+
let checked = 0;
|
|
298
|
+
let changed = 0;
|
|
299
|
+
for (let i = 0; i < maxLength; i += step) {
|
|
300
|
+
checked++;
|
|
301
|
+
if (a[i] !== b[i])
|
|
302
|
+
changed++;
|
|
273
303
|
}
|
|
274
|
-
return
|
|
304
|
+
return checked === 0 ? 0 : changed / checked;
|
|
305
|
+
}
|
|
306
|
+
function normalizeText(value) {
|
|
307
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
275
308
|
}
|
|
276
309
|
function sleep(ms) {
|
|
277
310
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -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<{
|
package/dist/recovery-chain.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveSelector } from './selector-resolver.js';
|
|
|
12
12
|
import { evaluatePostcondition } from './postcondition.js';
|
|
13
13
|
import { LLMHealer } from './llm-healer.js';
|
|
14
14
|
import { serializeAKTree } from './ak-tree.js';
|
|
15
|
-
import { executeOpcodeCoreAction
|
|
15
|
+
import { executeOpcodeCoreAction } from './opcode-actions.js';
|
|
16
16
|
import { dismissAllOverlays } from './overlay-engine.js';
|
|
17
17
|
import { logger } from './logger.js';
|
|
18
18
|
export class RecoveryChainImpl {
|
|
@@ -43,7 +43,7 @@ export class RecoveryChainImpl {
|
|
|
43
43
|
// Strategy 2: Selector memory + alternate selectors
|
|
44
44
|
if (recovery.useSelectorMemory && hasSelector(failedOpcode)) {
|
|
45
45
|
logger.debug(`[recovery ${opcodeIndex}] strategy 2 (selector memory)`);
|
|
46
|
-
const result = await trySelectorAlternatives(failedOpcode, opcodeIndex, adapter, this.selectorMemory, this.credentials);
|
|
46
|
+
const result = await trySelectorAlternatives(failedOpcode, opcodeIndex, adapter, this.selectorMemory, this.credentials, options.currentVariant);
|
|
47
47
|
logger.debug(`[recovery ${opcodeIndex}] strategy 2 → recovered=${result.recovered}, reason=${result.reason}`);
|
|
48
48
|
if (result.recovered)
|
|
49
49
|
return result;
|
|
@@ -109,7 +109,7 @@ async function retryOpcode(opcode, adapter, maxRetries, remainingTimeMs, current
|
|
|
109
109
|
return { recovered: false, reason: `${maxRetries} retries exhausted` };
|
|
110
110
|
}
|
|
111
111
|
// ── Strategy 2: Selector memory ─────────────────────────────────────
|
|
112
|
-
async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMemory, credentials) {
|
|
112
|
+
async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMemory, credentials, currentVariant) {
|
|
113
113
|
if (!hasSelector(opcode)) {
|
|
114
114
|
return { recovered: false, reason: 'opcode has no selector' };
|
|
115
115
|
}
|
|
@@ -129,7 +129,7 @@ async function trySelectorAlternatives(opcode, opcodeIndex, adapter, selectorMem
|
|
|
129
129
|
}
|
|
130
130
|
// Try the resolved selector
|
|
131
131
|
try {
|
|
132
|
-
await executeWithSelector(opcode, adapter, resolved.selector, credentials);
|
|
132
|
+
await executeWithSelector(opcode, adapter, resolved.selector, credentials, currentVariant);
|
|
133
133
|
const postcondition = await evaluatePostcondition(adapter, opcode.postcondition);
|
|
134
134
|
if (postcondition.passed) {
|
|
135
135
|
return {
|
|
@@ -312,40 +312,25 @@ async function executeHealerPatchedAction(opcode, adapter, interactionMode, curr
|
|
|
312
312
|
}
|
|
313
313
|
await executeRawAction(opcode, adapter, currentVariant, credentials, suppressPageReloads);
|
|
314
314
|
}
|
|
315
|
-
async function executeWithSelector(opcode, adapter, newSelector, credentials) {
|
|
315
|
+
async function executeWithSelector(opcode, adapter, newSelector, credentials, currentVariant) {
|
|
316
|
+
const opcodeWithSelector = cloneOpcodeWithSelector(opcode, newSelector);
|
|
317
|
+
const result = await executeOpcodeCoreAction(opcodeWithSelector, adapter, {
|
|
318
|
+
credentials,
|
|
319
|
+
currentVariant,
|
|
320
|
+
});
|
|
321
|
+
if (!result.success) {
|
|
322
|
+
throw new Error(result.error ?? `selector recovery failed for ${opcode.kind}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function cloneOpcodeWithSelector(opcode, newSelector) {
|
|
316
326
|
switch (opcode.kind) {
|
|
317
327
|
case 'CLICK':
|
|
318
|
-
|
|
319
|
-
break;
|
|
320
|
-
case 'TYPE': {
|
|
321
|
-
const text = substituteCredentialPlaceholders(opcode.text, credentials);
|
|
322
|
-
await adapter.type(newSelector, text, opcode.clearFirst);
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
328
|
+
case 'TYPE':
|
|
325
329
|
case 'HOVER':
|
|
326
|
-
if (!adapter.hover)
|
|
327
|
-
throw new Error('adapter does not support HOVER');
|
|
328
|
-
await adapter.hover(newSelector);
|
|
329
|
-
break;
|
|
330
330
|
case 'SELECT_OPTION':
|
|
331
|
-
if (!adapter.selectOption)
|
|
332
|
-
throw new Error('adapter does not support SELECT_OPTION');
|
|
333
|
-
await adapter.selectOption(newSelector, {
|
|
334
|
-
label: opcode.optionLabel,
|
|
335
|
-
value: opcode.optionValue,
|
|
336
|
-
index: opcode.optionIndex,
|
|
337
|
-
});
|
|
338
|
-
break;
|
|
339
331
|
case 'CHECK':
|
|
340
|
-
if (!adapter.check)
|
|
341
|
-
throw new Error('adapter does not support CHECK');
|
|
342
|
-
await adapter.check(newSelector, opcode.checked);
|
|
343
|
-
break;
|
|
344
332
|
case 'DOUBLE_CLICK':
|
|
345
|
-
|
|
346
|
-
throw new Error('adapter does not support DOUBLE_CLICK');
|
|
347
|
-
await adapter.doubleClick(newSelector);
|
|
348
|
-
break;
|
|
333
|
+
return { ...opcode, selector: newSelector };
|
|
349
334
|
default:
|
|
350
335
|
throw new Error(`cannot swap selector for opcode kind: ${opcode.kind}`);
|
|
351
336
|
}
|