cbrowser 19.1.7 → 19.1.8

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/browser.js CHANGED
@@ -2006,18 +2006,31 @@ For more help: https://playwright.dev/docs/browsers
2006
2006
  */
2007
2007
  async click(selector, options = {}) {
2008
2008
  const page = await this.getPage();
2009
- // Classify action zone
2010
- const zone = this.classifyAction("click", selector);
2009
+ // Classify action zone from the selector string (unchanged: a string that names a dangerous action
2010
+ // is refused before anything is resolved, exactly as before).
2011
+ let zone = this.classifyAction("click", selector);
2011
2012
  if (zone === "red" && !options.force) {
2012
2013
  return {
2013
2014
  success: false,
2014
2015
  screenshot: await this.screenshot(),
2015
2016
  message: `Red zone action requires --force: click "${selector}"`,
2017
+ zone,
2016
2018
  };
2017
2019
  }
2018
2020
  try {
2019
2021
  // Try multiple selector strategies
2020
2022
  const element = await this.findElement(selector);
2023
+ // Classify the ELEMENT, not only the string. A selector says how an element is addressed
2024
+ // (#delete-account, [data-testid=...], button:nth-of-type(3)); the element says what it is
2025
+ // ("Delete account"). The zone must be the same whichever way the element is addressed.
2026
+ let target;
2027
+ if (element) {
2028
+ const gate = await this.gateElement("click", selector, element, zone, options.force);
2029
+ if (gate.refusal)
2030
+ return gate.refusal;
2031
+ zone = gate.zone;
2032
+ target = gate.target;
2033
+ }
2021
2034
  if (!element) {
2022
2035
  const result = {
2023
2036
  success: false,
@@ -2097,6 +2110,20 @@ For more help: https://playwright.dev/docs/browsers
2097
2110
  if (targetBlankRemoved && options.verbose) {
2098
2111
  console.log('Removed target="_blank" to navigate in same tab');
2099
2112
  }
2113
+ // Re-judge what is about to be clicked, immediately before dispatch. The first gate ran when the
2114
+ // selector resolved; since then clickTarget may have moved to an ancestor anchor and a re-rendering
2115
+ // page may have swapped the node a locator resolves to. One round trip remains between this check
2116
+ // and Playwright's own resolution at click time.
2117
+ // For a container the judged point is pinned: the click goes exactly where the gate looked.
2118
+ let clickPosition;
2119
+ {
2120
+ const gate = await this.gateElement("click", selector, clickTarget, zone, options.force);
2121
+ if (gate.refusal)
2122
+ return gate.refusal;
2123
+ zone = gate.zone;
2124
+ target = gate.target ?? target;
2125
+ clickPosition = gate.position;
2126
+ }
2100
2127
  // Check for sticky element interception before clicking
2101
2128
  const interception = await this.checkForInterception(clickTarget);
2102
2129
  if (interception.intercepted && interception.interceptorSelector) {
@@ -2104,7 +2131,7 @@ For more help: https://playwright.dev/docs/browsers
2104
2131
  console.log(`Sticky element detected: ${interception.interceptorInfo}`);
2105
2132
  }
2106
2133
  // Try to handle the interception
2107
- const handled = await this.handleStickyInterception(clickTarget, interception.interceptorSelector, { verbose: options.verbose });
2134
+ const handled = await this.handleStickyInterception(clickTarget, interception.interceptorSelector, { verbose: options.verbose, position: clickPosition });
2108
2135
  if (!handled.success) {
2109
2136
  return {
2110
2137
  success: false,
@@ -2118,7 +2145,7 @@ For more help: https://playwright.dev/docs/browsers
2118
2145
  }
2119
2146
  else {
2120
2147
  // No interception, normal click
2121
- await clickTarget.click();
2148
+ await clickTarget.click(clickPosition ? { position: clickPosition } : undefined);
2122
2149
  }
2123
2150
  // Wait for any navigation or network activity
2124
2151
  await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { });
@@ -2127,6 +2154,8 @@ For more help: https://playwright.dev/docs/browsers
2127
2154
  success: true,
2128
2155
  screenshot: await this.screenshot(),
2129
2156
  message: `Clicked: ${selector}`,
2157
+ zone,
2158
+ target,
2130
2159
  };
2131
2160
  }
2132
2161
  catch (error) {
@@ -2149,6 +2178,12 @@ For more help: https://playwright.dev/docs/browsers
2149
2178
  // Find the last filled input and press Enter on it
2150
2179
  const lastInput = await this.findElement(this.lastFilledInputSelector);
2151
2180
  if (lastInput) {
2181
+ // Enter submits the input's form: gate on the form's action and default button, so a hidden
2182
+ // "Pay now" cannot be reached by pressing Enter in the card-number field.
2183
+ const gate = await this.gateElement("click", `${selector} (Enter-fallback)`, lastInput, zone, options.force, { implicitSubmit: true });
2184
+ if (gate.refusal)
2185
+ return gate.refusal;
2186
+ zone = gate.zone;
2152
2187
  await lastInput.focus();
2153
2188
  await page.keyboard.press('Enter');
2154
2189
  // Wait for navigation or network activity
@@ -2158,6 +2193,7 @@ For more help: https://playwright.dev/docs/browsers
2158
2193
  success: true,
2159
2194
  screenshot: await this.screenshot(),
2160
2195
  message: `Submitted via Enter key (button "${selector}" was not visible)`,
2196
+ zone,
2161
2197
  };
2162
2198
  }
2163
2199
  }
@@ -2317,6 +2353,8 @@ For more help: https://playwright.dev/docs/browsers
2317
2353
  * Handle a sticky element intercepting a click by trying multiple strategies.
2318
2354
  */
2319
2355
  async handleStickyInterception(element, interceptorSelector, options = {}) {
2356
+ // A container's click goes to the point the red-zone gate judged, whichever strategy clicks it.
2357
+ const at = options.position ? { position: options.position } : undefined;
2320
2358
  const page = await this.getPage();
2321
2359
  // Strategy 1: Scroll element to viewport center (away from sticky headers/footers)
2322
2360
  try {
@@ -2332,7 +2370,7 @@ For more help: https://playwright.dev/docs/browsers
2332
2370
  // Check if still intercepted
2333
2371
  const recheck = await this.checkForInterception(element);
2334
2372
  if (!recheck.intercepted) {
2335
- await element.click();
2373
+ await element.click(at);
2336
2374
  return { success: true, strategy: 'scroll-to-safe-zone' };
2337
2375
  }
2338
2376
  }
@@ -2353,7 +2391,7 @@ For more help: https://playwright.dev/docs/browsers
2353
2391
  });
