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/README.md +4 -0
- package/dist/browser.d.ts +83 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +738 -12
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +27 -5
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +12 -3
- package/dist/daemon.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +5 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/advanced-interaction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/advanced-interaction-tools.js +29 -5
- package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.js +8 -1
- package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +4 -0
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
*/
|