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.
@@ -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(tree, overlay.nodeId, closePatterns);
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
- await adapter.click(selector);
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
- function findCloseButton(tree, overlayNodeId, patterns) {
110
- // Find the overlay node first
111
- const overlayNode = findNodeById(tree.root, overlayNodeId);
112
- if (!overlayNode)
113
- return null;
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
- const label = node.label.toLowerCase();
119
- const ref = node.sourceRef.toLowerCase();
120
- const combined = label + ' ' + ref;
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 (combined.includes(pattern)) {
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 match aria-label="close" or title="close" in sourceRef
131
- if (ref.includes('aria-label="close"') || ref.includes('title="close"') || ref.includes('class="close"')) {
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;
@@ -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
- if (regex.test(fullPath) || regex.test(pathname)) {
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 author bare
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 = sampledBufferDiffRatio(screenshot, screenshot2);
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 = sampledBufferDiffRatio(context.lastScreenshot, screenshot);
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
- function sampledBufferDiffRatio(a, b) {
347
- const maxLength = Math.max(a.length, b.length);
348
- if (maxLength === 0)
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 < maxLength; i += step) {
355
- checked++;
356
- if (a[i] !== b[i])
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 checked === 0 ? 0 : changed / checked;
416
+ return changed / totalPixels;
360
417
  }
361
418
  function normalizeText(value) {
362
419
  return value.replace(/\s+/g, ' ').trim();
@@ -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 = node.bounds.x + node.bounds.w / 2;
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.sourceRef.includes(selector.replace(/[[\]"#.]/g, ''))) {
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) {
@@ -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`? Shares the
21
- * navigation site-scope model (`isWithinProjectScope`): exact-host for IPs /
22
- * localhost / shared-hosting suffixes (so sibling `*.vercel.app` previews are
23
- * NOT same-site), sub-domain family match for real registrable domains.
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`? Shares the
139
- * navigation site-scope model (`isWithinProjectScope`): exact-host for IPs /
140
- * localhost / shared-hosting suffixes (so sibling `*.vercel.app` previews are
141
- * NOT same-site), sub-domain family match for real registrable domains.
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 isFirstPartyUrl(scopeUrl, candidateUrl) {
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
- return isWithinProjectScope(candidate, [scopeFromParsedUrl(scopeParsed)]);
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();
@@ -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? Any of: a
51
- * navigation in flight, new network events, in-flight count change, network
52
- * activity advancing, readyState change, or DOM node-count change. A null
53
- * current reading is treated conservatively as "no progress"; the first real
54
- * reading (null `prev`) counts as progress to seed the watchdog window.
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
- * The network fields are first-party-scoped at the source (see
57
- * `getProgressSnapshot` / `isFirstPartyUrl`): background third-party telemetry
58
- * (analytics beacons, ad pixels, polling to other origins) is excluded, so it
59
- * cannot keep this `true` forever and starve the stuck-cut.
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';
@@ -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? Any of: a
57
- * navigation in flight, new network events, in-flight count change, network
58
- * activity advancing, readyState change, or DOM node-count change. A null
59
- * current reading is treated conservatively as "no progress"; the first real
60
- * reading (null `prev`) counts as progress to seed the watchdog window.
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
- * The network fields are first-party-scoped at the source (see
63
- * `getProgressSnapshot` / `isFirstPartyUrl`): background third-party telemetry
64
- * (analytics beacons, ad pixels, polling to other origins) is excluded, so it
65
- * cannot keep this `true` forever and starve the stuck-cut.
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.networkEventCount !== prev.networkEventCount
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
  }