2354
2392
  await page.waitForTimeout(50);
2355
2393
  // Click the element
2356
- await element.click();
2394
+ await element.click(at);
2357
2395
  // Restore the interceptor
2358
2396
  await interceptor.evaluate((el, original) => {
2359
2397
  el.style.display = original;
@@ -2763,6 +2801,18 @@ For more help: https://playwright.dev/docs/browsers
2763
2801
  */
2764
2802
  async hoverClick(selector, options = {}) {
2765
2803
  const page = await this.getPage();
2804
+ // Same gate as click(): the string first, then the element it resolves to. hoverClick is also the
2805
+ // daemon's click path (daemon.ts), so without this every daemon-mode click skipped the red zone.
2806
+ const stringZone = this.classifyAction("click", selector);
2807
+ if (stringZone === "red" && !options.force) {
2808
+ this.audit("hoverClick", selector, "red", "blocked");
2809
+ return {
2810
+ success: false,
2811
+ screenshot: await this.screenshot(),
2812
+ message: `Red zone action requires --force: hoverClick "${selector}"`,
2813
+ zone: stringZone,
2814
+ };
2815
+ }
2766
2816
  try {
2767
2817
  // STEP 1: Detect the likely parent menu from selector text
2768
2818
  // e.g., "International Admissions" → parent is likely "Admissions"
@@ -2822,13 +2872,18 @@ For more help: https://playwright.dev/docs/browsers
2822
2872
  await page.waitForTimeout(400);
2823
2873
  const found = await this.findElement(selector);
2824
2874
  if (found) {
2825
- await found.click();
2875
+ const gate = await this.gateElement("hoverClick", selector, found, stringZone, options.force);
2876
+ if (gate.refusal)
2877
+ return gate.refusal;
2878
+ await found.click(gate.position ? { position: gate.position } : undefined);
2826
2879
  await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { });
2827
- this.audit("hoverClick", selector, "yellow", "success");
2880
+ this.audit("hoverClick", selector, gate.zone, "success");
2828
2881
  return {
2829
2882
  success: true,
2830
2883
  screenshot: await this.screenshot(),
2831
2884
  message: `Hover-clicked (nav scan): ${selector}`,
2885
+ zone: gate.zone,
2886
+ target: gate.target,
2832
2887
  };
2833
2888
  }
2834
2889
  }
@@ -2844,13 +2899,18 @@ For more help: https://playwright.dev/docs/browsers
2844
2899
  };
2845
2900
  }
2846
2901
  // STEP 4: Click the element (parent should still be hovered)
2847
- await element.click();
2902
+ const gate = await this.gateElement("hoverClick", selector, element, stringZone, options.force);
2903
+ if (gate.refusal)
2904
+ return gate.refusal;
2905
+ await element.click(gate.position ? { position: gate.position } : undefined);
2848
2906
  await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { });
2849
- this.audit("hoverClick", selector, "yellow", "success");
2907
+ this.audit("hoverClick", selector, gate.zone, "success");
2850
2908
  return {
2851
2909
  success: true,
2852
2910
  screenshot: await this.screenshot(),
2853
2911
  message: `Hover-clicked: ${selector}`,
2912
+ zone: gate.zone,
2913
+ target: gate.target,
2854
2914
  };
2855
2915
  }
2856
2916
  catch (error) {
@@ -3183,6 +3243,12 @@ For more help: https://playwright.dev/docs/browsers
3183
3243
  screenshot: result.screenshot,
3184
3244
  };
3185
3245
  }
3246
+ // A red-zone refusal is a request for --force, not a selector that needs healing. Retrying
3247
+ // with a shorter alternative ("Delete" for "Delete account") used to route around the gate.
3248
+ const refusedRed = (r) => !r.success && r.zone === "red" && !options.force;
3249
+ if (refusedRed(result)) {
3250
+ return { success: false, attempts, message: result.message, screenshot: result.screenshot, zone: "red" };
3251
+ }
3186
3252
  // Try to find alternative selectors
3187
3253
  const alternatives = await this.findAlternativeSelectors(selector);
3188
3254
  for (let i = 0; i < Math.min(maxRetries - 1, alternatives.length); i++) {
@@ -3197,6 +3263,9 @@ For more help: https://playwright.dev/docs/browsers
3197
3263
  alternativeUsed: alt.reason,
3198
3264
  screenshot: result.screenshot,
3199
3265
  });
