chrometools-mcp 3.5.5 → 3.6.0
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 +28 -0
- package/README.md +104 -8
- package/browser/browser-manager.js +26 -2
- package/browser/page-manager.js +89 -0
- package/element-finder-utils.js +38 -0
- package/index.js +330 -52
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +56 -1
- package/server/tool-definitions.js +30 -4
- package/server/tool-groups.js +1 -1
- package/server/tool-schemas.js +19 -5
- package/utils/actions/click-action.js +70 -1
- package/utils/element-actions.js +8 -5
- package/utils/platform-utils.js +17 -2
- package/utils/post-click-diagnostics.js +21 -4
- package/NUL +0 -1
|
@@ -37,9 +37,16 @@ function initializeModelRegistry() {
|
|
|
37
37
|
*
|
|
38
38
|
* @param {boolean} interactiveOnly - Only include interactive elements and their parents
|
|
39
39
|
* @param {boolean} viewportOnly - Only include elements visible in current viewport
|
|
40
|
+
* @param {Object} portalOpts - Portal scan options: { include: boolean, selectors: string[] }
|
|
41
|
+
* When include=true, force-includes contents of React Portal containers
|
|
42
|
+
* (e.g. #menu-popup-root, #tooltip-root) that live outside main React root.
|
|
40
43
|
* @returns {Object} APOM tree structure
|
|
41
44
|
*/
|
|
42
|
-
function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
45
|
+
function buildAPOMTree(interactiveOnly = true, viewportOnly = false, portalOpts = undefined) {
|
|
46
|
+
const portalInclude = portalOpts ? portalOpts.include !== false : true;
|
|
47
|
+
const portalSelectors = (portalOpts && Array.isArray(portalOpts.selectors) && portalOpts.selectors.length > 0)
|
|
48
|
+
? portalOpts.selectors
|
|
49
|
+
: ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]'];
|
|
43
50
|
const pageId = `page_${btoa(window.location.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 20)}_${Date.now()}`;
|
|
44
51
|
|
|
45
52
|
// Initialize model registry (Strategy Pattern setup)
|
|
@@ -116,6 +123,54 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
|
|
|
116
123
|
forceMarkModalTree(el);
|
|
117
124
|
}
|
|
118
125
|
});
|
|
126
|
+
|
|
127
|
+
// Generic portal containers (menus, tooltips, popovers) — opt-in by selector list.
|
|
128
|
+
// Unlike framework modals, these are app-defined wrappers (e.g. #menu-popup-root from
|
|
129
|
+
// client/index.html). When non-empty, force-include their subtree.
|
|
130
|
+
if (portalInclude && portalSelectors.length > 0) {
|
|
131
|
+
try {
|
|
132
|
+
document.querySelectorAll(portalSelectors.join(',')).forEach(container => {
|
|
133
|
+
if (!container.children || container.children.length === 0) return;
|
|
134
|
+
for (const child of container.children) {
|
|
135
|
+
if (!modalElements.has(child)) {
|
|
136
|
+
forceMarkModalTree(child);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// Invalid selector in portalSelectors — skip silently, do not break analyzePage
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// In-tree popups (Popper/Tippy/FloatingUI/custom contextMenu pattern). Some libs
|
|
146
|
+
// render the popup inside a 0-height inline wrapper and then absolute-position it
|
|
147
|
+
// out of the wrapper's box. Default isVisible() drops the wrapper (height: 0) and
|
|
148
|
+
// every popup descendant is lost. Detect: a 0×0 wrapper whose subtree contains a
|
|
149
|
+
// positioned (absolute/fixed) child with real bounds — force-mark that child.
|
|
150
|
+
if (portalInclude) {
|
|
151
|
+
function findPositionedPopup(el, maxDepth) {
|
|
152
|
+
if (maxDepth <= 0) return null;
|
|
153
|
+
for (const child of el.children) {
|
|
154
|
+
if (modalElements.has(child)) continue;
|
|
155
|
+
const cs = window.getComputedStyle(child);
|
|
156
|
+
if ((cs.position === 'absolute' || cs.position === 'fixed') &&
|
|
157
|
+
child.offsetWidth > 0 && child.offsetHeight > 0) {
|
|
158
|
+
return child;
|
|
159
|
+
}
|
|
160
|
+
const deeper = findPositionedPopup(child, maxDepth - 1);
|
|
161
|
+
if (deeper) return deeper;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const allEls = document.body.querySelectorAll('*');
|
|
167
|
+
for (const el of allEls) {
|
|
168
|
+
// Only zero-sized wrappers with children are candidates — cheap filter
|
|
169
|
+
if ((el.offsetHeight !== 0 && el.offsetWidth !== 0) || el.children.length === 0) continue;
|
|
170
|
+
const popup = findPositionedPopup(el, 3);
|
|
171
|
+
if (popup) forceMarkModalTree(popup);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
119
174
|
}
|
|
120
175
|
|
|
121
176
|
// Build tree from body
|
|
@@ -37,6 +37,10 @@ export const toolDefinitions = [
|
|
|
37
37
|
waitAfter: { type: "number", description: "Wait ms (default: 1500)" },
|
|
38
38
|
screenshot: { type: "boolean", description: "Screenshot (default: false)" },
|
|
39
39
|
timeout: { type: "number", description: "Max wait ms (default: 30000)" },
|
|
40
|
+
waitForSelector: { type: "string", description: "CSS selector to wait for after click (atomic click+wait). Use for dropdowns/popups that render into portals." },
|
|
41
|
+
waitTimeoutMs: { type: "number", description: "Timeout for waitForSelector in ms (default: 2000)." },
|
|
42
|
+
waitForRouteChange: { type: "boolean", description: "SPA route wait: after click, wait for location.pathname+search to change vs before; reports 'routeChanged:true/false'. Does not fail click on timeout. For URL-less view changes use waitForSelector." },
|
|
43
|
+
autoAnalyzeAfter: { type: "boolean", description: "After click, diff APOM and append '+N appeared: id:\"text\"' delta to result. New ids are pre-registered for follow-up clicks. Use for dropdowns/menus opening with new options." },
|
|
40
44
|
},
|
|
41
45
|
},
|
|
42
46
|
},
|
|
@@ -92,18 +96,18 @@ export const toolDefinitions = [
|
|
|
92
96
|
},
|
|
93
97
|
{
|
|
94
98
|
name: "screenshot",
|
|
95
|
-
description: "Capture element image (5-10k tokens). Use analyzePage for form data/validation (8-10k tokens).",
|
|
99
|
+
description: "Capture element image (5-10k tokens), or full viewport when no id/selector is given. Use analyzePage for form data/validation (8-10k tokens).",
|
|
96
100
|
inputSchema: {
|
|
97
101
|
type: "object",
|
|
98
102
|
properties: {
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
id: { type: "string", description: "APOM element ID. Mutually exclusive with selector. Omit both for viewport screenshot." },
|
|
104
|
+
selector: { type: "string", description: "CSS selector. Mutually exclusive with id. Omit both for viewport screenshot." },
|
|
105
|
+
padding: { type: "number", description: "Padding px (default: 0). Ignored for viewport." },
|
|
101
106
|
maxWidth: { type: "number", description: "Max width px (default: 1024, null=original)" },
|
|
102
107
|
maxHeight: { type: "number", description: "Max height px (default: 8000, null=original)" },
|
|
103
108
|
quality: { type: "number", minimum: 1, maximum: 100, description: "JPEG quality (default: 40)" },
|
|
104
109
|
format: { type: "string", enum: ["png", "jpeg", "auto"], description: "Format (default: jpeg)" },
|
|
105
110
|
},
|
|
106
|
-
required: ["selector"],
|
|
107
111
|
},
|
|
108
112
|
},
|
|
109
113
|
{
|
|
@@ -364,6 +368,25 @@ Examples:
|
|
|
364
368
|
required: ["url"],
|
|
365
369
|
},
|
|
366
370
|
},
|
|
371
|
+
{
|
|
372
|
+
name: "listFrames",
|
|
373
|
+
description: "List all frames (main + iframes) on the current page with url/name/isMain, plus the currently active frame. Use to discover cross-origin iframes (e.g. app.example.com), then switchFrame into one.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "switchFrame",
|
|
381
|
+
description: "Set the active frame so click/type/hover/analyzePage/find/executeScript/waitForElement run INSIDE it — required to automate cross-origin iframes (resolved via CDP, bypassing Same-Origin Policy). Call with no args to reset to the main frame. Auto-resets on navigateTo.",
|
|
382
|
+
inputSchema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
frameUrl: { type: "string", description: "Substring matched against each frame's URL (e.g. 'app.example.com'). Mutually exclusive with frameSelector." },
|
|
386
|
+
frameSelector: { type: "string", description: "CSS selector of the <iframe> element; its content frame becomes active. Mutually exclusive with frameUrl." },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
367
390
|
{
|
|
368
391
|
name: "getFigmaFrame",
|
|
369
392
|
description: "Export Figma frame as PNG. Requires API token and file/node IDs.",
|
|
@@ -503,6 +526,7 @@ Examples:
|
|
|
503
526
|
properties: {
|
|
504
527
|
description: { type: "string", description: "Natural language description" },
|
|
505
528
|
maxResults: { type: "number", minimum: 1, maximum: 20, description: "Max candidates (default: 5)" },
|
|
529
|
+
minConfidence: { type: "number", minimum: 0, maximum: 1, description: "Confidence threshold (default: 0.6) for auto-executing `action`. Below it (or too close to runner-up), action is skipped and candidates returned with 'actionSkipped'. Prevents auto-clicking the wrong control." },
|
|
506
530
|
action: {
|
|
507
531
|
type: "object",
|
|
508
532
|
properties: {
|
|
@@ -532,6 +556,8 @@ Examples:
|
|
|
532
556
|
groupBy: { type: "string", description: "Group elements: 'type' or 'flat' (default: 'type')", enum: ["type", "flat"] },
|
|
533
557
|
viewportOnly: { type: "boolean", description: "Only analyze elements in current viewport (default: false). Reduces output for long pages." },
|
|
534
558
|
diff: { type: "boolean", description: "Return only changes since last analysis: {added, removed, changed} (default: false)." },
|
|
559
|
+
includePortals: { type: "boolean", description: "Include React Portal contents — menus, tooltips, popovers outside main root (default: true). Without this, dropdown items are invisible." },
|
|
560
|
+
portalSelectors: { type: "array", items: { type: "string" }, description: "Custom portal root CSS selectors. Default: ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]']." },
|
|
535
561
|
},
|
|
536
562
|
},
|
|
537
563
|
},
|
package/server/tool-groups.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export const toolGroups = {
|
|
8
8
|
core: ['ping', 'openBrowser', 'executeScript', 'navigateTo'],
|
|
9
9
|
|
|
10
|
-
interaction: ['click', 'type', 'scrollTo', 'waitForElement', 'hover', 'selectOption', 'selectFromGroup', 'drag', 'scrollHorizontal'],
|
|
10
|
+
interaction: ['click', 'type', 'scrollTo', 'waitForElement', 'hover', 'selectOption', 'selectFromGroup', 'drag', 'scrollHorizontal', 'switchFrame', 'listFrames'],
|
|
11
11
|
|
|
12
12
|
inspection: ['getElement', 'getComputedCss', 'getBoxModel', 'screenshot', 'saveScreenshot'],
|
|
13
13
|
|
package/server/tool-schemas.js
CHANGED
|
@@ -23,6 +23,10 @@ export const ClickSchema = z.object({
|
|
|
23
23
|
timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
|
|
24
24
|
skipNetworkWait: z.boolean().optional().describe("Skip waiting for network requests (default: false). Use for forms with long-polling/WebSockets to avoid timeouts."),
|
|
25
25
|
networkWaitTimeout: z.number().optional().describe("Maximum time to wait for network requests in ms (default: 3000). Only used if skipNetworkWait is false."),
|
|
26
|
+
waitForSelector: z.string().optional().describe("CSS selector to wait for after click — atomic click+wait. Useful for dropdowns/popups in portals (e.g. '#menu-popup-root > div') that otherwise race against the next MCP call."),
|
|
27
|
+
waitTimeoutMs: z.number().optional().describe("Timeout for waitForSelector in ms (default: 2000)."),
|
|
28
|
+
waitForRouteChange: z.boolean().optional().describe("For SPAs (React Router etc.): after click, wait for location.pathname+search to change relative to before. Surfaces 'routeChanged:true/false' so success means the view actually navigated, not just that the click was delivered. Does not fail the click on timeout. For content that renders without a URL change, prefer waitForSelector."),
|
|
29
|
+
autoAnalyzeAfter: z.boolean().optional().describe("After click, automatically diff APOM state and append a delta to the result: '+N appeared: id1:\"text\", id2:\"text\"'. New ids are re-registered so callers can use them directly in the next click/type call without an extra analyzePage. Use for dropdowns and menus that reveal new options on click."),
|
|
26
30
|
}).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
|
|
27
31
|
message: "Either 'id' or 'selector' must be provided, but not both"
|
|
28
32
|
});
|
|
@@ -110,15 +114,15 @@ export const SetStylesSchema = z.object({
|
|
|
110
114
|
|
|
111
115
|
// Screenshot tools
|
|
112
116
|
export const ScreenshotSchema = z.object({
|
|
113
|
-
id: z.string().optional().describe("APOM element ID from analyzePage (e.g., 'div_20'). Mutually exclusive with selector."),
|
|
114
|
-
selector: z.string().optional().describe("CSS selector for element to screenshot. Mutually exclusive with id."),
|
|
115
|
-
padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
|
|
117
|
+
id: z.string().optional().describe("APOM element ID from analyzePage (e.g., 'div_20'). Mutually exclusive with selector. If neither id nor selector is provided, captures full viewport."),
|
|
118
|
+
selector: z.string().optional().describe("CSS selector for element to screenshot. Mutually exclusive with id. If neither id nor selector is provided, captures full viewport."),
|
|
119
|
+
padding: z.number().optional().describe("Padding around element in pixels (default: 0). Ignored for viewport screenshot."),
|
|
116
120
|
maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
|
|
117
121
|
maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
|
|
118
122
|
quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 40)"),
|
|
119
123
|
format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format (default: 'jpeg')"),
|
|
120
|
-
}).refine(data =>
|
|
121
|
-
message: "
|
|
124
|
+
}).refine(data => !(data.id && data.selector), {
|
|
125
|
+
message: "Provide only one of 'id' or 'selector' (or neither for a viewport screenshot)"
|
|
122
126
|
});
|
|
123
127
|
|
|
124
128
|
export const SaveScreenshotSchema = z.object({
|
|
@@ -153,6 +157,13 @@ export const NavigateToSchema = z.object({
|
|
|
153
157
|
.describe("Wait until event (default: networkidle2)"),
|
|
154
158
|
});
|
|
155
159
|
|
|
160
|
+
export const ListFramesSchema = z.object({});
|
|
161
|
+
|
|
162
|
+
export const SwitchFrameSchema = z.object({
|
|
163
|
+
frameUrl: z.string().optional().describe("Substring matched against each frame's URL (e.g. 'app.example.com'). Selects the first matching frame, including cross-origin iframes (resolved via CDP, so Same-Origin Policy does not block access). Mutually exclusive with frameSelector."),
|
|
164
|
+
frameSelector: z.string().optional().describe("CSS selector of the <iframe> element in the current document; its content frame becomes active. Mutually exclusive with frameUrl."),
|
|
165
|
+
}).describe("Set the active frame for subsequent click/type/hover/analyzePage/find/executeScript/waitForElement. Call with NO arguments to reset back to the main frame. Active frame is also reset automatically on navigateTo. Use listFrames() to discover available frames.");
|
|
166
|
+
|
|
156
167
|
export const SetViewportSchema = z.object({
|
|
157
168
|
width: z.number().min(320).max(4000).describe("Viewport width in pixels (320-4000)"),
|
|
158
169
|
height: z.number().min(200).max(3000).describe("Viewport height in pixels (200-3000)"),
|
|
@@ -269,6 +280,7 @@ export const ConvertFigmaToCodeSchema = z.object({
|
|
|
269
280
|
export const SmartFindElementSchema = z.object({
|
|
270
281
|
description: z.string().describe("Natural language description of element to find (e.g., 'login button', 'email field')"),
|
|
271
282
|
maxResults: z.number().min(1).max(20).optional().describe("Maximum number of candidates to return (default: 5)"),
|
|
283
|
+
minConfidence: z.number().min(0).max(1).optional().describe("Confidence threshold (0-1, default: 0.6) for auto-executing `action`. If the best match scores below this, OR is too close to the runner-up, the action is SKIPPED and candidates are returned with an 'actionSkipped' reason — prevents auto-clicking the wrong control (e.g. a primary form submit when you asked for a menu item). Lower it to act on weaker matches."),
|
|
272
284
|
action: z.object({
|
|
273
285
|
type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the best match"),
|
|
274
286
|
text: z.string().optional().describe("Text to type (required for 'type' action)"),
|
|
@@ -289,6 +301,8 @@ export const AnalyzePageSchema = z.object({
|
|
|
289
301
|
groupBy: z.enum(['type', 'flat']).optional().describe("Group elements by type or return flat structure (default: 'type')"),
|
|
290
302
|
viewportOnly: z.boolean().optional().describe("Only analyze elements visible in current viewport (default: false). Reduces output for long pages."),
|
|
291
303
|
diff: z.boolean().optional().describe("Return only changes since last analysis: {added, removed, changed} (default: false). Useful after clicks to see what changed."),
|
|
304
|
+
includePortals: z.boolean().optional().describe("Include contents of React Portal containers that live outside main React root (default: true). Covers menus, tooltips, popovers rendered via portals — without this, dropdown contents are invisible to analyzePage."),
|
|
305
|
+
portalSelectors: z.array(z.string()).optional().describe("CSS selectors of portal root containers to scan (default: ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]']). Provide custom list when the app uses different portal element ids."),
|
|
292
306
|
});
|
|
293
307
|
|
|
294
308
|
export const GetElementDetailsSchema = z.object({
|
|
@@ -17,6 +17,8 @@ import { processScreenshot } from '../screenshot-processor.js';
|
|
|
17
17
|
* @param {boolean} options.screenshot - Whether to capture screenshot after click
|
|
18
18
|
* @param {boolean} options.skipNetworkWait - Skip waiting for network requests
|
|
19
19
|
* @param {number} options.networkWaitTimeout - Network wait timeout in ms
|
|
20
|
+
* @param {string} options.waitForSelector - CSS selector to wait for after click (e.g., dropdown menu opening)
|
|
21
|
+
* @param {number} options.waitTimeoutMs - Timeout for waitForSelector in ms (default: 2000)
|
|
20
22
|
* @returns {Promise<Object>} Result with content array
|
|
21
23
|
*/
|
|
22
24
|
export async function executeClickAction(page, element, options = {}) {
|
|
@@ -24,7 +26,10 @@ export async function executeClickAction(page, element, options = {}) {
|
|
|
24
26
|
identifier = 'element',
|
|
25
27
|
screenshot = false,
|
|
26
28
|
skipNetworkWait = false,
|
|
27
|
-
networkWaitTimeout = 3000
|
|
29
|
+
networkWaitTimeout = 3000,
|
|
30
|
+
waitForSelector = null,
|
|
31
|
+
waitTimeoutMs = 2000,
|
|
32
|
+
waitForRouteChange = false
|
|
28
33
|
} = options;
|
|
29
34
|
|
|
30
35
|
// Capture timestamp and URL BEFORE click for diagnostics
|
|
@@ -127,6 +132,52 @@ export async function executeClickAction(page, element, options = {}) {
|
|
|
127
132
|
|
|
128
133
|
await clickWithTimeout();
|
|
129
134
|
|
|
135
|
+
// Atomic click + wait: if caller asked to wait for a selector to appear after click,
|
|
136
|
+
// do it BEFORE diagnostics. Useful for dropdowns/popups that render into a portal —
|
|
137
|
+
// without atomic wait, the next MCP call may race against the popup closing.
|
|
138
|
+
let waitResult = null;
|
|
139
|
+
if (waitForSelector) {
|
|
140
|
+
const waitStart = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
await page.waitForSelector(waitForSelector, { timeout: waitTimeoutMs, visible: true });
|
|
143
|
+
waitResult = { appeared: true, selector: waitForSelector, appearedInMs: Date.now() - waitStart };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
waitResult = {
|
|
146
|
+
appeared: false,
|
|
147
|
+
selector: waitForSelector,
|
|
148
|
+
timedOutAfterMs: Date.now() - waitStart,
|
|
149
|
+
timeoutMs: waitTimeoutMs
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// SPA route-change wait (P1-5): SPAs navigate via history.pushState, so page.url()
|
|
155
|
+
// diagnostics below may not register a "navigation". Wait for location.pathname+search
|
|
156
|
+
// to actually change relative to before. Best-effort — a timeout never fails the click.
|
|
157
|
+
let routeChangeResult = null;
|
|
158
|
+
if (waitForRouteChange) {
|
|
159
|
+
let pathBefore;
|
|
160
|
+
try {
|
|
161
|
+
pathBefore = await page.evaluate(() => location.pathname + location.search);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
pathBefore = null;
|
|
164
|
+
}
|
|
165
|
+
if (pathBefore !== null) {
|
|
166
|
+
const routeStart = Date.now();
|
|
167
|
+
try {
|
|
168
|
+
await page.waitForFunction(
|
|
169
|
+
(before) => (location.pathname + location.search) !== before,
|
|
170
|
+
{ timeout: waitTimeoutMs },
|
|
171
|
+
pathBefore
|
|
172
|
+
);
|
|
173
|
+
const pathAfter = await page.evaluate(() => location.pathname + location.search);
|
|
174
|
+
routeChangeResult = { routeChanged: true, from: pathBefore, to: pathAfter, changedInMs: Date.now() - routeStart };
|
|
175
|
+
} catch (e) {
|
|
176
|
+
routeChangeResult = { routeChanged: false, from: pathBefore, timeoutMs: waitTimeoutMs };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
130
181
|
// Check if element was detached from DOM during click (Angular *ngFor + Zone.js pattern)
|
|
131
182
|
let elementDetached = false;
|
|
132
183
|
try {
|
|
@@ -207,6 +258,24 @@ export async function executeClickAction(page, element, options = {}) {
|
|
|
207
258
|
hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
|
|
208
259
|
}
|
|
209
260
|
|
|
261
|
+
// Surface SPA route-change outcome to the caller
|
|
262
|
+
if (routeChangeResult) {
|
|
263
|
+
if (routeChangeResult.routeChanged) {
|
|
264
|
+
hintsText += `\nRoute changed: "${routeChangeResult.from}" → "${routeChangeResult.to}" (${routeChangeResult.changedInMs}ms)`;
|
|
265
|
+
} else {
|
|
266
|
+
hintsText += `\n⚠️ ROUTE_UNCHANGED: location did not change within ${routeChangeResult.timeoutMs}ms after click (still "${routeChangeResult.from}"). View may render without a URL change — use waitForSelector instead.`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Surface waitForSelector outcome to the caller (success or timeout)
|
|
271
|
+
if (waitResult) {
|
|
272
|
+
if (waitResult.appeared) {
|
|
273
|
+
hintsText += `\nWait: "${waitResult.selector}" appeared in ${waitResult.appearedInMs}ms`;
|
|
274
|
+
} else {
|
|
275
|
+
hintsText += `\n⚠️ WAIT_TIMEOUT: "${waitResult.selector}" did not appear within ${waitResult.timeoutMs}ms after click`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
210
279
|
// 4. Add diagnostics to output
|
|
211
280
|
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
212
281
|
|
package/utils/element-actions.js
CHANGED
|
@@ -20,13 +20,16 @@ async function takeActionScreenshot(page, clip) {
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// Helper function to execute actions on elements
|
|
24
|
-
|
|
23
|
+
// Helper function to execute actions on elements.
|
|
24
|
+
// `frame` (P0-1) is the DOM context used for element resolution/evaluation —
|
|
25
|
+
// pass the active iframe's Frame to act inside it. Defaults to `page` (main frame).
|
|
26
|
+
// Page-level operations (keyboard, screenshot) always use `page`.
|
|
27
|
+
export async function executeElementAction(page, selector, action, frame = page) {
|
|
25
28
|
if (!action || !action.type) {
|
|
26
29
|
return null;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const element = await
|
|
32
|
+
const element = await frame.$(selector);
|
|
30
33
|
if (!element) {
|
|
31
34
|
throw new Error(`Element not found for action: ${selector}`);
|
|
32
35
|
}
|
|
@@ -83,7 +86,7 @@ export async function executeElementAction(page, selector, action) {
|
|
|
83
86
|
case 'scrollTo':
|
|
84
87
|
await element.scrollIntoView({ behavior: 'auto' });
|
|
85
88
|
await new Promise(resolve => setTimeout(resolve, action.waitAfter || 300));
|
|
86
|
-
const position = await
|
|
89
|
+
const position = await frame.evaluate(() => ({
|
|
87
90
|
x: window.scrollX,
|
|
88
91
|
y: window.scrollY
|
|
89
92
|
}));
|
|
@@ -128,7 +131,7 @@ export async function executeElementAction(page, selector, action) {
|
|
|
128
131
|
for (const style of action.styles) {
|
|
129
132
|
stylesObject[style.name] = style.value;
|
|
130
133
|
}
|
|
131
|
-
await
|
|
134
|
+
await frame.evaluate((sel, styles) => {
|
|
132
135
|
const el = document.querySelector(sel);
|
|
133
136
|
if (el) {
|
|
134
137
|
Object.entries(styles).forEach(([key, value]) => {
|
package/utils/platform-utils.js
CHANGED
|
@@ -30,6 +30,10 @@ export const isWindows = process.platform === 'win32' || isWSL;
|
|
|
30
30
|
* @returns {string} - Path to Chrome executable
|
|
31
31
|
*/
|
|
32
32
|
export function getChromePath() {
|
|
33
|
+
// Explicit override wins on every platform
|
|
34
|
+
if (process.env.CHROMETOOLS_CHROME_PATH) {
|
|
35
|
+
return process.env.CHROMETOOLS_CHROME_PATH;
|
|
36
|
+
}
|
|
33
37
|
if (process.platform === 'win32') {
|
|
34
38
|
// Native Windows
|
|
35
39
|
return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
|
@@ -57,6 +61,17 @@ export function getTempDir() {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/**
|
|
60
|
-
* Chrome
|
|
64
|
+
* Get the Chrome user-data-dir used when launching a new instance.
|
|
65
|
+
* Override with CHROMETOOLS_USER_DATA_DIR to point at a real/cloned profile
|
|
66
|
+
* that already holds the user's login session/cookies.
|
|
67
|
+
* @returns {string} - Path to Chrome user data directory
|
|
68
|
+
*/
|
|
69
|
+
export function getUserDataDir() {
|
|
70
|
+
return process.env.CHROMETOOLS_USER_DATA_DIR || `${getTempDir()}/chrome-mcp-profile`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Chrome remote debugging port.
|
|
75
|
+
* Override with CHROMETOOLS_DEBUG_PORT (defaults to 9222).
|
|
61
76
|
*/
|
|
62
|
-
export const CHROME_DEBUG_PORT = 9222;
|
|
77
|
+
export const CHROME_DEBUG_PORT = parseInt(process.env.CHROMETOOLS_DEBUG_PORT, 10) || 9222;
|
|
@@ -298,21 +298,38 @@ export function formatDiagnosticsForAI(diagnostics) {
|
|
|
298
298
|
const netActivity = diagnostics.networkActivity;
|
|
299
299
|
const trackedRequests = netActivity.trackedRequests || [];
|
|
300
300
|
|
|
301
|
-
// Show requests detected within 200ms after action
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
// Show requests detected within 200ms after action.
|
|
302
|
+
// P1-6: filter to data requests (XHR/Fetch) and drop static assets — a single
|
|
303
|
+
// SPA navigation pulls dozens of JS chunks/CSS/fonts that drown the signal.
|
|
304
|
+
// Cap the printed list and summarize the rest.
|
|
305
|
+
const STATIC_TYPES = new Set(['Script', 'Stylesheet', 'Font', 'Image', 'Media', 'Manifest', 'Other']);
|
|
306
|
+
const MAX_SHOWN = 12;
|
|
307
|
+
const dataRequests = trackedRequests.filter(req => {
|
|
308
|
+
if (!req.type) return true; // no type info → keep (bridge/legacy)
|
|
309
|
+
return !STATIC_TYPES.has(req.type);
|
|
310
|
+
});
|
|
311
|
+
const staticDropped = trackedRequests.length - dataRequests.length;
|
|
312
|
+
|
|
313
|
+
if (dataRequests.length > 0) {
|
|
314
|
+
const shown = dataRequests.slice(0, MAX_SHOWN);
|
|
315
|
+
output += `\n\n📡 Network requests (XHR/Fetch: ${dataRequests.length}${staticDropped > 0 ? `, ${staticDropped} static asset(s) hidden` : ''}):`;
|
|
316
|
+
shown.forEach((req, idx) => {
|
|
305
317
|
const statusIcon = req.status === 'pending' ? '⏳' :
|
|
306
318
|
(req.status === 'completed' || (typeof req.status === 'number' && req.status < 400) ? '✓' : '✗');
|
|
307
319
|
const statusText = req.statusText || req.status || 'pending';
|
|
308
320
|
output += `\n ${idx + 1}. ${statusIcon} ${req.method} ${req.url}`;
|
|
309
321
|
output += `\n → Status: ${statusText}`;
|
|
310
322
|
});
|
|
323
|
+
if (dataRequests.length > MAX_SHOWN) {
|
|
324
|
+
output += `\n … ${dataRequests.length - MAX_SHOWN} more`;
|
|
325
|
+
}
|
|
311
326
|
|
|
312
327
|
// Show if some requests are still pending after timeout
|
|
313
328
|
if (netActivity.stillPending > 0) {
|
|
314
329
|
output += `\n\n⏳ ${netActivity.stillPending} request(s) still pending after ${Math.round(netActivity.waitedMs)}ms timeout`;
|
|
315
330
|
}
|
|
331
|
+
} else if (staticDropped > 0) {
|
|
332
|
+
output += `\n\n📡 No XHR/Fetch requests detected within 200ms (${staticDropped} static asset(s) hidden)`;
|
|
316
333
|
} else {
|
|
317
334
|
output += '\n\n📡 No network requests detected within 200ms';
|
|
318
335
|
}
|