autokap 1.9.4 → 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 +86 -5
- 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
package/dist/overlay-engine.js
CHANGED
|
@@ -57,7 +57,13 @@ export async function dismissAllOverlays(adapter) {
|
|
|
57
57
|
catch {
|
|
58
58
|
// Non-fatal
|
|
59
59
|
}
|
|
60
|
-
// Pass 3: Try clicking common close patterns in AKTree
|
|
60
|
+
// Pass 3: Try clicking common close patterns in AKTree.
|
|
61
|
+
//
|
|
62
|
+
// Only hunt for a close button inside nodes that are GENUINELY overlay-like
|
|
63
|
+
// (modal/dialog/popup/toast surfaces) — not every entry in `tree.overlays`,
|
|
64
|
+
// which over-includes fixed/sticky high-z chrome (navbars, headers, sticky
|
|
65
|
+
// CTAs). Clicking a navbar control or an in-page "Skip to content" anchor is
|
|
66
|
+
// never the right move and silently scrolls the capture off-target.
|
|
61
67
|
const closePatterns = [
|
|
62
68
|
'close', 'dismiss', 'fermer', 'schließen', 'cerrar', 'chiudi',
|
|
63
69
|
'no thanks', 'non merci', 'maybe later', 'not now', 'skip',
|
|
@@ -65,16 +71,25 @@ export async function dismissAllOverlays(adapter) {
|
|
|
65
71
|
for (const overlay of tree.overlays) {
|
|
66
72
|
if (!overlay.blocksInteraction)
|
|
67
73
|
continue;
|
|
74
|
+
const overlayNode = findNodeById(tree.root, overlay.nodeId);
|
|
75
|
+
if (!overlayNode || !isGenuineOverlaySurface(overlayNode))
|
|
76
|
+
continue;
|
|
68
77
|
// Look for close buttons within the overlay's subtree
|
|
69
|
-
const closeNode = findCloseButton(
|
|
78
|
+
const closeNode = findCloseButton(overlayNode, closePatterns);
|
|
70
79
|
if (closeNode) {
|
|
71
80
|
try {
|
|
72
81
|
// Build selector from the close node's sourceRef
|
|
73
82
|
const selector = buildSelectorFromSourceRef(closeNode.sourceRef);
|
|
74
83
|
if (selector) {
|
|
75
|
-
|
|
84
|
+
// Mirror the cookie path's scroll-transparency: snapshot the page
|
|
85
|
+
// scroll, click via JS dispatch (Playwright `dispatchEvent` never
|
|
86
|
+
// auto-scrolls the target into view), then restore any drift so the
|
|
87
|
+
// capture stays on the section the program navigated to.
|
|
88
|
+
const beforeScroll = readPageScroll(tree);
|
|
89
|
+
await adapter.click(selector, { useJsDispatch: true });
|
|
76
90
|
methods.push(`close-button:${selector}`);
|
|
77
91
|
await sleep(300);
|
|
92
|
+
await restorePageScroll(adapter, beforeScroll);
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
95
|
catch {
|
|
@@ -106,20 +121,61 @@ async function readOverlaySnapshot(adapter) {
|
|
|
106
121
|
return null;
|
|
107
122
|
}
|
|
108
123
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
/**
|
|
125
|
+
* True when `node` is a genuine overlay surface (a modal/dialog/popup/toast)
|
|
126
|
+
* rather than fixed/sticky page chrome. Mirrors the live-DOM `isInsideOverlay`
|
|
127
|
+
* guard the cookie path uses, but reads the already-computed AKTree signals:
|
|
128
|
+
* node type, semantic pattern/traits, and dialog/modal attributes. A bare
|
|
129
|
+
* `fixed`/`sticky` trait is NOT enough — that matches navbars and headers.
|
|
130
|
+
*/
|
|
131
|
+
function isGenuineOverlaySurface(node) {
|
|
132
|
+
if (node.type === 'modal')
|
|
133
|
+
return true;
|
|
134
|
+
const pattern = node.semantic?.pattern;
|
|
135
|
+
if (pattern === 'modal' || pattern === 'cookie-banner' || pattern === 'dropdown' || pattern === 'toast') {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
const traits = node.semantic?.traits ?? [];
|
|
139
|
+
if (traits.includes('overlay'))
|
|
140
|
+
return true;
|
|
141
|
+
const attrs = node.attributes ?? {};
|
|
142
|
+
const role = (attrs.role || '').toLowerCase();
|
|
143
|
+
if (role === 'dialog' || role === 'alertdialog')
|
|
144
|
+
return true;
|
|
145
|
+
if (attrs['aria-modal'] === 'true')
|
|
146
|
+
return true;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* True when `node` is an in-page anchor "skip" link (e.g. "Skip to content",
|
|
151
|
+
* `<a href="#main">`). These are accessibility navigation aids, never overlay
|
|
152
|
+
* close buttons — clicking one jumps the page to a fragment and the capture
|
|
153
|
+
* ends up scrolled off-target.
|
|
154
|
+
*/
|
|
155
|
+
function isInPageAnchorLink(node) {
|
|
156
|
+
if (node.type !== 'link')
|
|
157
|
+
return false;
|
|
158
|
+
const ref = node.sourceRef.toLowerCase();
|
|
159
|
+
// `<a href="#...">` or a resolved selector carrying an in-page href.
|
|
160
|
+
const hrefMatch = ref.match(/href="([^"]*)"/);
|
|
161
|
+
if (hrefMatch) {
|
|
162
|
+
const href = hrefMatch[1].trim();
|
|
163
|
+
return href.startsWith('#') || href === '' || href === 'javascript:void(0)';
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
function findCloseButton(overlayNode, patterns) {
|
|
114
168
|
// Search for interactive close-like buttons within this subtree
|
|
115
169
|
let best = null;
|
|
116
170
|
function walk(node) {
|
|
117
|
-
if (node.visible && node.interactive) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
if (node.visible && node.interactive && !isInPageAnchorLink(node)) {
|
|
172
|
+
// Match on the accessible NAME (label + aria-label/title/value) and the
|
|
173
|
+
// explicit role/type — never the raw sourceRef. Matching the sourceRef
|
|
174
|
+
// pulled in unrelated controls whose markup merely contained "skip" or
|
|
175
|
+
// "close" (e.g. a `.skip-link` class on a content anchor).
|
|
176
|
+
const accessibleName = accessibleNameOf(node);
|
|
121
177
|
for (const pattern of patterns) {
|
|
122
|
-
if (
|
|
178
|
+
if (accessibleName.includes(pattern)) {
|
|
123
179
|
// Prefer buttons over links, and shorter labels over longer ones
|
|
124
180
|
if (!best || (node.type === 'button' && best.type !== 'button') || node.label.length < best.label.length) {
|
|
125
181
|
best = node;
|
|
@@ -127,8 +183,12 @@ function findCloseButton(tree, overlayNodeId, patterns) {
|
|
|
127
183
|
break;
|
|
128
184
|
}
|
|
129
185
|
}
|
|
130
|
-
// Also
|
|
131
|
-
|
|
186
|
+
// Also honour an explicit close affordance expressed via accessible
|
|
187
|
+
// attributes (aria-label/title="close"). These are unambiguous.
|
|
188
|
+
const attrs = node.attributes ?? {};
|
|
189
|
+
const ariaLabel = (attrs['aria-label'] || '').toLowerCase();
|
|
190
|
+
const title = (attrs.title || '').toLowerCase();
|
|
191
|
+
if (ariaLabel === 'close' || title === 'close') {
|
|
132
192
|
if (!best)
|
|
133
193
|
best = node;
|
|
134
194
|
}
|
|
@@ -138,6 +198,39 @@ function findCloseButton(tree, overlayNodeId, patterns) {
|
|
|
138
198
|
walk(overlayNode);
|
|
139
199
|
return best;
|
|
140
200
|
}
|
|
201
|
+
/** Accessible name = visible label plus aria-label / title / value text. */
|
|
202
|
+
function accessibleNameOf(node) {
|
|
203
|
+
const attrs = node.attributes ?? {};
|
|
204
|
+
return [node.label, attrs['aria-label'], attrs.title, attrs.value]
|
|
205
|
+
.filter((part) => typeof part === 'string' && part.length > 0)
|
|
206
|
+
.join(' ')
|
|
207
|
+
.toLowerCase();
|
|
208
|
+
}
|
|
209
|
+
function readPageScroll(tree) {
|
|
210
|
+
return {
|
|
211
|
+
top: tree.page?.scroll?.scrollTop ?? 0,
|
|
212
|
+
left: tree.page?.scroll?.scrollLeft ?? 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Restore the document scroll if the close click drifted the viewport. The
|
|
217
|
+
* close click uses JS dispatch (no Playwright auto-scroll), so this is a
|
|
218
|
+
* defensive belt-and-suspenders against focus-induced or fragment scroll. Uses
|
|
219
|
+
* the relative `scroll` adapter lever since no absolute setter is exposed.
|
|
220
|
+
*/
|
|
221
|
+
async function restorePageScroll(adapter, before) {
|
|
222
|
+
try {
|
|
223
|
+
const after = await adapter.getAKTree();
|
|
224
|
+
const top = after.page?.scroll?.scrollTop ?? 0;
|
|
225
|
+
const deltaY = top - before.top;
|
|
226
|
+
if (Math.abs(deltaY) >= 1) {
|
|
227
|
+
await adapter.scroll(deltaY > 0 ? 'up' : 'down', Math.abs(deltaY));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Scroll restore is best-effort; never fail the dismissal over it.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
141
234
|
function findNodeById(node, id) {
|
|
142
235
|
if (node.id === id)
|
|
143
236
|
return node;
|
package/dist/postcondition.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Deterministic evaluation of postconditions after each opcode.
|
|
5
5
|
* No LLM calls — purely structural checks against AKTree, URL, and screenshots.
|
|
6
6
|
*/
|
|
7
|
+
import sharp from 'sharp';
|
|
7
8
|
import { runWithProgressBudget } from './wait-contract.js';
|
|
8
9
|
export async function evaluatePostcondition(adapter, spec) {
|
|
9
10
|
const maxWait = spec.waitMs ?? 5000;
|
|
@@ -80,7 +81,15 @@ async function checkRouteMatches(adapter, pattern) {
|
|
|
80
81
|
const { pathname, search } = new URL(url);
|
|
81
82
|
const fullPath = pathname + search;
|
|
82
83
|
const regex = compileRoutePattern(pattern);
|
|
83
|
-
|
|
84
|
+
// Only patterns that explicitly mention the query string (`?`) are tested
|
|
85
|
+
// against pathname+search; bare patterns match the pathname ONLY. Otherwise
|
|
86
|
+
// the search concatenation lets `/dashboard` falsely "match"
|
|
87
|
+
// `/login?next=/dashboard`, defeating login-failure detection.
|
|
88
|
+
const matchesQuery = pattern.includes('?');
|
|
89
|
+
const matched = matchesQuery
|
|
90
|
+
? regex.test(fullPath) || regex.test(pathname)
|
|
91
|
+
: regex.test(pathname);
|
|
92
|
+
if (matched) {
|
|
84
93
|
return { passed: true, reason: `URL "${fullPath}" matches pattern "${pattern}"` };
|
|
85
94
|
}
|
|
86
95
|
return { passed: false, reason: `URL "${fullPath}" does not match pattern "${pattern}"` };
|
|
@@ -122,13 +131,24 @@ function compileRoutePattern(pattern) {
|
|
|
122
131
|
regexStr += ch;
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
|
-
// Substring (contains) match — NOT anchored. Generated programs
|
|
126
|
-
// patterns that are either a prefix of the real path (`/projects/`
|
|
127
|
-
// `/projects/<id>`) or a nested segment (`/tracking` ⊂
|
|
134
|
+
// Substring (contains) match — NOT anchored at the start. Generated programs
|
|
135
|
+
// author bare patterns that are either a prefix of the real path (`/projects/`
|
|
136
|
+
// ⊂ `/projects/<id>`) or a nested segment (`/tracking` ⊂
|
|
128
137
|
// `/projects/<id>/tracking`). An anchored `^…$` could match neither, which
|
|
129
138
|
// surfaced as a misleading "page stuck, no progress" failure after the
|
|
130
139
|
// navigation had actually succeeded. Callers needing strict matching pass an
|
|
131
140
|
// anchored regex (handled above).
|
|
141
|
+
//
|
|
142
|
+
// We DO anchor the *end* on a segment boundary so a bare `/home` no longer
|
|
143
|
+
// matches `/home-old` (and `/dashboard` no longer matches `/dashboard-v2`).
|
|
144
|
+
// Skip the terminator when the pattern already ends in a glob (`*`/`**`, which
|
|
145
|
+
// consume their own boundary) or a trailing `/` (an explicit prefix match like
|
|
146
|
+
// `/projects/` that should match `/projects/<id>`).
|
|
147
|
+
const endsInGlob = pattern.endsWith('*');
|
|
148
|
+
const endsInSlash = pattern.endsWith('/');
|
|
149
|
+
if (!endsInGlob && !endsInSlash) {
|
|
150
|
+
regexStr += '(?:/|$)';
|
|
151
|
+
}
|
|
132
152
|
return new RegExp(regexStr);
|
|
133
153
|
}
|
|
134
154
|
async function checkElementVisible(adapter, selector) {
|
|
@@ -250,14 +270,14 @@ async function checkScreenshotStable(adapter, threshold, context) {
|
|
|
250
270
|
context.lastScreenshot = screenshot;
|
|
251
271
|
await sleep(250);
|
|
252
272
|
const screenshot2 = await adapter.takeScreenshot();
|
|
253
|
-
const diff =
|
|
273
|
+
const diff = await pixelDiffRatio(screenshot, screenshot2);
|
|
254
274
|
context.lastScreenshot = screenshot2;
|
|
255
275
|
if (diff <= threshold) {
|
|
256
276
|
return { passed: true, reason: `consecutive screenshots stable (diff=${diff.toFixed(4)})` };
|
|
257
277
|
}
|
|
258
278
|
return { passed: false, reason: `consecutive screenshots differ (diff=${diff.toFixed(4)})` };
|
|
259
279
|
}
|
|
260
|
-
const diff =
|
|
280
|
+
const diff = await pixelDiffRatio(context.lastScreenshot, screenshot);
|
|
261
281
|
context.lastScreenshot = screenshot;
|
|
262
282
|
if (diff <= threshold) {
|
|
263
283
|
return { passed: true, reason: `screenshot stable (diff=${diff.toFixed(4)})` };
|
|
@@ -343,20 +363,57 @@ function matchesSelectorHeuristic(sourceRef, selector) {
|
|
|
343
363
|
// Fallback: contains
|
|
344
364
|
return lower.includes(selectorLower);
|
|
345
365
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
366
|
+
/** A pixel counts as changed only if a channel moves by more than this (out of 255). */
|
|
367
|
+
const PIXEL_CHANNEL_TOLERANCE = 8;
|
|
368
|
+
/**
|
|
369
|
+
* Real normalized per-pixel diff ratio between two screenshots.
|
|
370
|
+
*
|
|
371
|
+
* The previous implementation byte-diffed the *compressed* PNG buffers, where a
|
|
372
|
+
* single changed pixel reshuffles the deflate stream and yields ratios of
|
|
373
|
+
* ~0.5-0.75 — far above the 0.01 stability threshold. So `screenshot_stable`
|
|
374
|
+
* never passed on pages with any animation (blinking caret, clock, CSS anim)
|
|
375
|
+
* and surfaced as a false "page stuck" failure.
|
|
376
|
+
*
|
|
377
|
+
* We now decode both PNGs to raw RGBA via `sharp` and count the fraction of
|
|
378
|
+
* pixels that moved on any channel beyond a small tolerance. The returned ratio
|
|
379
|
+
* is in [0, 1], preserving the existing `diff <= threshold` semantics.
|
|
380
|
+
*/
|
|
381
|
+
async function pixelDiffRatio(a, b) {
|
|
382
|
+
if (a.length === b.length && a.equals(b))
|
|
383
|
+
return 0;
|
|
384
|
+
let decodedA;
|
|
385
|
+
let decodedB;
|
|
386
|
+
try {
|
|
387
|
+
[decodedA, decodedB] = await Promise.all([
|
|
388
|
+
sharp(a).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
|
|
389
|
+
sharp(b).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// Undecodable buffers (e.g. a stubbed adapter): treat as fully different if
|
|
394
|
+
// the bytes differ at all, fully stable otherwise. Never claim stability on
|
|
395
|
+
// content we cannot inspect.
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
// Dimension change is a wholesale layout change — treat as fully different.
|
|
399
|
+
if (decodedA.info.width !== decodedB.info.width || decodedA.info.height !== decodedB.info.height) {
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
const da = decodedA.data;
|
|
403
|
+
const db = decodedB.data;
|
|
404
|
+
const totalPixels = decodedA.info.width * decodedA.info.height;
|
|
405
|
+
if (totalPixels === 0)
|
|
349
406
|
return 0;
|
|
350
|
-
const sampleCount = Math.min(10000, maxLength);
|
|
351
|
-
const step = Math.max(1, Math.floor(maxLength / sampleCount));
|
|
352
|
-
let checked = 0;
|
|
353
407
|
let changed = 0;
|
|
354
|
-
for (let i = 0; i <
|
|
355
|
-
|
|
356
|
-
|
|
408
|
+
for (let i = 0; i < da.length; i += 4) {
|
|
409
|
+
if (Math.abs(da[i] - db[i]) > PIXEL_CHANNEL_TOLERANCE ||
|
|
410
|
+
Math.abs(da[i + 1] - db[i + 1]) > PIXEL_CHANNEL_TOLERANCE ||
|
|
411
|
+
Math.abs(da[i + 2] - db[i + 2]) > PIXEL_CHANNEL_TOLERANCE ||
|
|
412
|
+
Math.abs(da[i + 3] - db[i + 3]) > PIXEL_CHANNEL_TOLERANCE) {
|
|
357
413
|
changed++;
|
|
414
|
+
}
|
|
358
415
|
}
|
|
359
|
-
return
|
|
416
|
+
return changed / totalPixels;
|
|
360
417
|
}
|
|
361
418
|
function normalizeText(value) {
|
|
362
419
|
return value.replace(/\s+/g, ' ').trim();
|
package/dist/recovery-chain.js
CHANGED
|
@@ -181,8 +181,7 @@ async function tryAltInteraction(opcode, adapter, _credentials) {
|
|
|
181
181
|
const tree = await adapter.getAKTree();
|
|
182
182
|
const node = findInteractiveNodeBySelector(tree, sel);
|
|
183
183
|
if (node) {
|
|
184
|
-
const x
|
|
185
|
-
const y = node.bounds.y + node.bounds.h / 2;
|
|
184
|
+
const { x, y } = nodeCenterViewportPoint(node, tree);
|
|
186
185
|
await adapter.click(sel, { coordinates: { x, y } });
|
|
187
186
|
await sleep(500);
|
|
188
187
|
const postcondition = await evaluatePostcondition(adapter, opcode.postcondition);
|
|
@@ -341,7 +340,7 @@ function cloneOpcodeWithSelector(opcode, newSelector) {
|
|
|
341
340
|
}
|
|
342
341
|
function findInteractiveNodeBySelector(tree, selector) {
|
|
343
342
|
function walk(node) {
|
|
344
|
-
if (node.visible && node.interactive && node
|
|
343
|
+
if (node.visible && node.interactive && matchesNodeSelectorHeuristic(node, selector)) {
|
|
345
344
|
return node;
|
|
346
345
|
}
|
|
347
346
|
for (const child of node.children) {
|
|
@@ -353,15 +352,81 @@ function findInteractiveNodeBySelector(tree, selector) {
|
|
|
353
352
|
}
|
|
354
353
|
return walk(tree.root);
|
|
355
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Heuristic match between an AKTree node and a CSS-like selector. Mirrors the
|
|
357
|
+
* canonical matcher in `postcondition.ts` (which is module-private there) so
|
|
358
|
+
* coordinate-fallback recovery resolves the *same* node a postcondition would.
|
|
359
|
+
*
|
|
360
|
+
* The previous `sourceRef.includes(selector.replace(/[[\]"#.]/g, ''))` was
|
|
361
|
+
* doubly wrong: it stripped the selector's structural characters but compared
|
|
362
|
+
* against the raw `sourceRef`, so `[data-ak="x"]` (stripped to `data-akx`)
|
|
363
|
+
* never matched, while `#a` (stripped to `a`) matched any sourceRef containing
|
|
364
|
+
* an "a".
|
|
365
|
+
*/
|
|
366
|
+
function matchesNodeSelectorHeuristic(node, selector) {
|
|
367
|
+
if (node.sourceRef && matchesSelectorHeuristic(node.sourceRef, selector)) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
371
|
+
if (attrMatch) {
|
|
372
|
+
const [, attr, rawValue] = attrMatch;
|
|
373
|
+
return (node.attributes[attr] ?? '').toLowerCase() === rawValue.toLowerCase();
|
|
374
|
+
}
|
|
375
|
+
if (selector.startsWith('#')) {
|
|
376
|
+
return (node.attributes.id ?? '').toLowerCase() === selector.slice(1).toLowerCase();
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
function matchesSelectorHeuristic(sourceRef, selector) {
|
|
381
|
+
const lower = sourceRef.toLowerCase();
|
|
382
|
+
const selectorLower = selector.toLowerCase();
|
|
383
|
+
if (selectorLower === lower) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
// Generic attribute selector match, including AutoKap's canonical data-ak
|
|
387
|
+
// selectors and legacy test-id selectors.
|
|
388
|
+
const attrMatch = selector.match(/\[([a-z0-9_-]+)=["'](.+?)["']\]/i);
|
|
389
|
+
if (attrMatch) {
|
|
390
|
+
const [, attr, rawValue] = attrMatch;
|
|
391
|
+
const value = rawValue.toLowerCase();
|
|
392
|
+
return lower === `[${attr.toLowerCase()}="${value}"]`
|
|
393
|
+
|| lower.includes(`[${attr.toLowerCase()}="${value}"]`)
|
|
394
|
+
|| lower.includes(`${attr.toLowerCase()}="${value}"`);
|
|
395
|
+
}
|
|
396
|
+
// ID match
|
|
397
|
+
if (selector.startsWith('#')) {
|
|
398
|
+
return lower.includes(`id="${selector.slice(1)}"`) || lower.includes(`#${selector.slice(1)}`);
|
|
399
|
+
}
|
|
400
|
+
// Tag name match
|
|
401
|
+
if (/^[a-z][a-z0-9]*$/i.test(selector)) {
|
|
402
|
+
return lower.startsWith(`<${selectorLower}`) || lower.includes(`<${selectorLower} `);
|
|
403
|
+
}
|
|
404
|
+
// Fallback: contains
|
|
405
|
+
return lower.includes(selectorLower);
|
|
406
|
+
}
|
|
356
407
|
async function resolveSelectorCoordinates(adapter, selector) {
|
|
357
408
|
const tree = await adapter.getAKTree();
|
|
358
409
|
const node = findInteractiveNodeBySelector(tree, selector);
|
|
359
410
|
if (!node) {
|
|
360
411
|
throw new Error(`healer could not resolve coordinates for selector "${selector}"`);
|
|
361
412
|
}
|
|
413
|
+
return nodeCenterViewportPoint(node, tree);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Center point of an AKTree node in *viewport* coordinates.
|
|
417
|
+
*
|
|
418
|
+
* AKTree bounds are document-absolute (`rect.x + window.scrollX`, see
|
|
419
|
+
* `Browser.buildAKNode`), but `adapter.click({ coordinates })` ultimately
|
|
420
|
+
* routes to `page.mouse.click`, which is viewport-relative. Without subtracting
|
|
421
|
+
* the live scroll offset, a coordinate click after any SCROLL lands off-screen
|
|
422
|
+
* (mirrors the transform in `Browser.clickAKNodeEntryByBounds`).
|
|
423
|
+
*/
|
|
424
|
+
function nodeCenterViewportPoint(node, tree) {
|
|
425
|
+
const scrollX = tree.page.scroll.scrollLeft;
|
|
426
|
+
const scrollY = tree.page.scroll.scrollTop;
|
|
362
427
|
return {
|
|
363
|
-
x: node.bounds.x + node.bounds.w / 2,
|
|
364
|
-
y: node.bounds.y + node.bounds.h / 2,
|
|
428
|
+
x: node.bounds.x + node.bounds.w / 2 - scrollX,
|
|
429
|
+
y: node.bounds.y + node.bounds.h / 2 - scrollY,
|
|
365
430
|
};
|
|
366
431
|
}
|
|
367
432
|
function sleep(ms) {
|
package/dist/security.d.ts
CHANGED
|
@@ -17,10 +17,18 @@ export interface SecurityDecision {
|
|
|
17
17
|
target?: InteractiveElement | null;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* Is `candidateUrl` part of the same first-party site as `scopeUrl
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Is `candidateUrl` part of the same first-party site as `scopeUrl`, for the
|
|
21
|
+
* purpose of the PROGRESS signal and analytics-blocking (NOT navigation
|
|
22
|
+
* guarding)? This is deliberately MORE permissive than the navigation
|
|
23
|
+
* site-scope model so the app's own backend on a sibling host/port still reads
|
|
24
|
+
* as first-party:
|
|
25
|
+
* - real domains: same registrable domain (eTLD+1) match — `app.example.com`,
|
|
26
|
+
* `api.example.com` and `example.com` are all the same site (the navigation
|
|
27
|
+
* guard, in contrast, requires a strict sub-domain match);
|
|
28
|
+
* - localhost / private IPs: same host, REGARDLESS of port — a Vite app on
|
|
29
|
+
* `:5173` calling its API on `:3000` is the same first-party app;
|
|
30
|
+
* - shared-hosting suffixes (`*.vercel.app`, …): exact host (so sibling
|
|
31
|
+
* previews stay foreign — they are genuinely distinct sites).
|
|
24
32
|
*
|
|
25
33
|
* Used to scope the adaptive-wait progress signal (AUT-240) to the app's own
|
|
26
34
|
* traffic, so third-party telemetry (PostHog beacons, analytics/ad pixels,
|
|
@@ -35,6 +43,15 @@ export interface SecurityDecision {
|
|
|
35
43
|
* navigation commits) ⇒ true (don't filter);
|
|
36
44
|
* - non-http(s) `candidateUrl` (`data:` / `blob:` / `about:`) ⇒ true (in-page
|
|
37
45
|
* resource).
|
|
46
|
+
*
|
|
47
|
+
* NOTE: this MUST NOT be used to gate navigation — it intentionally does not use
|
|
48
|
+
* the navigation `isWithinProjectScope` model. Use `isAllowedNavigation` there.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isFirstPartyForProgress(scopeUrl: string | null | undefined, candidateUrl: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Back-compat alias for the progress / analytics first-party predicate.
|
|
53
|
+
* @deprecated Prefer {@link isFirstPartyForProgress}; this name is kept so the
|
|
54
|
+
* progress listeners and analytics-block guard keep compiling unchanged.
|
|
38
55
|
*/
|
|
39
56
|
export declare function isFirstPartyUrl(scopeUrl: string | null | undefined, candidateUrl: string): boolean;
|
|
40
57
|
export declare function evaluateResolvedActionSecurity(action: ActionType, args: Record<string, unknown>, context: SecurityContext, target: InteractiveElement | null): SecurityDecision;
|
package/dist/security.js
CHANGED
|
@@ -106,6 +106,41 @@ function isLocalHostname(hostname) {
|
|
|
106
106
|
function isSharedHostingHostname(hostname) {
|
|
107
107
|
return SHARED_HOSTING_SUFFIXES.some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Multi-label public suffixes where the registrable domain is the last THREE
|
|
111
|
+
* labels (e.g. `example.co.uk`), not the usual two. Conservative, dependency-free
|
|
112
|
+
* subset (no bundled PSL) covering the common ccTLD second levels — enough to
|
|
113
|
+
* avoid collapsing `a.example.co.uk` and `b.other.co.uk` to a shared `co.uk`.
|
|
114
|
+
*/
|
|
115
|
+
const TWO_LABEL_PUBLIC_SUFFIXES = new Set([
|
|
116
|
+
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk',
|
|
117
|
+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'id.au',
|
|
118
|
+
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
119
|
+
'co.jp', 'or.jp', 'ne.jp', 'go.jp', 'ac.jp',
|
|
120
|
+
'com.br', 'net.br', 'org.br', 'gov.br',
|
|
121
|
+
'co.in', 'net.in', 'org.in', 'gov.in',
|
|
122
|
+
'com.cn', 'net.cn', 'org.cn', 'gov.cn',
|
|
123
|
+
'co.za', 'org.za', 'gov.za',
|
|
124
|
+
'co.kr', 'or.kr', 'go.kr',
|
|
125
|
+
'com.mx', 'com.tr', 'com.sg', 'com.hk', 'com.tw',
|
|
126
|
+
]);
|
|
127
|
+
/**
|
|
128
|
+
* The registrable domain (eTLD+1) of a hostname, e.g. `app.example.com` and
|
|
129
|
+
* `api.example.com` both reduce to `example.com`. Returns the host unchanged
|
|
130
|
+
* when it has too few labels to reduce. Conservative public-suffix handling via
|
|
131
|
+
* {@link TWO_LABEL_PUBLIC_SUFFIXES}; callers must only use this for the
|
|
132
|
+
* progress signal (fail-open), never for the navigation-scope guard.
|
|
133
|
+
*/
|
|
134
|
+
function registrableDomain(hostname) {
|
|
135
|
+
const labels = hostname.split('.');
|
|
136
|
+
if (labels.length <= 2)
|
|
137
|
+
return hostname;
|
|
138
|
+
const lastTwo = labels.slice(-2).join('.');
|
|
139
|
+
if (TWO_LABEL_PUBLIC_SUFFIXES.has(lastTwo)) {
|
|
140
|
+
return labels.slice(-3).join('.');
|
|
141
|
+
}
|
|
142
|
+
return lastTwo;
|
|
143
|
+
}
|
|
109
144
|
function normalizeScopeHostname(hostname) {
|
|
110
145
|
return hostname.startsWith('www.') ? hostname.slice(4) : hostname;
|
|
111
146
|
}
|
|
@@ -135,10 +170,18 @@ function buildNavigationScopes(context) {
|
|
|
135
170
|
return Array.from(dedup.values());
|
|
136
171
|
}
|
|
137
172
|
/**
|
|
138
|
-
* Is `candidateUrl` part of the same first-party site as `scopeUrl
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
173
|
+
* Is `candidateUrl` part of the same first-party site as `scopeUrl`, for the
|
|
174
|
+
* purpose of the PROGRESS signal and analytics-blocking (NOT navigation
|
|
175
|
+
* guarding)? This is deliberately MORE permissive than the navigation
|
|
176
|
+
* site-scope model so the app's own backend on a sibling host/port still reads
|
|
177
|
+
* as first-party:
|
|
178
|
+
* - real domains: same registrable domain (eTLD+1) match — `app.example.com`,
|
|
179
|
+
* `api.example.com` and `example.com` are all the same site (the navigation
|
|
180
|
+
* guard, in contrast, requires a strict sub-domain match);
|
|
181
|
+
* - localhost / private IPs: same host, REGARDLESS of port — a Vite app on
|
|
182
|
+
* `:5173` calling its API on `:3000` is the same first-party app;
|
|
183
|
+
* - shared-hosting suffixes (`*.vercel.app`, …): exact host (so sibling
|
|
184
|
+
* previews stay foreign — they are genuinely distinct sites).
|
|
142
185
|
*
|
|
143
186
|
* Used to scope the adaptive-wait progress signal (AUT-240) to the app's own
|
|
144
187
|
* traffic, so third-party telemetry (PostHog beacons, analytics/ad pixels,
|
|
@@ -153,8 +196,11 @@ function buildNavigationScopes(context) {
|
|
|
153
196
|
* navigation commits) ⇒ true (don't filter);
|
|
154
197
|
* - non-http(s) `candidateUrl` (`data:` / `blob:` / `about:`) ⇒ true (in-page
|
|
155
198
|
* resource).
|
|
199
|
+
*
|
|
200
|
+
* NOTE: this MUST NOT be used to gate navigation — it intentionally does not use
|
|
201
|
+
* the navigation `isWithinProjectScope` model. Use `isAllowedNavigation` there.
|
|
156
202
|
*/
|
|
157
|
-
export function
|
|
203
|
+
export function isFirstPartyForProgress(scopeUrl, candidateUrl) {
|
|
158
204
|
const scopeParsed = parseHttpUrl(scopeUrl ?? undefined);
|
|
159
205
|
if (!scopeParsed)
|
|
160
206
|
return true;
|
|
@@ -167,7 +213,28 @@ export function isFirstPartyUrl(scopeUrl, candidateUrl) {
|
|
|
167
213
|
}
|
|
168
214
|
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:')
|
|
169
215
|
return true;
|
|
170
|
-
|
|
216
|
+
const scopeHost = scopeParsed.hostname.toLowerCase();
|
|
217
|
+
const candidateHost = candidate.hostname.toLowerCase();
|
|
218
|
+
// localhost / private IPs: same host is first-party regardless of port (the
|
|
219
|
+
// dev server and its API commonly live on different ports of the same host).
|
|
220
|
+
if (isLocalHostname(scopeHost) || isIpv4Hostname(scopeHost) || isIpv6Hostname(scopeHost)) {
|
|
221
|
+
return candidateHost === scopeHost;
|
|
222
|
+
}
|
|
223
|
+
// Shared-hosting previews are distinct sites: keep exact-host so a sibling
|
|
224
|
+
// `*.vercel.app` beacon never masquerades as first-party progress.
|
|
225
|
+
if (isSharedHostingHostname(scopeHost)) {
|
|
226
|
+
return candidateHost === scopeHost || candidateHost.endsWith(`.${scopeHost}`);
|
|
227
|
+
}
|
|
228
|
+
// Real registrable domain: any candidate sharing the eTLD+1 is first-party.
|
|
229
|
+
return registrableDomain(candidateHost) === registrableDomain(scopeHost);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Back-compat alias for the progress / analytics first-party predicate.
|
|
233
|
+
* @deprecated Prefer {@link isFirstPartyForProgress}; this name is kept so the
|
|
234
|
+
* progress listeners and analytics-block guard keep compiling unchanged.
|
|
235
|
+
*/
|
|
236
|
+
export function isFirstPartyUrl(scopeUrl, candidateUrl) {
|
|
237
|
+
return isFirstPartyForProgress(scopeUrl, candidateUrl);
|
|
171
238
|
}
|
|
172
239
|
function isWithinProjectScope(candidate, scopes) {
|
|
173
240
|
const hostname = candidate.hostname.toLowerCase();
|
package/dist/wait-contract.d.ts
CHANGED
|
@@ -47,16 +47,30 @@ export declare const PIXEL_FALLBACK_DIFF_THRESHOLD = 0.01;
|
|
|
47
47
|
*/
|
|
48
48
|
export declare function resolveGlobalWaitDeadlineMs(startedAtMs: number, compiledTimeoutMs: number, mediaMode: MediaMode): number;
|
|
49
49
|
/**
|
|
50
|
-
* Did the page make observable progress between two snapshots?
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* Did the page make observable, CORROBORATED progress between two snapshots?
|
|
51
|
+
* Progress is one of:
|
|
52
|
+
* - a navigation in flight (`navigating`);
|
|
53
|
+
* - a first-party request COMPLETING — a net decrease in `inflightRequests`
|
|
54
|
+
* (something finished or failed, i.e. a response the page can act on);
|
|
55
|
+
* - a `readyState` transition;
|
|
56
|
+
* - a `domNodeCount` change (the page rendered/mutated something).
|
|
55
57
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* A null current reading is treated conservatively as "no progress"; the first
|
|
59
|
+
* real reading (null `prev`) counts as progress to seed the watchdog window.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately does NOT key on monotonic counters that a steady self-poll bumps
|
|
62
|
+
* forever (`networkEventCount`, `lastNetworkActivityAtMs`) nor on a request
|
|
63
|
+
* merely STARTING (an INCREASE in `inflightRequests`): an app polling its own
|
|
64
|
+
* origin every tick would otherwise keep this `true` indefinitely, `lastProgressAt`
|
|
65
|
+
* would keep resetting, and the stuck-cut would never fire — burning the global
|
|
66
|
+
* deadline and skipping recovery (AUT-240 D2). Requiring a completion or a
|
|
67
|
+
* DOM/readyState change means real loads (requests resolve, the DOM fills,
|
|
68
|
+
* readyState advances) still register, while idle chatter does not.
|
|
69
|
+
*
|
|
70
|
+
* The network fields are also first-party-scoped at the source (see
|
|
71
|
+
* `getProgressSnapshot` / `isFirstPartyForProgress`): background third-party
|
|
72
|
+
* telemetry (analytics beacons, ad pixels, polling to other origins) is
|
|
73
|
+
* excluded so it cannot masquerade as the app's own work.
|
|
60
74
|
*/
|
|
61
75
|
export declare function hasProgress(prev: ProgressSnapshot | null | undefined, cur: ProgressSnapshot | null | undefined): boolean;
|
|
62
76
|
export type ProgressBudgetCut = 'stuck' | 'deadline';
|
package/dist/wait-contract.js
CHANGED
|
@@ -53,16 +53,30 @@ export function resolveGlobalWaitDeadlineMs(startedAtMs, compiledTimeoutMs, medi
|
|
|
53
53
|
return startedAtMs + Math.max(compiledTimeoutMs, cap);
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* Did the page make observable progress between two snapshots?
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* Did the page make observable, CORROBORATED progress between two snapshots?
|
|
57
|
+
* Progress is one of:
|
|
58
|
+
* - a navigation in flight (`navigating`);
|
|
59
|
+
* - a first-party request COMPLETING — a net decrease in `inflightRequests`
|
|
60
|
+
* (something finished or failed, i.e. a response the page can act on);
|
|
61
|
+
* - a `readyState` transition;
|
|
62
|
+
* - a `domNodeCount` change (the page rendered/mutated something).
|
|
61
63
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* A null current reading is treated conservatively as "no progress"; the first
|
|
65
|
+
* real reading (null `prev`) counts as progress to seed the watchdog window.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately does NOT key on monotonic counters that a steady self-poll bumps
|
|
68
|
+
* forever (`networkEventCount`, `lastNetworkActivityAtMs`) nor on a request
|
|
69
|
+
* merely STARTING (an INCREASE in `inflightRequests`): an app polling its own
|
|
70
|
+
* origin every tick would otherwise keep this `true` indefinitely, `lastProgressAt`
|
|
71
|
+
* would keep resetting, and the stuck-cut would never fire — burning the global
|
|
72
|
+
* deadline and skipping recovery (AUT-240 D2). Requiring a completion or a
|
|
73
|
+
* DOM/readyState change means real loads (requests resolve, the DOM fills,
|
|
74
|
+
* readyState advances) still register, while idle chatter does not.
|
|
75
|
+
*
|
|
76
|
+
* The network fields are also first-party-scoped at the source (see
|
|
77
|
+
* `getProgressSnapshot` / `isFirstPartyForProgress`): background third-party
|
|
78
|
+
* telemetry (analytics beacons, ad pixels, polling to other origins) is
|
|
79
|
+
* excluded so it cannot masquerade as the app's own work.
|
|
66
80
|
*/
|
|
67
81
|
export function hasProgress(prev, cur) {
|
|
68
82
|
if (!cur)
|
|
@@ -71,9 +85,7 @@ export function hasProgress(prev, cur) {
|
|
|
71
85
|
return true;
|
|
72
86
|
if (!prev)
|
|
73
87
|
return true;
|
|
74
|
-
return (cur.
|
|
75
|
-
|| cur.inflightRequests !== prev.inflightRequests
|
|
76
|
-
|| cur.lastNetworkActivityAtMs !== prev.lastNetworkActivityAtMs
|
|
88
|
+
return (cur.inflightRequests < prev.inflightRequests
|
|
77
89
|
|| cur.readyState !== prev.readyState
|
|
78
90
|
|| cur.domNodeCount !== prev.domNodeCount);
|
|
79
91
|
}
|