3266
+ if (refusedRed(result)) {
3267
+ return { success: false, attempts, message: result.message, screenshot: result.screenshot, zone: "red" };
3268
+ }
3200
3269
  if (result.success) {
3201
3270
  // v11.8.0: Gate success on confidence threshold
3202
3271
  const meetsConfidence = alt.confidence >= minConfidence;
@@ -5246,10 +5315,13 @@ For more help: https://playwright.dev/docs/browsers
5246
5315
  * - YELLOW: Interactive actions (click, fill) - log and proceed
5247
5316
  * - GREEN: Read-only actions - auto-execute
5248
5317
  */
5249
- classifyAction(action, target) {
5318
+ classifyAction(action, target, options = {}) {
5250
5319
  const lowerTarget = target.toLowerCase();
5251
5320
  // =========================================================================
5252
5321
  // BLACK ZONE - Security violations, never execute
5322
+ // ignoreBlack: the black patterns describe an INSTRUCTION ("bypass the login"), not a control on a
5323
+ // page. classifyElement skips them so page text like "SQL injection course" is not a violation and a
5324
+ // red label that happens to sit next to a black word still classifies red.
5253
5325
  // =========================================================================
5254
5326
  const blackPatterns = [
5255
5327
  /bypass/i,
@@ -5259,7 +5331,7 @@ For more help: https://playwright.dev/docs/browsers
5259
5331
  /sql\s*injection/i,
5260
5332
  /xss/i,
5261
5333
  ];
5262
- if (blackPatterns.some(p => p.test(lowerTarget))) {
5334
+ if (!options.ignoreBlack && blackPatterns.some(p => p.test(lowerTarget))) {
5263
5335
  return "black";
5264
5336
  }
5265
5337
  // =========================================================================
@@ -5349,6 +5421,660 @@ For more help: https://playwright.dev/docs/browsers
5349
5421
  // =========================================================================
5350
5422
  return "yellow";
5351
5423
  }
5424
+ /**
5425
+ * Classify a RESOLVED element by what it is, with the same patterns classifyAction applies to a
5426
+ * string. Reads the control a click would act on (the element, its nearest interactive ancestor in the
5427
+ * composed tree, or the control under the point the click lands on) and runs classifyAction over every
5428
+ * description of it: aria-label, resolved aria-labelledby, a button's value, title, its own visible
5429
+ * text, names that live below it and belong to it (an icon's aria-label or <title>, an image's alt,
5430
+ * an image input's alt, open-shadow-root and slotted text), and the action - or the submitter's
5431
+ * formaction - of the form it submits. The most severe zone wins.
5432
+ *
5433
+ * Not judged, because acting on them has no consequence of its own: a text field (typed text and the
5434
+ * field's label are data; clicking into "Reason you want to close account" closes nothing) and a choice
5435
+ * control (checkbox, radio, switch, select, option: choosing a value; the submit that commits it is
5436
+ * judged when it is clicked).
5437
+ *
5438
+ * implicitSubmit: the caller is about to press Enter, which submits the focused field's form. The
5439
+ * form's action and its default submit button are then the descriptions that matter.
5440
+ * skipHit: the element is already the deepest element at a known point (a coordinate click, a drag
5441
+ * endpoint, the focused element); do not hit-test its centre.
5442
+ *
5443
+ * position: for an element that is not itself a control (a section, a card, <main>), the point that was
5444
+ * judged, relative to the element's padding box. Callers pass it to the click so the point judged is the
5445
+ * point clicked.
5446
+ *
5447
+ * Returns null when the element cannot be read (detached, frame gone); the caller keeps the string zone.
5448
+ */
5449
+ async classifyElement(element, options = {}) {
5450
+ const inPage = (node, arg) => {
5451
+ const norm = (s, max) => (s || "").replace(/\s+/g, " ").trim().slice(0, max);
5452
+ const INTERACTIVE = "a,button,input,select,textarea,summary,label,[role='button'],[role='link'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='tab'],[role='option'],[role='checkbox'],[role='radio'],[role='switch'],[onclick]";
5453
+ // Native checkboxes, radios and selects choose a value; role=checkbox/radio/switch likewise. NOT role=option or
5454
+ // menu items: command palettes (cmdk / shadcn Command) render every command as role=option and run it on click.
5455
+ const CHOICE = "input[type=checkbox],input[type=radio],select,option,[role='checkbox'],[role='radio'],[role='switch']";
5456
+ const BUTTON_INPUT = /^(submit|button|reset|image)$/i;
5457
+ const isTextField = (el) => el.tagName === "TEXTAREA" || el.isContentEditable ||
5458
+ (el.tagName === "INPUT" && !BUTTON_INPUT.test(el.type || "") && !/^(checkbox|radio)$/i.test(el.type || ""));
5459
+ const isData = (el) => isTextField(el) || el.matches(CHOICE);
5460
+ // Composed-tree walk: a slotted node's parent is its slot, a shadow root's parent is its host, and
5461
+ // a same-origin frame's document hangs under its <iframe>.
5462
+ const up = (e) => {
5463
+ const slot = e.assignedSlot;
5464
+ if (slot)
5465
+ return slot;
5466
+ if (e.parentElement)
5467
+ return e.parentElement;
5468
+ const r = e.getRootNode();
5469
+ if (r.host)
5470
+ return r.host;
5471
+ try {
5472
+ return e.ownerDocument.defaultView?.frameElement ?? null;
5473
+ }
5474
+ catch {
5475
+ return null;
5476
+ }
5477
+ };
5478
+ const composedClosest = (e) => { for (let n = e; n; n = up(n))
5479
+ if (n.matches(INTERACTIVE))
5480
+ return n; return null; };
5481
+ const composedContains = (a, b) => { for (let n = b; n; n = up(n))
5482
+ if (n === a)
5483
+ return true; return false; };
5484
+ // The element a pointer at viewport (x, y) actually reaches: beneath anything not inside `within`
5485
+ // (a cookie bar, a loading overlay), into open shadow roots and same-origin frames.
5486
+ const deepAt = (x, y, within) => {
5487
+ const hitRoot = (within ? within.getRootNode() : document);
5488
+ const stack = hitRoot.elementsFromPoint(x, y);
5489
+ let e = within ? (stack.find(s => composedContains(within, s)) ?? null) : (stack[0] ?? null);
5490
+ let ox = 0, oy = 0;
5491
+ for (let i = 0; e && i < 12; i++) {
5492
+ const sr = e.shadowRoot;
5493
+ if (sr) {
5494
+ const inner = sr.elementFromPoint(x - ox, y - oy);
5495
+ if (inner && inner !== e) {
5496
+ e = inner;
5497
+ continue;
5498
+ }
5499
+ // The point is on the host's own light-DOM text, rendered through a <slot>: that slot's control
5500
+ // is what the click activates.
5501
+ const host = e;
5502
+ const slot = Array.from(sr.querySelectorAll("slot")).find(s => s.assignedNodes().some(n => n.parentNode === host));
5503
+ if (slot) {
5504
+ e = slot;
5505
+ break;
5506
+ }
5507
+ }
5508
+ if (e.tagName === "IFRAME" || e.tagName === "FRAME") {
5509
+ let d = null;
5510
+ try {
5511
+ d = e.contentDocument;
5512
+ }
5513
+ catch {
5514
+ d = null;
5515
+ }
5516
+ if (d) {
5517
+ const r = e.getBoundingClientRect();
5518
+ ox += r.left + e.clientLeft;
5519
+ oy += r.top + e.clientTop;
5520
+ const inner = d.elementFromPoint(x - ox, y - oy);
5521
+ if (inner) {
5522
+ e = inner;
5523
+ continue;
5524
+ }
5525
+ }
5526
+ }
5527
+ break;
5528
+ }
5529
+ return e;
5530
+ };
5531
+ // Something that is not an interactive element can still act as one: a <div> with a click listener
5532
+ // shows it by a pointer cursor, a tabindex or an inline pointer handler.
5533
+ const clickable = (el) => {
5534
+ if (el.hasAttribute("tabindex"))
5535
+ return true;
5536
+ for (const a of Array.from(el.attributes))
5537
+ if (/^on(click|mousedown|mouseup|pointerdown|pointerup|touchstart|touchend)$/i.test(a.name))
5538
+ return true;
5539
+ // cursor is inherited: the clickable element is where a pointer cursor STARTS, not every child of it
5540
+ // (the icon inside a pointer-cursor menu item is not the item).
5541
+ try {
5542
+ const cs = (n) => (n.ownerDocument.defaultView || window).getComputedStyle(n).cursor;
5543
+ if (cs(el) !== "pointer")
5544
+ return false;
5545
+ const p = up(el);
5546
+ return !p || cs(p) !== "pointer";
5547
+ }
5548
+ catch {
5549
+ return false;
5550
+ }
5551
+ };
5552
+ // An element's own text: its visible text minus the text of controls nested inside it. A text node counts
5553
+ // when no interactive element sits between it and `el` (el itself need not be an interactive element).
5554
+ const ownText = (el) => {
5555
+ if (!el.querySelector(INTERACTIVE))
5556
+ return el.innerText;
5557
+ const parts = [];
5558
+ const walker = (el.ownerDocument || document).createTreeWalker(el, NodeFilter.SHOW_TEXT);
5559
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
5560
+ const p = n.parentElement;
5561
+ if (!p)
5562
+ continue;
5563
+ const c = p.closest(INTERACTIVE);
5564
+ if (!c || c === el || !el.contains(c))
5565
+ parts.push(n.textContent || "");
5566
+ }
5567
+ return parts.join(" ");
5568
+ };
5569
+ // What a pointer on `e` activates: its nearest interactive element; else the nearest ancestor that shows a
5570
+ // click affordance (a pointer-cursor <div> menu item whose icon was hit); else the nearest element whose own
5571
+ // text is label-sized (a listener-only item, "Delete account"), never a prose paragraph. Kept strictly inside
5572
+ // `stopAt` when given.
5573
+ const activatorOf = (e, stopAt) => {
5574
+ const inside = (n) => !stopAt || (n !== stopAt && composedContains(stopAt, n));
5575
+ const c = composedClosest(e);
5576
+ if (c)
5577
+ return inside(c) ? c : null;
5578
+ for (let n = e, i = 0; n && i < 8 && inside(n); n = up(n), i++) {
5579
+ if (n === n.ownerDocument.body || n === n.ownerDocument.documentElement)
5580
+ break;
5581
+ if (clickable(n))
5582
+ return n;
5583
+ }
5584
+ for (let n = e, i = 0; n && i < 3 && inside(n); n = up(n), i++) {
5585
+ if (n === n.ownerDocument.body || n === n.ownerDocument.documentElement)
5586
+ break;
5587
+ const tx = norm(ownText(n), 200);
5588
+ if (tx && tx.length <= 60)
5589
+ return n;
5590
+ }
5591
+ return null;
5592
+ };
5593
+ // For an element that is already the deepest one at a known point (a coordinate click, a drag end, the
5594
+ // focused element), the thing activated may be an ancestor without an interactive role.
5595
+ const control = composedClosest(node) || (arg.skipHit ? activatorOf(node, null) : null) || node;
5596
+ const isContainer = !composedClosest(node);
5597
+ const viaActivator = control !== node && isContainer;
5598
+ // Where a click on this element lands: the centre of its first box clipped to the viewport (what
5599
+ // Playwright clicks), after scrolling it into view if none of it is visible. Not computed for a key
5600
+ // press or an element that is already the deepest one at a known point.
5601
+ let position = null;
5602
+ const hitControl = (() => {
5603
+ if (arg.implicitSubmit || arg.skipHit)
5604
+ return null;
5605
+ const firstBox = () => node.getClientRects()[0] ?? node.getBoundingClientRect();
5606
+ const clip = (b) => ({ l: Math.max(b.left, 0), t: Math.max(b.top, 0), r: Math.min(b.right, innerWidth), b: Math.min(b.bottom, innerHeight) });
5607
+ let box = firstBox();
5608
+ if (!box || box.width === 0 || box.height === 0)
5609
+ return null;
5610
+ let v = clip(box);
5611
+ if (v.r <= v.l || v.b <= v.t) {
5612
+ node.scrollIntoView({ block: "center", inline: "center" });
5613
+ box = firstBox();
5614
+ v = clip(box);
5615
+ }
5616
+ if (v.r <= v.l || v.b <= v.t)
5617
+ return null;
5618
+ const px = (v.l + v.r) / 2;
5619
+ const py = (v.t + v.b) / 2;
5620
+ position = { x: px - box.left - node.clientLeft, y: py - box.top - node.clientTop };
5621
+ const deep = deepAt(px, py, node);
5622
+ if (!deep || deep === node)
5623
+ return null;
5624
+ const c = activatorOf(deep, node);
5625
+ return c && c !== control ? c : null;
5626
+ })();
5627
+ const nodes = [node, control, hitControl].filter((e, i, a) => !!e && a.indexOf(e) === i);
5628
+ const root = node.getRootNode();
5629
+ const byIdIn = (el, id) => {
5630
+ const r = el.getRootNode();
5631
+ return r.getElementById ? r.getElementById(id) : root.getElementById?.(id) ?? null;
5632
+ };
5633
+ const labelledBy = (el) => (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean)
5634
+ .map(id => byIdIn(el, id)).filter(Boolean).map(n => n.innerText || n.textContent || "").join(" ");
5635
+ const descriptors = [];
5636
+ const push = (s, max = 200) => { const v = norm(s, max); if (v && !descriptors.includes(v))
5637
+ descriptors.push(v); };
5638
+ // A form's action names the consequence ("/checkout", "/account/close"). Paths name the object
5639
+ // first; the patterns read verb first ("close account"), so push both orders. A submitter's
5640
+ // formaction overrides the form's action for that button.
5641
+ const pushPath = (path) => {
5642
+ if (!path)
5643
+ return;
5644
+ let p = path;
5645
+ try {
5646
+ p = decodeURIComponent(p);
5647
+ }
5648
+ catch { /* keep raw */ }
5649
+ const words = norm(p.replace(/^[a-z]+:\/\/[^/]+/i, "").replace(/[/\-_.?=&+%#]+/g, " "), 120);
5650
+ push(words);
5651
+ push(words.split(" ").reverse().join(" "));
5652
+ };
5653
+ const pushSubmit = (form, submitter) => {
5654
+ const fa = submitter?.getAttribute("formaction");
5655
+ pushPath(fa ? fa : form.getAttribute("action"));
5656
+ };
5657
+ // The DOM's own submit test: a <button> with no or an invalid type is a submit button.
5658
+ const isSubmitter = (el) => (el.tagName === "BUTTON" && el.type === "submit") ||
5659
+ (el.tagName === "INPUT" && /^(submit|image)$/.test(el.type));
5660
+ const pushControl = (el) => {
5661
+ push(el.getAttribute("aria-label"));
5662
+ push(labelledBy(el));
5663
+ if (el.tagName === "BUTTON" || (el.tagName === "INPUT" && BUTTON_INPUT.test(el.type || ""))) {
5664
+ push(el.value);
5665
+ }
5666
+ if (el.tagName === "INPUT" && el.type === "image")
5667
+ push(el.getAttribute("alt"));
5668
+ if (el.tagName === "IMG")
5669
+ push(el.alt);
5670
+ // <label for> names a button or submit input too.
5671
+ const labels = el.labels;
5672
+ if (labels)
5673
+ for (const l of Array.from(labels))
5674
+ push(l.innerText);
5675
+ push(el.getAttribute("title"));
5676
+ };
5677
+ // A descendant's name belongs to `el` only when no other control sits between them: the icon inside
5678
+ // a button labels the button; the icon button inside a clickable row does not label the row.
5679
+ const owns = (el, d) => { const c = composedClosest(d); return !c || c === el || composedContains(c, el); };
5680
+ const pushBelow = (el) => {
5681
+ Array.from(el.querySelectorAll("[aria-label]")).filter(d => owns(el, d)).slice(0, 5).forEach(d => push(d.getAttribute("aria-label")));
5682
+ Array.from(el.querySelectorAll("svg title")).filter(d => owns(el, d)).slice(0, 5).forEach(d => push(d.textContent));
5683
+ Array.from(el.querySelectorAll("img[alt]")).filter(d => owns(el, d)).slice(0, 5).forEach(d => push(d.alt));
5684
+ const sr = el.shadowRoot;
5685
+ if (sr) {
5686
+ push(sr.textContent, 120);
5687
+ Array.from(sr.querySelectorAll("img[alt],[aria-label]")).slice(0, 5).forEach(d => push(d.getAttribute("alt") || d.getAttribute("aria-label")));
5688
+ }
5689
+ // A control inside a shadow root shows its host's light-DOM text through a <slot>.
5690
+ Array.from(el.querySelectorAll("slot")).slice(0, 5).forEach(s => push(s.assignedNodes({ flatten: true }).map(n => n.textContent || "").join(" "), 120));
5691
+ };
5692
+ let label = "";
5693
+ let submitsVia = "";
5694
+ const nameOf = (el) => {
5695
+ const lb = norm(labelledBy(el), 200);
5696
+ if (lb)
5697
+ return lb;
5698
+ const al = el.getAttribute("aria-label");
5699
+ if (al && al.trim())
5700
+ return norm(al, 200);
5701
+ if (el.tagName === "INPUT" && el.value && BUTTON_INPUT.test(el.type || ""))
5702
+ return norm(el.value, 200);
5703
+ if (el.tagName === "INPUT" && el.type === "image" && el.getAttribute("alt"))
5704
+ return norm(el.getAttribute("alt"), 200);
5705
+ if (el.tagName === "IMG" && el.alt)
5706
+ return norm(el.alt, 200);
5707
+ if (isData(el)) {
5708
+ const labels = el.labels;
5709
+ const lt = labels ? Array.from(labels).map(l => l.innerText).join(" ") : "";
5710
+ return norm(lt || el.getAttribute("placeholder") || el.getAttribute("name"), 200);
5711
+ }
5712
+ const t = norm(el.innerText, 200);
5713
+ if (t)
5714
+ return t;
5715
+ const slotted = Array.from(el.querySelectorAll("slot")).map(s => s.assignedNodes({ flatten: true }).map(n => n.textContent || "").join(" ")).join(" ");
5716
+ const below = norm(slotted, 200) || el.querySelector("[aria-label]")?.getAttribute("aria-label") || el.querySelector("svg title")?.textContent
5717
+ || el.querySelector("img[alt]")?.alt
5718
+ || el.shadowRoot?.textContent;
5719
+ if (below && below.trim())
5720
+ return norm(below, 200);
5721
+ const lbl = el.labels;
5722
+ if (lbl && lbl.length)
5723
+ return norm(Array.from(lbl).map(l => l.innerText).join(" "), 200);
5724
+ return norm(el.getAttribute("title"), 200);
5725
+ };
5726
+ for (const el of nodes) {
5727
+ const isControl = (el === control && (!isContainer || viaActivator)) || el === hitControl;
5728
+ if (!isData(el)) {
5729
+ // Judged by its own names: a control, or something acting as one. A control's own text counts in
5730
+ // full; an acting-as-control element's own text by its opening 120 characters (a long div-button is
5731
+ // labelled by how it starts). A plain container or inert text is not a label: the hit-test above
5732
+ // judges the control a click on it lands on.
5733
+ const isLeaf = !el.querySelector(INTERACTIVE);
5734
+ // The resolved element as a leaf is judged by how its text starts, affordance or not: a refused inert
5735
+ // heading costs one force retry, a missed listener-only "Delete account" div cannot be undone.
5736
+ const actsAsControl = isControl || (el === node && isLeaf) || clickable(el);
5737
+ if (actsAsControl) {
5738
+ pushControl(el);
5739
+ push(norm(ownText(el), isControl ? 300 : 120), 300);
5740
+ pushBelow(el);
5741
+ }
5742
+ else if (el === node) {
5743
+ // A listener-only row that also holds a help link or an info button: its own opening text.
5744
+ push(norm(ownText(el), 120), 120);
5745
+ }
5746
+ const form = el.form;
5747
+ if (form && isSubmitter(el))
5748
+ pushSubmit(form, el);
5749
+ }
5750
+ else if (arg.implicitSubmit && el.tagName !== "TEXTAREA") {
5751
+ // Enter in a field submits its form through the form's default button.
5752
+ const form = el.form;
5753
+ if (form) {
5754
+ // The default button is the first submit button in tree order; if it is disabled, Enter submits nothing.
5755
+ const def = Array.from(form.elements).find(isSubmitter) ?? null;
5756
+ if (!(def && def.disabled))
5757
+ pushSubmit(form, def);
5758
+ if (def && !def.disabled) {
5759
+ pushControl(def);
5760
+ pushBelow(def);
5761
+ push(def.innerText);
5762
+ submitsVia = nameOf(def);
5763
+ }
5764
+ }
5765
+ }
5766
+ if (!label)
5767
+ label = nameOf(el);
5768
+ }
5769
+ if (submitsVia)
5770
+ label = `${label} (Enter submits "${submitsVia}")`;
5771
+ if (hitControl)
5772
+ label = `${label} (click lands on <${hitControl.tagName.toLowerCase()}> "${nameOf(hitControl)}")`;
5773
+ return { tag: control.tagName.toLowerCase(), label, descriptors, position: isContainer ? position : null };
5774
+ };
5775
+ const arg = { implicitSubmit: !!options.implicitSubmit, skipHit: !!options.skipHit };
5776
+ let info = null;
5777
+ try {
5778
+ info = "count" in element
5779
+ ? await element.evaluate(inPage, arg, { timeout: 3000 })
5780
+ : await element.evaluate(inPage, arg);
5781
+ }
5782
+ catch {
5783
+ return null;
5784
+ }
5785
+ if (!info)
5786
+ return null;
5787
+ let zone = "yellow";
5788
+ for (const d of info.descriptors)
5789
+ zone = this.maxZone(zone, this.classifyAction("click", d, { ignoreBlack: true }));
5790
+ return { zone, tag: info.tag, label: info.label, descriptors: info.descriptors, position: info.position ?? undefined };
5791
+ }
5792
+ /**
5793
+ * Gate a resolved element before it is activated. Returns a refusal when the element is red and the
5794
+ * caller did not pass force; otherwise the effective zone (the more severe of the string zone and the
5795
+ * element zone), the element's tag and label for the result and the audit trail, and - for a
5796
+ * container - the position that was judged, which the caller must click.
5797
+ */
5798
+ async gateElement(verb, selector, element, stringZone, force, options = {}) {
5799
+ const el = await this.classifyElement(element, options);
5800
+ if (!el)
5801
+ return { zone: stringZone };
5802
+ const target = { tag: el.tag, label: el.label };
5803
+ const zone = this.maxZone(stringZone, el.zone);
5804
+ if (el.zone === "red" && !force) {
5805
+ this.audit(verb, selector, "red", "blocked");
5806
+ return {
5807
+ zone,
5808
+ target,
5809
+ refusal: {
5810
+ success: false,
5811
+ screenshot: await this.screenshot(),
5812
+ message: `Red zone action requires --force: ${verb} "${selector}" resolves to <${el.tag}> "${el.label}"`,
5813
+ zone,
5814
+ target,
5815
+ },
5816
+ };
5817
+ }
5818
+ return { zone, target, position: el.position };
5819
+ }
5820
+ /**
5821
+ * The element a pointer at viewport (x, y) reaches: the topmost element there, followed into open shadow
5822
+ * roots and same-origin frames. Cross-origin frames stop at the <iframe>. (classifyElement carries the
5823
+ * same walk for a container's hit-test; the two are kept identical.)
5824
+ */
5825
+ async deepElementAt(x, y) {
5826
+ const page = await this.getPage();
5827
+ const h = await page.evaluateHandle(([px, py]) => {
5828
+ let e = document.elementFromPoint(px, py);
5829
+ let ox = 0, oy = 0;
5830
+ for (let i = 0; e && i < 12; i++) {
5831
+ const sr = e.shadowRoot;
5832
+ if (sr) {
5833
+ const inner = sr.elementFromPoint(px - ox, py - oy);
5834
+ if (inner && inner !== e) {
5835
+ e = inner;
5836
+ continue;
5837
+ }
5838
+ const host = e;
5839
+ const slot = Array.from(sr.querySelectorAll("slot")).find(s => s.assignedNodes().some(n => n.parentNode === host));
5840
+ if (slot) {
5841
+ e = slot;
5842
+ break;
5843
+ }
5844
+ }
5845
+ if (e.tagName === "IFRAME" || e.tagName === "FRAME") {
5846
+ let d = null;
5847
+ try {
5848
+ d = e.contentDocument;
5849
+ }
5850
+ catch {
5851
+ d = null;
5852
+ }
5853
+ if (d) {
5854
+ const r = e.getBoundingClientRect();
5855
+ ox += r.left + e.clientLeft;
5856
+ oy += r.top + e.clientTop;
5857
+ const inner = d.elementFromPoint(px - ox, py - oy);
5858
+ if (inner) {
5859
+ e = inner;
5860
+ continue;
5861
+ }
5862
+ }
5863
+ }
5864
+ break;
5865
+ }
5866
+ return e;
5867
+ }, [x, y]);
5868
+ const el = h.asElement();
5869
+ if (!el)
5870
+ await h.dispose();
5871
+ return el;
5872
+ }
5873
+ /**
5874
+ * Refusal message when a click at viewport point (x, y) would activate a red-zone control, else null.
5875
+ * Used by the NL test runner's "click at X, Y".
5876
+ */
5877
+ async pointRedZone(x, y, force) {
5878
+ if (force)
5879
+ return null;
5880
+ const handle = await this.deepElementAt(x, y);
5881
+ if (!handle)
5882
+ return null;
5883
+ try {
5884
+ const el = await this.classifyElement(handle, { skipHit: true });
5885
+ if (el?.zone !== "red")
5886
+ return null;
5887
+ this.audit("click", `at:${x},${y}`, "red", "blocked");
5888
+ return `Red zone action requires --force: click at ${x},${y} lands on <${el.tag}> "${el.label}"`;
5889
+ }
5890
+ finally {
5891
+ await handle.dispose().catch(() => { });
5892
+ }
5893
+ }
5894
+ /**
5895
+ * Refusal message when dragging `source` onto `target` would click a red-zone control, else null.
5896
+ * A drag is a mousedown where the pointer is over the source's centre and a mouseup over the target's
5897
+ * centre, and the browser fires click on the nearest common ancestor of the two elements actually under
5898
+ * the pointer. So the press and release points are resolved the way the pointer reaches them (beneath
5899
+ * nothing, into shadow roots and same-origin frames), each after scrolling its element into view as the
5900
+ * drag itself does, and the control containing both is judged.
5901
+ */
5902
+ async dragRedZone(source, target, force) {
5903
+ if (force)
5904
+ return null;
5905
+ const page = await this.getPage();
5906
+ const pointOf = async (sel) => {
5907
+ const loc = page.locator(sel).first();
5908
+ await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
5909
+ // Playwright presses the centre of the element's first box (a wrapped inline element's first line),
5910
+ // clipped to the viewport.
5911
+ const p = await loc.evaluate((el) => {
5912
+ const r = el.getClientRects()[0] ?? el.getBoundingClientRect();
5913
+ const l = Math.max(r.left, 0), rr = Math.min(r.right, innerWidth);
5914
+ const tp = Math.max(r.top, 0), bt = Math.min(r.bottom, innerHeight);
5915
+ return rr > l && bt > tp ? { x: (l + rr) / 2, y: (tp + bt) / 2 } : null;
5916
+ }, undefined, { timeout: 3000 }).catch(() => null);
5917
+ const at = p ? await this.deepElementAt(p.x, p.y) : null;
5918
+ return at ?? (await loc.elementHandle({ timeout: 3000 }).catch(() => null));
5919
+ };
5920
+ const a = await pointOf(source);
5921
+ const b = a ? await pointOf(target) : null;
5922
+ if (!a || !b) {
5923
+ await a?.dispose();
5924
+ return null;
5925
+ }
5926
+ const common = (await page.evaluateHandle(([x, y]) => {
5927
+ const up = (e) => {
5928
+ const slot = e.assignedSlot;
5929
+ if (slot)
5930
+ return slot;
5931
+ if (e.parentElement)
5932
+ return e.parentElement;
5933
+ const r = e.getRootNode();
5934
+ if (r.host)
5935
+ return r.host;
5936
+ try {
5937
+ return e.ownerDocument.defaultView?.frameElement ?? null;
5938
+ }
5939
+ catch {
5940
+ return null;
5941
+ }
5942
+ };
5943
+ const chain = new Set();
5944
+ for (let n = x; n; n = up(n))
5945
+ chain.add(n);
5946
+ let n = y;
5947
+ while (n && !chain.has(n))
5948
+ n = up(n);
5949
+ return n;
5950
+ }, [a, b])).asElement();
5951
+ try {
5952
+ if (!common)
5953
+ return null;
5954
+ const el = await this.classifyElement(common, { skipHit: true });
5955
+ if (el?.zone !== "red")
5956
+ return null;
5957
+ this.audit("drag", `${source} -> ${target}`, "red", "blocked");
5958
+ return `Red zone action requires --force: dragging "${source}" onto "${target}" clicks <${el.tag}> "${el.label}"`;
5959
+ }
5960
+ finally {
5961
+ await Promise.all([a.dispose(), b.dispose(), common?.dispose()].map(p => p?.catch(() => { })));
5962
+ }
5963
+ }
5964
+ /**
5965
+ * Zone of pressing `key` on whatever has focus. Enter activates the focused control or submits the
5966
+ * form of the focused field; Space activates a focused button-like control. Any other key, or nothing
5967
+ * focused, returns null (no activation to gate). Focus is followed into open shadow roots and
5968
+ * same-origin frames (a design-system app shell's host is not what has focus). Used by the keyboard
5969
+ * paths through keystrokeRedZone.
5970
+ */
5971
+ async classifyKeyActivation(key) {
5972
+ const raw = key.split("+").pop() || "";
5973
+ const last = raw.trim();
5974
+ // Playwright maps "\n" and "\r" to Enter, so they are Enter here too.
5975
+ const isEnter = /^(enter|numpadenter)$/i.test(last) || raw === "\n" || raw === "\r";
5976
+ const isSpace = last === "" ? key.endsWith(" ") : /^(space|spacebar)$/i.test(last);
5977
+ if (!isEnter && !isSpace)
5978
+ return null;
5979
+ const page = await this.getPage();
5980
+ const h = await page.evaluateHandle(() => {
5981
+ let a = document.activeElement;
5982
+ for (let i = 0; a && i < 12; i++) {
5983
+ const sr = a.shadowRoot;
5984
+ if (sr?.activeElement) {
5985
+ a = sr.activeElement;
5986
+ continue;
5987
+ }
5988
+ if (a.tagName === "IFRAME" || a.tagName === "FRAME") {
5989
+ let d = null;
5990
+ try {
5991
+ d = a.contentDocument;
5992
+ }
5993
+ catch {
5994
+ d = null;
5995
+ }
5996
+ if (d?.activeElement && d.activeElement !== d.body) {
5997
+ a = d.activeElement;
5998
+ continue;
5999
+ }
6000
+ }
6001
+ break;
6002
+ }
6003
+ return a && a !== document.body && a !== document.documentElement ? a : null;
6004
+ });
6005
+ const focused = h.asElement();
6006
+ if (!focused) {
6007
+ await h.dispose();
6008
+ return null;
6009
+ }
6010
+ try {
6011
+ if (isSpace) {
6012
+ const buttonLike = await focused.evaluate((el) => el.matches("button,summary,input[type=submit],input[type=button],input[type=reset],input[type=image],[role=button],[role=menuitem],[role=tab]")).catch(() => false);
6013
+ if (!buttonLike)
6014
+ return null;
6015
+ }
6016
+ const el = await this.classifyElement(focused, { implicitSubmit: isEnter, skipHit: true });
6017
+ // A combobox or listbox keeps focus on itself and points at the highlighted item with
6018
+ // aria-activedescendant; Enter activates that item (a command palette runs it) and, in a form, may also
6019
+ // submit the form. Both are judged and the more severe wins. The id is looked up only in the field's own
6020
+ // root, so a same-id element elsewhere on the page is never the one judged.
6021
+ let item = null;
6022
+ if (isEnter) {
6023
+ const ih = await focused.evaluateHandle((a) => {
6024
+ const ad = a.getAttribute("aria-activedescendant");
6025
+ return ad ? a.getRootNode().getElementById(ad) : null;
6026
+ }).catch(() => null);
6027
+ const ie = ih?.asElement() ?? null;
6028
+ if (ie) {
6029
+ const c = await this.classifyElement(ie, { skipHit: true });
6030
+ if (c)
6031
+ item = { zone: c.zone, tag: c.tag, label: c.label };
6032
+ }
6033
+ await ih?.dispose().catch(() => { });
6034
+ }
6035
+ const own = el ? { zone: el.zone, tag: el.tag, label: el.label } : null;
6036
+ if (own && item)
6037
+ return this.maxZone(own.zone, item.zone) === item.zone && item.zone !== own.zone ? item : own;
6038
+ return own ?? item;
6039
+ }
6040
+ finally {
6041
+ await focused.dispose().catch(() => { });
6042
+ }
6043
+ }
6044
+ /**
6045
+ * Refusal message when a keystroke would activate a red-zone control, else null. A key press is
6046
+ * checked as itself; typed text is checked for the activating characters it contains (a newline is
6047
+ * Enter, a space is Space). Every user-directed keyboard path calls this: the press_key and type_text
6048
+ * MCP tools, the CLI's press / type / keyboard commands and the daemon's equivalents.
6049
+ * Checked against what has focus now; a Tab inside typed text that moves focus first is not followed.
6050
+ */
6051
+ async keystrokeRedZone(input, force) {
6052
+ if (force)
6053
+ return null;
6054
+ const keys = [];
6055
+ if (input.key)
6056
+ keys.push(input.key);
6057
+ if (input.text !== undefined) {
6058
+ if (/[\r\n]/.test(input.text))
6059
+ keys.push("Enter");
6060
+ if (input.text.includes(" "))
6061
+ keys.push(" ");
6062
+ }
6063
+ for (const key of keys) {
6064
+ const a = await this.classifyKeyActivation(key);
6065
+ if (a?.zone === "red") {
6066
+ const what = input.key ? key : key === "Enter" ? "a newline in the typed text" : "a space in the typed text";
6067
+ this.audit("keyboard", input.key ?? JSON.stringify(input.text), "red", "blocked");
6068
+ return `Red zone action requires --force: ${what} would activate <${a.tag}> "${a.label}"`;
6069
+ }
6070
+ }
6071
+ return null;
6072
+ }
6073
+ /** The more severe of two zones (green < yellow < red < black). */
6074
+ maxZone(a, b) {
6075
+ const rank = { green: 0, yellow: 1, red: 2, black: 3 };
6076
+ return rank[b] > rank[a] ? b : a;
6077
+ }
5352
6078
  /**
5353
6079
  * Log an action to the audit trail.
5354
6080
  */