chrometools-mcp 3.5.1 → 3.5.3
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/CHANGELOG.md +18 -0
- package/README.md +8 -0
- package/bridge/bridge-client.js +12 -7
- package/browser/browser-manager.js +1 -1
- package/index.js +3 -14
- package/models/TextInputModel.js +22 -4
- package/models/TextareaModel.js +19 -3
- package/models/index.js +39 -1
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +103 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.5.3] - 2026-02-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Bridge connection errors flooding stderr** — Bridge client no longer prints `console.error` on connection failures (ECONNREFUSED). All bridge connection errors are now debug-level only (visible with `DEBUG=1`). Fixes MCP clients showing scary error messages for users without Chrome Extension
|
|
9
|
+
- **Unwanted auto-reconnect on startup** — `scheduleReconnect` now only triggers when a previously established connection is lost, not on initial connection failure. Eliminates 5 retry attempts when bridge service is simply not running
|
|
10
|
+
- **Removed auto-install bridge from startup** — Bridge auto-installation via `reg add` (Windows registry) was running on every server start, which is intrusive and unnecessary for users without Chrome Extension. Bridge installation remains available via `npx chrometools-mcp --install-bridge`
|
|
11
|
+
|
|
12
|
+
## [3.5.2] - 2026-02-16
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Modal/Dialog detection (React Portals)** — `analyzePage` now detects modals rendered via React Portals (antd, MUI, Bootstrap, Chakra, Element UI, Headless UI, Radix, Mantine). ModalModel class in Element Model system with `role="dialog"` / `aria-modal="true"` matching. Portal wrapper ancestors are force-included in APOM tree with compact format. Modal metadata includes title and action buttons
|
|
16
|
+
- **TxtInp `clear` action** — TextInput model now supports `executeModelAction(action: "clear")` for clearing pre-filled form fields
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **React controlled input clearing** — `type(clearFirst: true)` now works correctly with React/Vue/Angular controlled inputs (antd `<Input>`, MUI `<TextField>`, etc.). Uses native `HTMLInputElement.prototype.value` setter to bypass framework value trackers that ignored programmatic `el.value = ''` changes. Applied to both TextInputModel and TextareaModel
|
|
20
|
+
- **"ModelRegistry is not defined" error** — Fixed sporadic ReferenceError when calling `executeModelAction` or `click` after page navigation. Bare `ModelRegistry` identifier was inaccessible after `eval()` in strict mode contexts; changed to `window.ModelRegistry` reference
|
|
21
|
+
- **Modal output bloat** — ModalModel now only matches actual dialog elements (`role="dialog"`), not framework wrapper divs (`ant-modal-root`, `ant-modal-wrap`). Reduces `modalCount` from 3 to 1 per modal and forces wrapper ancestors to compact container format
|
|
22
|
+
|
|
5
23
|
## [3.5.1] - 2026-02-16
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -335,6 +335,14 @@ executeScenario({ name: "login_flow", parameters: { email: "user@test.com" } })
|
|
|
335
335
|
- Example: `executeModelAction({id: "input_34", action: "check"})`
|
|
336
336
|
- Example: `executeModelAction({selector: ".datepicker", action: "SetDate", params: {date: "2024-03-15"}})`
|
|
337
337
|
- See `models/` directory for available models and actions
|
|
338
|
+
- Available models: TxtInp, Sel, Btn, Chk, Radio, TxtArea, Link, Range, DatePicker, DateInp, FileInp, ColorInp, **Modal**, default
|
|
339
|
+
|
|
340
|
+
#### Modal/Dialog Support
|
|
341
|
+
- **Automatic detection**: APOM detects modals rendered via React Portals (antd, MUI, Bootstrap, Chakra, Mantine, Element UI, Headless UI, Radix)
|
|
342
|
+
- **Detection methods**: `role="dialog"`, `aria-modal="true"`, framework-specific CSS classes
|
|
343
|
+
- **Animation-proof**: Modal elements are included even during CSS appear animations (opacity: 0)
|
|
344
|
+
- **Rich metadata**: Modal nodes include `title` and `actions` (button labels) in metadata
|
|
345
|
+
- **In APOM tree**: Modals appear as `type: "dialog"` with `model: "Modal"`, containing all interactive children
|
|
338
346
|
|
|
339
347
|
**Why specialized tools matter:**
|
|
340
348
|
- ✅ Trigger proper browser events (click, input, change)
|
package/bridge/bridge-client.js
CHANGED
|
@@ -89,7 +89,7 @@ export async function connectToBridge() {
|
|
|
89
89
|
ws = new WebSocket(`ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
90
90
|
|
|
91
91
|
const connectTimeout = setTimeout(() => {
|
|
92
|
-
|
|
92
|
+
debugLog('Bridge connection timeout (5s)');
|
|
93
93
|
logToFile('TIMEOUT: Connection timeout after 5s');
|
|
94
94
|
ws?.close();
|
|
95
95
|
resolve(false);
|
|
@@ -115,22 +115,27 @@ export async function connectToBridge() {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
ws.on('close', (code, reason) => {
|
|
118
|
-
|
|
118
|
+
debugLog(`Bridge disconnected (code=${code}, reason=${reason || 'none'})`);
|
|
119
119
|
logToFile(`CLOSE: WebSocket closed, code=${code}, reason=${reason || 'none'}`);
|
|
120
|
+
const wasConnected = isConnected;
|
|
120
121
|
isConnected = false;
|
|
121
122
|
ws = null;
|
|
122
|
-
|
|
123
|
+
// Only reconnect if we were previously connected (lost connection)
|
|
124
|
+
// Don't reconnect if initial connection failed (bridge not running)
|
|
125
|
+
if (wasConnected) {
|
|
126
|
+
scheduleReconnect();
|
|
127
|
+
}
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
ws.on('error', (error) => {
|
|
126
131
|
clearTimeout(connectTimeout);
|
|
127
|
-
|
|
132
|
+
debugLog(`Bridge connection error: ${error.message}`);
|
|
128
133
|
logToFile(`ERROR: WebSocket error: ${error.message}`);
|
|
129
134
|
resolve(false);
|
|
130
135
|
});
|
|
131
136
|
|
|
132
137
|
} catch (error) {
|
|
133
|
-
|
|
138
|
+
debugLog(`Failed to create WebSocket: ${error.message}`);
|
|
134
139
|
logToFile(`EXCEPTION: Failed to create WebSocket: ${error.message}`);
|
|
135
140
|
resolve(false);
|
|
136
141
|
}
|
|
@@ -143,12 +148,12 @@ export async function connectToBridge() {
|
|
|
143
148
|
function scheduleReconnect() {
|
|
144
149
|
if (reconnectTimer) return;
|
|
145
150
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
146
|
-
|
|
151
|
+
debugLog(`Bridge: max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`);
|
|
147
152
|
return;
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
reconnectAttempts++;
|
|
151
|
-
debugLog(`Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
|
156
|
+
debugLog(`Scheduling Bridge reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
|
152
157
|
|
|
153
158
|
reconnectTimer = setTimeout(async () => {
|
|
154
159
|
reconnectTimer = null;
|
|
@@ -264,7 +264,7 @@ function scheduleBridgeReconnect() {
|
|
|
264
264
|
if (attempt < delays.length) {
|
|
265
265
|
setTimeout(tryConnect, delays[attempt]);
|
|
266
266
|
} else {
|
|
267
|
-
|
|
267
|
+
debugLog('Bridge not available after Chrome launch (extension may not be installed)');
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {Server} from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -584,7 +584,7 @@ async function executeToolInternal(name, args) {
|
|
|
584
584
|
|
|
585
585
|
// Initialize registry if needed
|
|
586
586
|
const registry = window.__MODEL_REGISTRY__ || (() => {
|
|
587
|
-
const reg = new ModelRegistry();
|
|
587
|
+
const reg = new window.ModelRegistry();
|
|
588
588
|
if (window.ELEMENT_MODELS_CLASSES) {
|
|
589
589
|
reg.registerAll(window.ELEMENT_MODELS_CLASSES);
|
|
590
590
|
}
|
|
@@ -3916,18 +3916,7 @@ async function main() {
|
|
|
3916
3916
|
console.error("[chrometools-mcp] GUI mode requires X server (DISPLAY=" + (process.env.DISPLAY || "not set") + ")");
|
|
3917
3917
|
}
|
|
3918
3918
|
|
|
3919
|
-
//
|
|
3920
|
-
try {
|
|
3921
|
-
const { isBridgeInstalled, installBridge } = await import('./bridge/install.js');
|
|
3922
|
-
if (!isBridgeInstalled()) {
|
|
3923
|
-
console.error('[chrometools-mcp] Bridge not installed. Auto-installing...');
|
|
3924
|
-
await installBridge({ silent: true });
|
|
3925
|
-
}
|
|
3926
|
-
} catch (e) {
|
|
3927
|
-
console.error('[chrometools-mcp] Bridge auto-install failed:', e.message);
|
|
3928
|
-
}
|
|
3929
|
-
|
|
3930
|
-
// Connect to Bridge Service (if running)
|
|
3919
|
+
// Connect to Bridge Service (if running — optional, for Chrome Extension integration)
|
|
3931
3920
|
await startWebSocketServer();
|
|
3932
3921
|
|
|
3933
3922
|
// Register handler for syncing active tab when user switches tabs
|
package/models/TextInputModel.js
CHANGED
|
@@ -42,13 +42,22 @@ export class TextInputModel extends BaseInputModel {
|
|
|
42
42
|
try {
|
|
43
43
|
// Method 1: Try Puppeteer typing (works for most cases)
|
|
44
44
|
try {
|
|
45
|
-
// Focus and clear using
|
|
45
|
+
// Focus and clear using native setter (works with React/Vue/Angular controlled inputs)
|
|
46
46
|
await withTimeout(
|
|
47
47
|
() => this.element.evaluate((el, shouldClear) => {
|
|
48
48
|
el.focus();
|
|
49
49
|
el.click();
|
|
50
50
|
if (shouldClear) {
|
|
51
|
-
|
|
51
|
+
// Use native HTMLInputElement setter to bypass React's value tracker
|
|
52
|
+
// React overrides the value setter and ignores programmatic changes via el.value = ''
|
|
53
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
54
|
+
window.HTMLInputElement.prototype, 'value'
|
|
55
|
+
)?.set;
|
|
56
|
+
if (nativeSetter) {
|
|
57
|
+
nativeSetter.call(el, '');
|
|
58
|
+
} else {
|
|
59
|
+
el.value = '';
|
|
60
|
+
}
|
|
52
61
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
53
62
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
54
63
|
}
|
|
@@ -78,11 +87,20 @@ export class TextInputModel extends BaseInputModel {
|
|
|
78
87
|
// Fall through to JS method
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
// Method 2: Fallback to direct JS value setting
|
|
90
|
+
// Method 2: Fallback to direct JS value setting (with React-compatible native setter)
|
|
82
91
|
await withTimeout(
|
|
83
92
|
() => this.element.evaluate((el, newValue, shouldClear) => {
|
|
84
93
|
el.focus();
|
|
85
|
-
|
|
94
|
+
const finalValue = shouldClear ? newValue : el.value + newValue;
|
|
95
|
+
// Use native setter to bypass React's value tracker
|
|
96
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
97
|
+
window.HTMLInputElement.prototype, 'value'
|
|
98
|
+
)?.set;
|
|
99
|
+
if (nativeSetter) {
|
|
100
|
+
nativeSetter.call(el, finalValue);
|
|
101
|
+
} else {
|
|
102
|
+
el.value = finalValue;
|
|
103
|
+
}
|
|
86
104
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
87
105
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
88
106
|
}, value, clearFirst),
|
package/models/TextareaModel.js
CHANGED
|
@@ -54,7 +54,15 @@ export class TextareaModel extends BaseInputModel {
|
|
|
54
54
|
await withTimeout(
|
|
55
55
|
() => this.element.evaluate(el => {
|
|
56
56
|
el.focus();
|
|
57
|
-
|
|
57
|
+
// Use native setter to bypass React's value tracker
|
|
58
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
59
|
+
window.HTMLTextAreaElement.prototype, 'value'
|
|
60
|
+
)?.set;
|
|
61
|
+
if (nativeSetter) {
|
|
62
|
+
nativeSetter.call(el, '');
|
|
63
|
+
} else {
|
|
64
|
+
el.value = '';
|
|
65
|
+
}
|
|
58
66
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
59
67
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
60
68
|
}),
|
|
@@ -82,11 +90,19 @@ export class TextareaModel extends BaseInputModel {
|
|
|
82
90
|
// Fall through to JS method
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
// Method 2: Fallback to direct JS value setting
|
|
93
|
+
// Method 2: Fallback to direct JS value setting (with React-compatible native setter)
|
|
86
94
|
await withTimeout(
|
|
87
95
|
() => this.element.evaluate((el, newValue, shouldClear) => {
|
|
88
96
|
el.focus();
|
|
89
|
-
|
|
97
|
+
const finalValue = shouldClear ? newValue : el.value + newValue;
|
|
98
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
99
|
+
window.HTMLTextAreaElement.prototype, 'value'
|
|
100
|
+
)?.set;
|
|
101
|
+
if (nativeSetter) {
|
|
102
|
+
nativeSetter.call(el, finalValue);
|
|
103
|
+
} else {
|
|
104
|
+
el.value = finalValue;
|
|
105
|
+
}
|
|
90
106
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
91
107
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
92
108
|
}, value, clearFirst),
|
package/models/index.js
CHANGED
|
@@ -19,7 +19,7 @@ class TextInputModel extends ElementModel {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
getActions() {
|
|
22
|
-
return ['type', 'click', 'hover', 'screenshot'];
|
|
22
|
+
return ['type', 'clear', 'click', 'hover', 'screenshot'];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
matches(element, elementType) {
|
|
@@ -32,6 +32,7 @@ class TextInputModel extends ElementModel {
|
|
|
32
32
|
getActionHandler(actionName) {
|
|
33
33
|
const handlers = {
|
|
34
34
|
'type': 'executeTypeAction',
|
|
35
|
+
'clear': 'executeTypeAction',
|
|
35
36
|
'click': 'executeClickAction',
|
|
36
37
|
'hover': 'executeHoverAction',
|
|
37
38
|
'screenshot': 'executeScreenshotAction'
|
|
@@ -381,6 +382,42 @@ class ColorInputModel extends ElementModel {
|
|
|
381
382
|
}
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Modal/Dialog Model
|
|
387
|
+
* Handles: Modal dialogs, popups, overlays (React Portals, framework modals)
|
|
388
|
+
* Detects elements rendered via portals outside the main React tree
|
|
389
|
+
*/
|
|
390
|
+
class ModalModel extends ElementModel {
|
|
391
|
+
getName() {
|
|
392
|
+
return 'Modal';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
getActions() {
|
|
396
|
+
return ['screenshot', 'close', 'scrollTo'];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
getPriority() {
|
|
400
|
+
return 200; // High priority — check before containers
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
matches(element, elementType) {
|
|
404
|
+
// Only match actual dialog elements, not framework wrappers
|
|
405
|
+
// Framework wrappers are detected separately for portal inclusion
|
|
406
|
+
if (element.getAttribute('role') === 'dialog') return true;
|
|
407
|
+
if (element.getAttribute('aria-modal') === 'true') return true;
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
getActionHandler(actionName) {
|
|
412
|
+
const handlers = {
|
|
413
|
+
'screenshot': 'executeScreenshotAction',
|
|
414
|
+
'close': 'executeClickAction',
|
|
415
|
+
'scrollTo': 'executeScrollToAction'
|
|
416
|
+
};
|
|
417
|
+
return handlers[actionName] || null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
384
421
|
/**
|
|
385
422
|
* Default Model (fallback for non-interactive elements)
|
|
386
423
|
*/
|
|
@@ -424,6 +461,7 @@ const MODELS = [
|
|
|
424
461
|
DateInputModel,
|
|
425
462
|
FileInputModel,
|
|
426
463
|
ColorInputModel,
|
|
464
|
+
ModalModel,
|
|
427
465
|
DefaultModel
|
|
428
466
|
];
|
|
429
467
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrometools-mcp",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.3",
|
|
4
4
|
"description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,7 +16,7 @@ function initializeModelRegistry() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// Create and populate registry
|
|
19
|
-
const registry = new ModelRegistry();
|
|
19
|
+
const registry = new (window.ModelRegistry || ModelRegistry)();
|
|
20
20
|
|
|
21
21
|
// Register all models (order doesn't matter, priority is handled internally)
|
|
22
22
|
if (typeof window !== 'undefined' && window.ELEMENT_MODELS_CLASSES) {
|
|
@@ -73,12 +73,51 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
73
73
|
let idCounter = 0;
|
|
74
74
|
const elementIds = new WeakMap();
|
|
75
75
|
const interactiveElements = new WeakSet();
|
|
76
|
+
const modalElements = new WeakSet(); // Elements inside modal portals (skip visibility checks)
|
|
77
|
+
const modalAncestors = new WeakSet(); // Portal wrapper ancestors (force compact format)
|
|
76
78
|
|
|
77
79
|
// First pass: mark all interactive elements
|
|
78
80
|
if (interactiveOnly) {
|
|
79
81
|
markInteractiveElements(document.body);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// Second pass: detect modal/dialog portals and force-mark for inclusion
|
|
85
|
+
// Modals are rendered via React Portals outside the main tree and may have
|
|
86
|
+
// opacity: 0 during animation — force-include them and all their descendants
|
|
87
|
+
if (interactiveOnly) {
|
|
88
|
+
// Framework-specific portal container patterns (used only for detection, not model assignment)
|
|
89
|
+
const portalPatterns = [
|
|
90
|
+
'ant-modal-root', 'ant-modal-wrap',
|
|
91
|
+
'MuiDialog-root', 'MuiModal-root',
|
|
92
|
+
'modal-dialog',
|
|
93
|
+
'chakra-modal__content-container',
|
|
94
|
+
'el-dialog__wrapper', 'el-overlay-dialog',
|
|
95
|
+
'headlessui-dialog',
|
|
96
|
+
'radix-dialog',
|
|
97
|
+
'mantine-Modal-root',
|
|
98
|
+
];
|
|
99
|
+
function isPortalElement(el) {
|
|
100
|
+
if (el.getAttribute('role') === 'dialog') return true;
|
|
101
|
+
if (el.getAttribute('aria-modal') === 'true') return true;
|
|
102
|
+
const classes = el.className || '';
|
|
103
|
+
if (typeof classes !== 'string') return false;
|
|
104
|
+
return portalPatterns.some(p => classes.includes(p));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Scan body direct children for framework-specific portal roots
|
|
108
|
+
for (const child of document.body.children) {
|
|
109
|
+
if (isPortalElement(child)) {
|
|
110
|
+
forceMarkModalTree(child);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Also find deeper dialog elements (some frameworks nest portals)
|
|
114
|
+
document.querySelectorAll('[role="dialog"], [aria-modal="true"]').forEach(el => {
|
|
115
|
+
if (!modalElements.has(el)) {
|
|
116
|
+
forceMarkModalTree(el);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
// Build tree from body
|
|
83
122
|
result.tree = buildNode(document.body, null, 0, []);
|
|
84
123
|
|
|
@@ -408,6 +447,31 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
408
447
|
interactiveElements.add(document.body);
|
|
409
448
|
}
|
|
410
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Force-mark modal portal element and all its descendants for inclusion in APOM tree.
|
|
452
|
+
* Modal portals (React Portals) are rendered outside the main tree and may have
|
|
453
|
+
* opacity: 0 during CSS animations — this ensures they're always included.
|
|
454
|
+
*/
|
|
455
|
+
function forceMarkModalTree(element) {
|
|
456
|
+
modalElements.add(element);
|
|
457
|
+
interactiveElements.add(element);
|
|
458
|
+
// Mark all descendants — inputs, buttons, etc. inside the modal
|
|
459
|
+
element.querySelectorAll('*').forEach(el => {
|
|
460
|
+
modalElements.add(el);
|
|
461
|
+
interactiveElements.add(el);
|
|
462
|
+
});
|
|
463
|
+
// Mark ancestors up to body (so the path from body to modal is traversed)
|
|
464
|
+
// Must add to modalElements for isVisible() bypass (0x0 dimensions, opacity:0)
|
|
465
|
+
// Also mark as modalAncestors to force compact format (portal wrappers are pass-through)
|
|
466
|
+
let current = element.parentElement;
|
|
467
|
+
while (current && current !== document.body) {
|
|
468
|
+
modalElements.add(current);
|
|
469
|
+
modalAncestors.add(current);
|
|
470
|
+
interactiveElements.add(current);
|
|
471
|
+
current = current.parentElement;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
411
475
|
/**
|
|
412
476
|
* Check if element is in viewport
|
|
413
477
|
*/
|
|
@@ -431,7 +495,11 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
431
495
|
*/
|
|
432
496
|
function isVisible(el) {
|
|
433
497
|
// Check dimensions first (works for fixed position elements)
|
|
434
|
-
|
|
498
|
+
// Exception: modal portal wrapper divs may have 0x0 dimensions
|
|
499
|
+
// while their visible content (dialog, inputs, buttons) does not
|
|
500
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
|
|
501
|
+
if (!modalElements.has(el)) return false;
|
|
502
|
+
}
|
|
435
503
|
|
|
436
504
|
// Check computed styles
|
|
437
505
|
const style = window.getComputedStyle(el);
|
|
@@ -439,11 +507,12 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
439
507
|
return false;
|
|
440
508
|
}
|
|
441
509
|
|
|
442
|
-
// Check opacity, but allow exceptions for
|
|
443
|
-
// (checkboxes, radios, file inputs
|
|
510
|
+
// Check opacity, but allow exceptions for:
|
|
511
|
+
// - inputs styled with opacity:0 (checkboxes, radios, file inputs with custom overlay)
|
|
512
|
+
// - elements inside modal portals (opacity: 0 during CSS appear animation)
|
|
444
513
|
const tag = el.tagName.toLowerCase();
|
|
445
514
|
const isStylableInput = tag === 'input' && ['checkbox', 'radio', 'file'].includes(el.type);
|
|
446
|
-
if (style.opacity === '0' && !isStylableInput) {
|
|
515
|
+
if (style.opacity === '0' && !isStylableInput && !modalElements.has(el)) {
|
|
447
516
|
return false;
|
|
448
517
|
}
|
|
449
518
|
|
|
@@ -457,7 +526,8 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
457
526
|
|
|
458
527
|
// Additional check: element should be in viewport or have offsetParent
|
|
459
528
|
// This handles elements inside position:fixed containers (Angular Material)
|
|
460
|
-
|
|
529
|
+
// Exception: modal portal elements may lack offsetParent
|
|
530
|
+
return el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky' || modalElements.has(el);
|
|
461
531
|
}
|
|
462
532
|
|
|
463
533
|
/**
|
|
@@ -505,8 +575,31 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
505
575
|
}
|
|
506
576
|
}
|
|
507
577
|
|
|
578
|
+
// Modal containers: promote to interactive node with metadata
|
|
579
|
+
if (modelName === 'Modal') {
|
|
580
|
+
elementType.isInteractive = true;
|
|
581
|
+
elementType.type = 'dialog';
|
|
582
|
+
// Extract modal title
|
|
583
|
+
const titleEl = element.querySelector(
|
|
584
|
+
'.ant-modal-title, .MuiDialogTitle-root, [class*="modal-title"], [class*="dialog-title"], .modal-header h5, .modal-header h4'
|
|
585
|
+
);
|
|
586
|
+
const titleText = titleEl ? titleEl.textContent.trim().substring(0, 100) : null;
|
|
587
|
+
// Extract action buttons
|
|
588
|
+
const buttons = element.querySelectorAll('button');
|
|
589
|
+
const actions = Array.from(buttons)
|
|
590
|
+
.map(b => b.textContent.trim())
|
|
591
|
+
.filter(t => t && t.length > 0 && t.length < 30);
|
|
592
|
+
elementType.metadata = {
|
|
593
|
+
...(elementType.metadata || {}),
|
|
594
|
+
...(titleText ? { title: titleText } : {}),
|
|
595
|
+
...(actions.length ? { actions: actions.slice(0, 5) } : {})
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
508
599
|
// Build node - minimize non-interactive parents
|
|
509
|
-
|
|
600
|
+
// Modal ancestors (portal wrappers) are forced to compact format —
|
|
601
|
+
// they have onclick handlers (close on outside click) but are just pass-through containers
|
|
602
|
+
const isInteractive = elementType.isInteractive && !modalAncestors.has(element);
|
|
510
603
|
|
|
511
604
|
// Build node structure based on mode
|
|
512
605
|
let node;
|
|
@@ -562,16 +655,14 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
562
655
|
|
|
563
656
|
// Update metadata counters
|
|
564
657
|
result.metadata.totalElements++;
|
|
565
|
-
if (
|
|
658
|
+
if (isInteractive) {
|
|
566
659
|
result.metadata.interactiveCount++;
|
|
567
660
|
}
|
|
568
661
|
if (elementType.type === 'form') {
|
|
569
662
|
result.metadata.formCount++;
|
|
570
663
|
}
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
result.metadata.modalCount++;
|
|
574
|
-
}
|
|
664
|
+
if (modelName === 'Modal') {
|
|
665
|
+
result.metadata.modalCount++;
|
|
575
666
|
}
|
|
576
667
|
if (depth > result.metadata.maxDepth) {
|
|
577
668
|
result.metadata.maxDepth = depth;
|