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.
@@ -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
  }
@@ -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.pointerEvents !== 'none' ? 'true' : 'false';
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 = candidate;
3165
+ let nextValue;
3120
3166
  try {
3121
3167
  const parsed = JSON.parse(current);
3122
3168
  const rewritten = rewrite(parsed);
3123
- if (rewritten.changed) {
3124
- nextValue = JSON.stringify(rewritten.value);
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
- return await page.locator(selector).first().evaluate((node) => {
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
- const nested = start.querySelector(ACTIONABLE_SELECTOR);
4517
- if (nested instanceof HTMLElement)
4518
- return nested;
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;
@@ -238,22 +238,39 @@ export async function runCapture(options) {
238
238
  }
239
239
  }
240
240
  let interrupted = false;
241
- let abortNotified = false;
242
- const notifyAborted = async () => {
243
- if (abortNotified || !isLocalRegisteredRun)
244
- return;
245
- abortNotified = true;
246
- await postRunAborted(config, runId, program.presetId, options.env);
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. Hard-exit with the
251
- // conventional 128+SIGINT(2) code; the best-effort notify already fired.
252
- process.exit(130);
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);
@@ -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>;
@@ -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.includes('cookie') || combinedAttrs.includes('consent') ||
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
- htmlEl.click();
200
- return text;
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
- }, ACCEPT_PATTERNS.map(rx => rx.toString()));
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);
@@ -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
- if (opcode.kind === 'ASSERT_ROUTE' || opcode.kind === 'ASSERT_SURFACE') {
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 = await evaluatePostconditionWithProgress(adapter, runtimePostcondition, Date.now(), globalDeadlineMs, getProgress);
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
  }