autokap 1.9.5 → 1.9.6
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/dist/action-verifier.d.ts +33 -0
- package/dist/action-verifier.js +103 -4
- package/dist/browser.js +96 -10
- package/dist/cli-runner.js +26 -9
- package/dist/cookie-dismiss.d.ts +9 -0
- package/dist/cookie-dismiss.js +43 -10
- package/dist/opcode-runner.js +75 -2
- package/dist/overlay-engine.js +108 -15
- package/dist/postcondition.js +73 -16
- package/dist/recovery-chain.js +70 -5
- package/dist/security.d.ts +21 -4
- package/dist/security.js +73 -6
- package/dist/wait-contract.d.ts +23 -9
- package/dist/wait-contract.js +24 -12
- package/dist/web-playwright-local.d.ts +1 -0
- package/dist/web-playwright-local.js +34 -1
- package/package.json +1 -1
|
@@ -27,9 +27,42 @@ export interface ActionChange {
|
|
|
27
27
|
/**
|
|
28
28
|
* Captures AKTree state before and after an action to detect changes.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* F2: outcome of a SET_THEME / SET_LOCALE effect probe. These opcodes used to
|
|
32
|
+
* report clean success even when the app ignored the requested theme/locale
|
|
33
|
+
* (e.g. an app that themes via a `class`/`data-theme`/storage key but never
|
|
34
|
+
* reads `prefers-color-scheme`). The probe reads back an observable signal from
|
|
35
|
+
* the live AKTree — node styling for theme, visible text for locale — and, when
|
|
36
|
+
* no change is observed (or the signal can't be read), flags the result
|
|
37
|
+
* low-confidence. It NEVER hard-fails: a missing signal only downgrades trust.
|
|
38
|
+
*/
|
|
39
|
+
export interface ThemeLocaleEffectResult {
|
|
40
|
+
/** Whether a deterministic, observable change confirmed the effect applied. */
|
|
41
|
+
observed: boolean;
|
|
42
|
+
/** Human-readable reason for logging / low-confidence attribution. */
|
|
43
|
+
summary: string;
|
|
44
|
+
}
|
|
30
45
|
export declare class ActionVerifier {
|
|
31
46
|
private beforeTree;
|
|
32
47
|
private beforeUrl;
|
|
48
|
+
/** F2: pre-action signal for SET_THEME (style) / SET_LOCALE (text). */
|
|
49
|
+
private themeLocaleBaseline;
|
|
33
50
|
captureBeforeState(adapter: RuntimeAdapter): Promise<void>;
|
|
34
51
|
verifyAfterAction(adapter: RuntimeAdapter): Promise<ActionVerification>;
|
|
52
|
+
/**
|
|
53
|
+
* F2: capture the pre-action signal for a SET_THEME / SET_LOCALE effect probe.
|
|
54
|
+
* `theme` reads aggregate visible node styling (background/foreground colors);
|
|
55
|
+
* `locale` reads aggregate visible text. Best-effort: a failed AKTree read
|
|
56
|
+
* leaves the baseline null, and `verifyThemeLocaleEffect` then reports the
|
|
57
|
+
* effect unverifiable (low-confidence) rather than asserting success.
|
|
58
|
+
*/
|
|
59
|
+
captureThemeLocaleBaseline(adapter: RuntimeAdapter, kind: 'theme' | 'locale'): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* F2: compare the post-action signal against the baseline captured by
|
|
62
|
+
* `captureThemeLocaleBaseline`. Returns `observed:true` only when a real,
|
|
63
|
+
* deterministic change is seen. A missing baseline, an unreadable after-tree,
|
|
64
|
+
* or an unchanged signal all return `observed:false` so the caller flags the
|
|
65
|
+
* capture low-confidence. NEVER throws and NEVER signals a hard failure.
|
|
66
|
+
*/
|
|
67
|
+
verifyThemeLocaleEffect(adapter: RuntimeAdapter, kind: 'theme' | 'locale'): Promise<ThemeLocaleEffectResult>;
|
|
35
68
|
}
|
package/dist/action-verifier.js
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
* Post-action validation: diff AKTree before/after an action
|
|
5
5
|
* to detect whether the action had a real effect or was intercepted.
|
|
6
6
|
*/
|
|
7
|
-
/**
|
|
8
|
-
* Captures AKTree state before and after an action to detect changes.
|
|
9
|
-
*/
|
|
10
7
|
export class ActionVerifier {
|
|
11
8
|
beforeTree = null;
|
|
12
9
|
beforeUrl = null;
|
|
10
|
+
/** F2: pre-action signal for SET_THEME (style) / SET_LOCALE (text). */
|
|
11
|
+
themeLocaleBaseline = null;
|
|
13
12
|
async captureBeforeState(adapter) {
|
|
14
13
|
try {
|
|
15
14
|
this.beforeTree = await adapter.getAKTree();
|
|
@@ -25,11 +24,19 @@ export class ActionVerifier {
|
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
26
|
async verifyAfterAction(adapter) {
|
|
27
|
+
// B2: a transient AKTree read failure during captureBeforeState left us with
|
|
28
|
+
// no baseline to diff against, so the effect cannot be verified. We assume
|
|
29
|
+
// the action had an effect (the action itself already returned success), but
|
|
30
|
+
// — like the after-illisible path below — flag it low-confidence so a no-op
|
|
31
|
+
// action on a flaky baseline read can't pass the effect gate as a clean
|
|
32
|
+
// success. We do NOT recapture the baseline here: the action has already run,
|
|
33
|
+
// so a fresh "before" would equal "after" and spuriously report no effect.
|
|
28
34
|
if (!this.beforeTree || !this.beforeUrl) {
|
|
29
35
|
return {
|
|
30
36
|
hadEffect: true, // assume effect if no before state
|
|
31
37
|
changes: [],
|
|
32
|
-
summary: 'no before state captured, assuming action had effect',
|
|
38
|
+
summary: 'no before state captured, assuming action had effect (low-confidence)',
|
|
39
|
+
lowConfidence: true,
|
|
33
40
|
};
|
|
34
41
|
}
|
|
35
42
|
// AUT-240 (decision 2): "assume OK, but smart". A first `page.evaluate`
|
|
@@ -155,6 +162,50 @@ export class ActionVerifier {
|
|
|
155
162
|
this.beforeUrl = null;
|
|
156
163
|
return { hadEffect, changes, summary };
|
|
157
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* F2: capture the pre-action signal for a SET_THEME / SET_LOCALE effect probe.
|
|
167
|
+
* `theme` reads aggregate visible node styling (background/foreground colors);
|
|
168
|
+
* `locale` reads aggregate visible text. Best-effort: a failed AKTree read
|
|
169
|
+
* leaves the baseline null, and `verifyThemeLocaleEffect` then reports the
|
|
170
|
+
* effect unverifiable (low-confidence) rather than asserting success.
|
|
171
|
+
*/
|
|
172
|
+
async captureThemeLocaleBaseline(adapter, kind) {
|
|
173
|
+
try {
|
|
174
|
+
const tree = await adapter.getAKTree();
|
|
175
|
+
this.themeLocaleBaseline = kind === 'theme'
|
|
176
|
+
? collectStyleSignature(tree.root)
|
|
177
|
+
: collectTextSignature(tree.root);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
this.themeLocaleBaseline = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* F2: compare the post-action signal against the baseline captured by
|
|
185
|
+
* `captureThemeLocaleBaseline`. Returns `observed:true` only when a real,
|
|
186
|
+
* deterministic change is seen. A missing baseline, an unreadable after-tree,
|
|
187
|
+
* or an unchanged signal all return `observed:false` so the caller flags the
|
|
188
|
+
* capture low-confidence. NEVER throws and NEVER signals a hard failure.
|
|
189
|
+
*/
|
|
190
|
+
async verifyThemeLocaleEffect(adapter, kind) {
|
|
191
|
+
const baseline = this.themeLocaleBaseline;
|
|
192
|
+
this.themeLocaleBaseline = null;
|
|
193
|
+
if (baseline === null) {
|
|
194
|
+
return { observed: false, summary: `${kind} baseline unavailable; effect unverified` };
|
|
195
|
+
}
|
|
196
|
+
let after;
|
|
197
|
+
try {
|
|
198
|
+
const tree = await adapter.getAKTree();
|
|
199
|
+
after = kind === 'theme' ? collectStyleSignature(tree.root) : collectTextSignature(tree.root);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { observed: false, summary: `${kind} after-state unreadable; effect unverified` };
|
|
203
|
+
}
|
|
204
|
+
if (after !== baseline) {
|
|
205
|
+
return { observed: true, summary: `${kind} applied (observable ${kind === 'theme' ? 'styling' : 'text'} change)` };
|
|
206
|
+
}
|
|
207
|
+
return { observed: false, summary: `no observable ${kind === 'theme' ? 'styling' : 'text'} change; app may ignore the requested ${kind}` };
|
|
208
|
+
}
|
|
158
209
|
}
|
|
159
210
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
160
211
|
function countVisibleNodes(node) {
|
|
@@ -164,6 +215,54 @@ function countVisibleNodes(node) {
|
|
|
164
215
|
}
|
|
165
216
|
return count;
|
|
166
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* F2: aggregate the visible node styling (background/foreground colors) into a
|
|
220
|
+
* stable signature. A real theme flip changes computed colors across the tree;
|
|
221
|
+
* an app that ignores the requested theme leaves the signature identical.
|
|
222
|
+
* Bounded so a huge tree can't blow the string up.
|
|
223
|
+
*/
|
|
224
|
+
function collectStyleSignature(root) {
|
|
225
|
+
const parts = [];
|
|
226
|
+
function walk(node) {
|
|
227
|
+
if (node.visible && (node.style.bgColor || node.style.fgColor)) {
|
|
228
|
+
parts.push(`${node.style.bgColor ?? ''}/${node.style.fgColor ?? ''}`);
|
|
229
|
+
if (parts.length >= 400)
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (const child of node.children) {
|
|
233
|
+
if (parts.length >= 400)
|
|
234
|
+
return;
|
|
235
|
+
walk(child);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
walk(root);
|
|
239
|
+
return parts.join('|');
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* F2: aggregate the visible node text (label / own text) into a stable
|
|
243
|
+
* signature. A real locale switch retranslates visible copy; an app that
|
|
244
|
+
* ignores the requested locale leaves the signature identical. Bounded.
|
|
245
|
+
*/
|
|
246
|
+
function collectTextSignature(root) {
|
|
247
|
+
const parts = [];
|
|
248
|
+
function walk(node) {
|
|
249
|
+
if (node.visible) {
|
|
250
|
+
const text = (node.attributes.__ownText ?? node.label ?? '').trim();
|
|
251
|
+
if (text) {
|
|
252
|
+
parts.push(text);
|
|
253
|
+
if (parts.length >= 400)
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const child of node.children) {
|
|
258
|
+
if (parts.length >= 400)
|
|
259
|
+
return;
|
|
260
|
+
walk(child);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
walk(root);
|
|
264
|
+
return parts.join('');
|
|
265
|
+
}
|
|
167
266
|
function collectVisibleInteractiveIds(node) {
|
|
168
267
|
const ids = [];
|
|
169
268
|
function walk(n) {
|
package/dist/browser.js
CHANGED
|
@@ -2828,6 +2828,52 @@ export class Browser {
|
|
|
2828
2828
|
}
|
|
2829
2829
|
return attrs;
|
|
2830
2830
|
};
|
|
2831
|
+
// A node "blocks interaction" only when it actually intercepts clicks into
|
|
2832
|
+
// the page content — i.e. it (or one of its descendants) sits at the
|
|
2833
|
+
// viewport centre under a real hit-test AND it covers a meaningful portion
|
|
2834
|
+
// of the viewport. `pointerEvents !== 'none'` alone is true for almost the
|
|
2835
|
+
// entire DOM, so it would flag ordinary fixed chrome (navbar, sidebar,
|
|
2836
|
+
// sticky CTA) as a blocking overlay. We additionally exclude that chrome by
|
|
2837
|
+
// role/landmark and by shape (thin top strip, thin side rail).
|
|
2838
|
+
const computeBlocksInteraction = (el, style, rect, coverage) => {
|
|
2839
|
+
if (style.pointerEvents === 'none')
|
|
2840
|
+
return false;
|
|
2841
|
+
// Ordinary page chrome is not a blocking overlay.
|
|
2842
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
2843
|
+
const tag = el.tagName.toUpperCase();
|
|
2844
|
+
if (['NAV', 'HEADER', 'FOOTER', 'ASIDE'].includes(tag))
|
|
2845
|
+
return false;
|
|
2846
|
+
if (['navigation', 'banner', 'contentinfo', 'complementary', 'toolbar'].includes(role))
|
|
2847
|
+
return false;
|
|
2848
|
+
if (el.closest('nav, header, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"], [role="complementary"]')) {
|
|
2849
|
+
return false;
|
|
2850
|
+
}
|
|
2851
|
+
const vw = window.innerWidth;
|
|
2852
|
+
const vh = window.innerHeight;
|
|
2853
|
+
if (vw <= 0 || vh <= 0)
|
|
2854
|
+
return false;
|
|
2855
|
+
// Thin top strip (navbar) or thin side rail (sidebar): not a blocker.
|
|
2856
|
+
const coversWidth = rect.width >= vw * 0.6;
|
|
2857
|
+
const coversHeight = rect.height >= vh * 0.6;
|
|
2858
|
+
const isTopStrip = rect.top <= 4 && rect.height <= vh * 0.25;
|
|
2859
|
+
const isSideRail = rect.left <= 4 && rect.width <= vw * 0.35 && coversHeight;
|
|
2860
|
+
if (isTopStrip || isSideRail)
|
|
2861
|
+
return false;
|
|
2862
|
+
// Must cover a meaningful portion of the viewport AND span enough of one
|
|
2863
|
+
// axis to plausibly trap the user (a small sticky CTA does neither).
|
|
2864
|
+
if (coverage < 25)
|
|
2865
|
+
return false;
|
|
2866
|
+
if (!coversWidth && !coversHeight)
|
|
2867
|
+
return false;
|
|
2868
|
+
// Real hit-test: does this node (or a descendant) actually sit under the
|
|
2869
|
+
// viewport centre? If the main content shows through, it isn't blocking.
|
|
2870
|
+
const cx = Math.round(vw / 2);
|
|
2871
|
+
const cy = Math.round(vh / 2);
|
|
2872
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2873
|
+
if (!hit)
|
|
2874
|
+
return false;
|
|
2875
|
+
return hit === el || el.contains(hit);
|
|
2876
|
+
};
|
|
2831
2877
|
const parseNode = (element) => {
|
|
2832
2878
|
if (!(element instanceof HTMLElement))
|
|
2833
2879
|
return null;
|
|
@@ -2873,7 +2919,7 @@ export class Browser {
|
|
|
2873
2919
|
attributes.__hasBorder = visualChrome.border ? 'true' : 'false';
|
|
2874
2920
|
attributes.__hasShadow = visualChrome.shadow ? 'true' : 'false';
|
|
2875
2921
|
attributes.__coveragePercent = String(Math.round(coveragePercent));
|
|
2876
|
-
attributes.__blocksInteraction = style
|
|
2922
|
+
attributes.__blocksInteraction = computeBlocksInteraction(element, style, rect, coveragePercent) ? 'true' : 'false';
|
|
2877
2923
|
return {
|
|
2878
2924
|
tagName: element.tagName,
|
|
2879
2925
|
role: element.getAttribute('role'),
|
|
@@ -3116,15 +3162,20 @@ export class Browser {
|
|
|
3116
3162
|
}
|
|
3117
3163
|
return { changed: false, value: input };
|
|
3118
3164
|
};
|
|
3119
|
-
let nextValue
|
|
3165
|
+
let nextValue;
|
|
3120
3166
|
try {
|
|
3121
3167
|
const parsed = JSON.parse(current);
|
|
3122
3168
|
const rewritten = rewrite(parsed);
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3169
|
+
// The current value is structured JSON. Only write back if a whitelisted
|
|
3170
|
+
// locale/theme field was actually rewritten — never clobber a structured
|
|
3171
|
+
// object with the bare candidate string (that destroys unrelated data and
|
|
3172
|
+
// falsely reports success). No writable key found → report no-op.
|
|
3173
|
+
if (!rewritten.changed)
|
|
3174
|
+
return false;
|
|
3175
|
+
nextValue = JSON.stringify(rewritten.value);
|
|
3126
3176
|
}
|
|
3127
3177
|
catch {
|
|
3178
|
+
// Current value is a bare (non-JSON) string: replace it wholesale.
|
|
3128
3179
|
nextValue = candidate;
|
|
3129
3180
|
}
|
|
3130
3181
|
storage.setItem(key, nextValue);
|
|
@@ -4465,7 +4516,21 @@ export class Browser {
|
|
|
4465
4516
|
async resolveActivationTarget(selector) {
|
|
4466
4517
|
const page = this.ensurePage();
|
|
4467
4518
|
try {
|
|
4468
|
-
|
|
4519
|
+
// An explicit selector that matches exactly one element is a deliberate
|
|
4520
|
+
// capture target (the generator chose `#id` / `[data-testid]` / a class
|
|
4521
|
+
// pointing at this specific node). Treat it like `data-ak`: honor the
|
|
4522
|
+
// matched node, never dive into a descendant control. The descendant dive
|
|
4523
|
+
// is reserved for selector-less / index resolution where `start` is just a
|
|
4524
|
+
// structural anchor and the real control may be nested inside it.
|
|
4525
|
+
const explicitUnique = await page.evaluate((sel) => {
|
|
4526
|
+
try {
|
|
4527
|
+
return document.querySelectorAll(sel).length === 1;
|
|
4528
|
+
}
|
|
4529
|
+
catch {
|
|
4530
|
+
return false;
|
|
4531
|
+
}
|
|
4532
|
+
}, selector);
|
|
4533
|
+
return await page.locator(selector).first().evaluate((node, allowDescendantDive) => {
|
|
4469
4534
|
const ACTIONABLE_SELECTOR = [
|
|
4470
4535
|
'button',
|
|
4471
4536
|
'a[href]',
|
|
@@ -4498,6 +4563,20 @@ export class Browser {
|
|
|
4498
4563
|
return ['button', 'link', 'menuitem', 'option', 'radio', 'switch', 'tab'].includes(role);
|
|
4499
4564
|
};
|
|
4500
4565
|
const findActivationNode = (start) => {
|
|
4566
|
+
// An element carrying `data-ak`/`data-ak-interact` is an EXPLICIT
|
|
4567
|
+
// capture target the generator deliberately tagged to be clicked.
|
|
4568
|
+
// Click it directly and let native DOM bubbling (and label forwarding)
|
|
4569
|
+
// do the rest. Never walk up to a different control or dive into the
|
|
4570
|
+
// first actionable DESCENDANT — that hijacks the click, e.g. a clickable
|
|
4571
|
+
// `<div data-ak onClick>` card whose first inner button opens a menu and
|
|
4572
|
+
// stops propagation, so the card's own navigation never runs.
|
|
4573
|
+
if (start instanceof HTMLElement
|
|
4574
|
+
&& (start.hasAttribute('data-ak') || start.hasAttribute('data-ak-interact'))) {
|
|
4575
|
+
if (start instanceof HTMLLabelElement && start.control instanceof HTMLElement) {
|
|
4576
|
+
return start.control;
|
|
4577
|
+
}
|
|
4578
|
+
return start;
|
|
4579
|
+
}
|
|
4501
4580
|
let current = start;
|
|
4502
4581
|
while (current) {
|
|
4503
4582
|
if (current instanceof HTMLLabelElement) {
|
|
@@ -4513,9 +4592,16 @@ export class Browser {
|
|
|
4513
4592
|
if (labelAncestor instanceof HTMLLabelElement) {
|
|
4514
4593
|
return labelAncestor.control instanceof HTMLElement ? labelAncestor.control : labelAncestor;
|
|
4515
4594
|
}
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4595
|
+
// Only dive into the first actionable DESCENDANT for selector-less /
|
|
4596
|
+
// index resolution, where `start` is a structural anchor and the real
|
|
4597
|
+
// control is nested inside it. For an explicit, uniquely-matched
|
|
4598
|
+
// selector (or `data-ak`), `start` IS the intended target — diving
|
|
4599
|
+
// would hijack the click into the wrong child control.
|
|
4600
|
+
if (allowDescendantDive) {
|
|
4601
|
+
const nested = start.querySelector(ACTIONABLE_SELECTOR);
|
|
4602
|
+
if (nested instanceof HTMLElement)
|
|
4603
|
+
return nested;
|
|
4604
|
+
}
|
|
4519
4605
|
return start;
|
|
4520
4606
|
};
|
|
4521
4607
|
const target = findActivationNode(node);
|
|
@@ -4538,7 +4624,7 @@ export class Browser {
|
|
|
4538
4624
|
role: target.getAttribute('role') || '',
|
|
4539
4625
|
inputType: target instanceof HTMLInputElement ? target.type : target.getAttribute('type'),
|
|
4540
4626
|
};
|
|
4541
|
-
});
|
|
4627
|
+
}, !explicitUnique);
|
|
4542
4628
|
}
|
|
4543
4629
|
catch {
|
|
4544
4630
|
return null;
|
package/dist/cli-runner.js
CHANGED
|
@@ -238,22 +238,39 @@ export async function runCapture(options) {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
let interrupted = false;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
// Memoize the terminal notification so the eager call from the signal handler
|
|
242
|
+
// and the finally-block call share ONE in-flight PATCH: the handler fires it
|
|
243
|
+
// the instant Ctrl-C lands, and every awaiter (the finally, a second Ctrl-C)
|
|
244
|
+
// waits on that same promise instead of racing, re-sending, or dropping it.
|
|
245
|
+
// postRunAborted never rejects (it swallows its own errors), so the memoized
|
|
246
|
+
// promise always settles — `void`-ing it can't leak an unhandled rejection.
|
|
247
|
+
let abortNotifyPromise = null;
|
|
248
|
+
const notifyAborted = () => {
|
|
249
|
+
if (!isLocalRegisteredRun)
|
|
250
|
+
return Promise.resolve();
|
|
251
|
+
if (!abortNotifyPromise) {
|
|
252
|
+
abortNotifyPromise = postRunAborted(config, runId, program.presetId, options.env);
|
|
253
|
+
}
|
|
254
|
+
return abortNotifyPromise;
|
|
247
255
|
};
|
|
248
256
|
const onInterrupt = (signal) => {
|
|
249
257
|
if (interrupted) {
|
|
250
|
-
// Second interrupt: the user wants out now.
|
|
251
|
-
//
|
|
252
|
-
|
|
258
|
+
// Second interrupt: the user wants out now. Exit with the conventional
|
|
259
|
+
// 128+SIGINT(2) code, but only AFTER the abort notification (fired on the
|
|
260
|
+
// first press) settles. Force-quitting before the PATCH lands is exactly
|
|
261
|
+
// what used to leave the preset stuck "capturing" until the 15-min cutoff.
|
|
262
|
+
void notifyAborted().finally(() => process.exit(130));
|
|
263
|
+
return;
|
|
253
264
|
}
|
|
254
265
|
interrupted = true;
|
|
255
266
|
logger.warn(`[capture] ${signal} received — stopping the run and notifying AutoKap…`);
|
|
256
267
|
abortController.abort(new Error('User interrupted (Ctrl-C)'));
|
|
268
|
+
// Tell the server NOW, not from the finally block. The in-flight opcode is
|
|
269
|
+
// not interruptible (the abort signal is only checked between opcodes), so
|
|
270
|
+
// executeProgram can take many seconds to unwind — and a second Ctrl-C
|
|
271
|
+
// during that window force-quits before the finally runs. Firing here clears
|
|
272
|
+
// the "capturing" badge even if the process never reaches a clean exit.
|
|
273
|
+
void notifyAborted();
|
|
257
274
|
};
|
|
258
275
|
if (isLocalRegisteredRun) {
|
|
259
276
|
process.on('SIGINT', onInterrupt);
|
package/dist/cookie-dismiss.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { Page } from 'playwright';
|
|
2
|
+
/**
|
|
3
|
+
* Decides whether a container's attributes identify it as a real cookie /
|
|
4
|
+
* consent / privacy surface. Used by Strategy 2 to avoid clicking ordinary CTAs
|
|
5
|
+
* (Continue / OK / Got it) inside unrelated containers.
|
|
6
|
+
*
|
|
7
|
+
* Exported for testing. The predicate is also stringified and injected into the
|
|
8
|
+
* in-browser `page.evaluate` walker so there is a single source of truth.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isCookieConsentContainer(combinedAttrs: string, role: string): boolean;
|
|
2
11
|
export declare const CAPTURE_HIDE_STYLE_ID = "autokap-capture-hide-style";
|
|
3
12
|
export declare function getCaptureHideCSS(): string;
|
|
4
13
|
export declare function ensureCaptureHideStyles(page: Page): Promise<void>;
|
package/dist/cookie-dismiss.js
CHANGED
|
@@ -111,6 +111,25 @@ const DEV_TOOL_SELECTORS = [
|
|
|
111
111
|
'[data-replit-devtools]',
|
|
112
112
|
'#replit-devtools',
|
|
113
113
|
];
|
|
114
|
+
/**
|
|
115
|
+
* Decides whether a container's attributes identify it as a real cookie /
|
|
116
|
+
* consent / privacy surface. Used by Strategy 2 to avoid clicking ordinary CTAs
|
|
117
|
+
* (Continue / OK / Got it) inside unrelated containers.
|
|
118
|
+
*
|
|
119
|
+
* Exported for testing. The predicate is also stringified and injected into the
|
|
120
|
+
* in-browser `page.evaluate` walker so there is a single source of truth.
|
|
121
|
+
*/
|
|
122
|
+
export function isCookieConsentContainer(combinedAttrs, role) {
|
|
123
|
+
return (combinedAttrs.includes('cookie') ||
|
|
124
|
+
combinedAttrs.includes('consent') ||
|
|
125
|
+
combinedAttrs.includes('gdpr') ||
|
|
126
|
+
combinedAttrs.includes('privacy') ||
|
|
127
|
+
// Only treat dialogs as cookie surfaces when they carry a real cookie/consent
|
|
128
|
+
// signal. A bare "banner" token, or the previous "co" substring (which matched
|
|
129
|
+
// confirm/account/connect/contact...), wrongly hijacked ordinary app dialogs.
|
|
130
|
+
((role === 'dialog' || role === 'alertdialog') &&
|
|
131
|
+
(combinedAttrs.includes('cookie') || combinedAttrs.includes('consent'))));
|
|
132
|
+
}
|
|
114
133
|
export const CAPTURE_HIDE_STYLE_ID = 'autokap-capture-hide-style';
|
|
115
134
|
export function getCaptureHideCSS() {
|
|
116
135
|
return [...HIDE_SELECTORS, ...DEV_TOOL_SELECTORS].map(selector => `${selector} { display: none !important; visibility: hidden !important; pointer-events: none !important; }`).join('\n');
|
|
@@ -158,11 +177,15 @@ export async function dismissCookiesAndWidgets(page) {
|
|
|
158
177
|
}
|
|
159
178
|
// Strategy 2: Find accept buttons by text in cookie-like containers
|
|
160
179
|
try {
|
|
161
|
-
const dismissed = await page.evaluate((patterns) => {
|
|
180
|
+
const dismissed = await page.evaluate(({ patterns, predicateSource }) => {
|
|
162
181
|
const regexPatterns = patterns.map(p => {
|
|
163
182
|
const match = p.match(/^\/(.*)\/([gimsuy]*)$/);
|
|
164
183
|
return match ? new RegExp(match[1], match[2]) : new RegExp(p, 'i');
|
|
165
184
|
});
|
|
185
|
+
// Reconstruct the container predicate from its stringified source so the
|
|
186
|
+
// in-browser walker and the unit tests share a single source of truth.
|
|
187
|
+
// eslint-disable-next-line no-new-func
|
|
188
|
+
const isCookieConsentContainer = new Function(`return (${predicateSource})`)();
|
|
166
189
|
// Find all visible buttons and links
|
|
167
190
|
const candidates = document.querySelectorAll('button, a, [role="button"], input[type="submit"]');
|
|
168
191
|
for (const el of candidates) {
|
|
@@ -186,22 +209,32 @@ export async function dismissCookiesAndWidgets(page) {
|
|
|
186
209
|
const role = (parent.getAttribute('role') || '').toLowerCase();
|
|
187
210
|
const ariaLabel = (parent.getAttribute('aria-label') || '').toLowerCase();
|
|
188
211
|
const combinedAttrs = `${id} ${className} ${ariaLabel}`;
|
|
189
|
-
if (combinedAttrs
|
|
190
|
-
combinedAttrs.includes('gdpr') || combinedAttrs.includes('privacy') ||
|
|
191
|
-
combinedAttrs.includes('banner') ||
|
|
192
|
-
((role === 'dialog' || role === 'alertdialog') && combinedAttrs.includes('co'))) {
|
|
212
|
+
if (isCookieConsentContainer(combinedAttrs, role)) {
|
|
193
213
|
isCookieContainer = true;
|
|
194
214
|
break;
|
|
195
215
|
}
|
|
196
216
|
parent = parent.parentElement;
|
|
197
217
|
}
|
|
198
|
-
if (isCookieContainer)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
218
|
+
if (!isCookieContainer)
|
|
219
|
+
continue;
|
|
220
|
+
// Only click a candidate that is actually interactable. Mirrors the
|
|
221
|
+
// isVisible() guard on the CMP-selector path — an invisible, aria-hidden
|
|
222
|
+
// or disabled match must be skipped, not clicked, so a hidden cookie node
|
|
223
|
+
// can't trigger a parasitic click on the live page.
|
|
224
|
+
const isVisible = htmlEl.offsetParent !== null || htmlEl.getClientRects().length > 0;
|
|
225
|
+
const isHidden = htmlEl.getAttribute('aria-hidden') === 'true';
|
|
226
|
+
const isDisabled = htmlEl.disabled === true ||
|
|
227
|
+
htmlEl.getAttribute('aria-disabled') === 'true';
|
|
228
|
+
if (!isVisible || isHidden || isDisabled)
|
|
229
|
+
continue;
|
|
230
|
+
htmlEl.click();
|
|
231
|
+
return text;
|
|
202
232
|
}
|
|
203
233
|
return null;
|
|
204
|
-
},
|
|
234
|
+
}, {
|
|
235
|
+
patterns: ACCEPT_PATTERNS.map(rx => rx.toString()),
|
|
236
|
+
predicateSource: isCookieConsentContainer.toString(),
|
|
237
|
+
});
|
|
205
238
|
if (dismissed) {
|
|
206
239
|
logger.info(`Cookie dismissed via text match: "${dismissed}"`);
|
|
207
240
|
await page.waitForTimeout(500);
|
package/dist/opcode-runner.js
CHANGED
|
@@ -103,12 +103,58 @@ function getOpcodeActionEffectPolicy(opcode) {
|
|
|
103
103
|
}
|
|
104
104
|
return { captureBefore: true, requireEffect: true, noEffectMode: 'fail' };
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* F2: maps SET_THEME / SET_LOCALE to the effect-probe kind so the runner reads
|
|
108
|
+
* back an observable signal (node styling for theme, visible text for locale)
|
|
109
|
+
* after the action. Returns undefined for every other opcode.
|
|
110
|
+
*/
|
|
111
|
+
function getThemeLocaleEffectKind(opcode) {
|
|
112
|
+
if (opcode.kind === 'SET_THEME')
|
|
113
|
+
return 'theme';
|
|
114
|
+
if (opcode.kind === 'SET_LOCALE')
|
|
115
|
+
return 'locale';
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
106
118
|
function resolveRuntimePostcondition(opcode) {
|
|
107
|
-
|
|
119
|
+
// ASSERT opcodes carry their source of truth on the opcode itself (urlPattern /
|
|
120
|
+
// selectors+matchAll), NOT in `postcondition` (validated to `always` or an
|
|
121
|
+
// equivalent spec). On the MAIN path the assertion runs inside
|
|
122
|
+
// `executeOpcodeAction`, so this postcondition is informational. On the
|
|
123
|
+
// RECOVERY recheck path the recovery replays a no-op for ASSERT, so this is
|
|
124
|
+
// the ONLY validation left — it must re-check the real source of truth, not
|
|
125
|
+
// vacuously pass (B1). For the multi-selector matchAll case a single
|
|
126
|
+
// `PostconditionSpec` cannot express it; the recovery path uses
|
|
127
|
+
// `evaluateAssertionRecheck` instead and never reaches this branch.
|
|
128
|
+
if (opcode.kind === 'ASSERT_ROUTE') {
|
|
129
|
+
return { type: 'route_matches', pattern: opcode.urlPattern };
|
|
130
|
+
}
|
|
131
|
+
if (opcode.kind === 'ASSERT_SURFACE') {
|
|
132
|
+
if (opcode.selectors.length === 1) {
|
|
133
|
+
return { type: 'element_visible', selector: opcode.selectors[0] };
|
|
134
|
+
}
|
|
108
135
|
return { type: 'always' };
|
|
109
136
|
}
|
|
110
137
|
return opcode.postcondition;
|
|
111
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Re-runs the real ASSERT source-of-truth check (used on the recovery recheck
|
|
141
|
+
* path, where the recovery replay no-ops the assertion). Mirrors the main-path
|
|
142
|
+
* assertion in `executeOpcodeAction`, including ASSERT_SURFACE matchAll
|
|
143
|
+
* semantics. Returns a `PostconditionResult` so the recovery path treats it like
|
|
144
|
+
* any other postcondition recheck. B1: prevents a failed ASSERT (e.g. redirect
|
|
145
|
+
* to /login) from being reported as `recovered`.
|
|
146
|
+
*/
|
|
147
|
+
async function evaluateAssertionRecheck(adapter, opcode) {
|
|
148
|
+
if (opcode.kind === 'ASSERT_ROUTE') {
|
|
149
|
+
// Immediate re-check (waitMs: 1) — mirrors the main-path assertion. The
|
|
150
|
+
// recovery replay no-ops the assertion, so there is nothing to poll for.
|
|
151
|
+
return evaluatePostcondition(adapter, { type: 'route_matches', pattern: opcode.urlPattern, waitMs: 1 });
|
|
152
|
+
}
|
|
153
|
+
const result = await evaluateSurfaceAssertion(adapter, opcode.selectors, opcode.matchAll);
|
|
154
|
+
return result.success
|
|
155
|
+
? { passed: true, reason: 'ASSERT_SURFACE selectors satisfied' }
|
|
156
|
+
: { passed: false, reason: result.error ?? 'ASSERT_SURFACE failed' };
|
|
157
|
+
}
|
|
112
158
|
/** Mark the variant low-confidence once (keeps the first reason). */
|
|
113
159
|
function recordLowConfidence(state, reason) {
|
|
114
160
|
if (state.lowConfidence)
|
|
@@ -431,6 +477,14 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
431
477
|
await verifier.captureBeforeState(adapter);
|
|
432
478
|
logger.debug(`[opcode ${index}] captureBeforeState took ${Date.now() - beforeStart}ms`);
|
|
433
479
|
}
|
|
480
|
+
// F2: snapshot the theme/locale effect baseline before the action runs so we
|
|
481
|
+
// can later confirm the app actually reacted (an app that themes via
|
|
482
|
+
// class/data-theme/storage ignores prefers-color-scheme and would otherwise
|
|
483
|
+
// record a wrong-theme screenshot as clean success).
|
|
484
|
+
const themeLocaleKind = getThemeLocaleEffectKind(opcode);
|
|
485
|
+
if (themeLocaleKind) {
|
|
486
|
+
await verifier.captureThemeLocaleBaseline(adapter, themeLocaleKind);
|
|
487
|
+
}
|
|
434
488
|
// `WAIT_FOR` is a pure wait: it extends while the page is progressing, up to
|
|
435
489
|
// the global deadline. Artifact-producing capture opcodes (CAPTURE_SCREENSHOT,
|
|
436
490
|
// END_CLIP) also run against the global deadline, NOT the narrow compiled
|
|
@@ -542,6 +596,19 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
542
596
|
}
|
|
543
597
|
}
|
|
544
598
|
}
|
|
599
|
+
// F2: confirm SET_THEME / SET_LOCALE actually took effect on the live page.
|
|
600
|
+
// Conservative by design — a missing or unchanged signal only flags the
|
|
601
|
+
// capture low-confidence (so post-capture verification scrutinizes a possibly
|
|
602
|
+
// wrong-theme/locale screenshot); it NEVER hard-fails these opcodes.
|
|
603
|
+
if (themeLocaleKind) {
|
|
604
|
+
const effect = await verifier.verifyThemeLocaleEffect(adapter, themeLocaleKind);
|
|
605
|
+
if (!effect.observed) {
|
|
606
|
+
recordLowConfidence(executionState, `${opcode.kind} effect unverified: ${effect.summary}`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
logger.debug(`[opcode ${index}] ${opcode.kind} effect confirmed: ${effect.summary}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
545
612
|
// Record successful mock data application
|
|
546
613
|
if (opcode.kind === 'INJECT_MOCK_DATA') {
|
|
547
614
|
if (!telemetry.mockDataGroupResults)
|
|
@@ -689,8 +756,14 @@ async function handleFailure(opcode, index, adapter, verifier, breaker, recovery
|
|
|
689
756
|
error: `${errorMsg} (recovery succeeded but postcondition could not be rechecked before timeout after ${effectiveTimeoutMs}ms)`,
|
|
690
757
|
};
|
|
691
758
|
}
|
|
759
|
+
// B1: ASSERT opcodes no-op on the recovery replay, so this recheck is the
|
|
760
|
+
// only validation left. Re-run the real source-of-truth assertion (route /
|
|
761
|
+
// surface, incl. matchAll) instead of the vacuous `always` recheck that
|
|
762
|
+
// turned a failed assertion (e.g. redirect to /login) into `recovered`.
|
|
692
763
|
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
693
|
-
const postcondition =
|
|
764
|
+
const postcondition = opcode.kind === 'ASSERT_ROUTE' || opcode.kind === 'ASSERT_SURFACE'
|
|
765
|
+
? await evaluateAssertionRecheck(adapter, opcode)
|
|
766
|
+
: await evaluatePostconditionWithProgress(adapter, runtimePostcondition, Date.now(), globalDeadlineMs, getProgress);
|
|
694
767
|
if (postcondition.lowConfidence) {
|
|
695
768
|
recordLowConfidence(executionState, `postcondition ${runtimePostcondition.type}: ${postcondition.reason}`);
|
|
696
769
|
}
|