chrometools-mcp 3.5.6 → 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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.6.0] - 2026-06-25
6
+
7
+ ### Added
8
+ - **iframe automation (`switchFrame` / `listFrames`)** — Automate pages whose UI lives in an `<iframe>`, including **cross-origin** ones (resolved over CDP, bypassing Same-Origin Policy). `switchFrame({ frameUrl | frameSelector })` sets an active frame so `click`, `type`, `hover`, `selectOption`, `pressKey`, `scrollTo`, `waitForElement`, `analyzePage`, `findElementsByText`, `smartFindElement`, `executeScript` all run inside it; call `switchFrame()` with no args to reset. `listFrames()` lists frames; `analyzePage` also reports a `frames` array when >1 frame exists. Active frame auto-resets on `navigateTo`
9
+ - **Browser connection env vars** — `CHROMETOOLS_BROWSER_WS_ENDPOINT` (connect directly to a CDP WebSocket), `CHROMETOOLS_DEBUG_PORT` (default 9222), `CHROMETOOLS_USER_DATA_DIR` (use a real/logged-in Chrome profile instead of a blank temp one), `CHROMETOOLS_CHROME_PATH`. Lets the server attach to your authenticated Chrome session — required for sites that need real cookies/cross-subdomain session
10
+ - **`smartFindElement({ minConfidence })`** — Confidence threshold (default 0.6) gating auto-`action`. If the best match is below it or too close to the runner-up, the action is skipped and candidates are returned with an `actionSkipped` reason. Candidate coverage broadened (`[onclick]`, `[role=menuitem]`, `[role=tab]`, nav links); scoring penalizes text that doesn't match the description and rewards nav/menu context
11
+ - **`click({ waitForRouteChange })`** — For SPAs: after click, waits for `location.pathname+search` to change and reports `routeChanged:true/false` (never fails the click on timeout)
12
+
13
+ ### Changed
14
+ - **`executeScript` top-level `return`** — Now runs the snippet as-is first and only re-wraps in an async IIFE if the engine reports an "Illegal return statement". Handles `const x=…; return x`, callbacks containing `function`, and bare expressions — replaces the fragile `^return` + no-`function` heuristic
15
+ - **`navigateTo` network summary** — Lists only XHR/Fetch requests (static assets hidden and counted), capped at 12 with a `… N more` note, instead of dumping every chunk/CSS/font
16
+
17
+ ### Fixed
18
+ - **`findElementsByText({ action })` in iframes** — The action path resolved the selector against the main frame even after `switchFrame`, causing `Element not found for action`. Now resolves inside the active frame
19
+
5
20
  ## [3.5.6] - 2026-05-28
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -219,13 +219,14 @@ The Chrome Extension is **required** for scenario recording and other advanced f
219
219
  - [Installation](#installation)
220
220
  - [Chrome Extension Setup](#chrome-extension-setup)
221
221
  - [AI Optimization Features](#ai-optimization-features)- [Scenario Recorder](#scenario-recorder) - Visual UI-based recording with smart optimization
222
- - [Available Tools](#available-tools) - **49+ Tools Total**
222
+ - [Available Tools](#available-tools) - **51+ Tools Total**
223
223
  - [AI-Powered Tools](#ai-powered-tools) - smartFindElement, analyzePage, getElementDetails, findElementsByText
224
224
  - [Core Tools](#1-core-tools) - ping, openBrowser
225
225
  - [Interaction Tools](#2-interaction-tools) - click, type, scrollTo, selectOption, selectFromGroup, drag, scrollHorizontal
226
226
  - [Inspection Tools](#3-inspection-tools) - getElement, getComputedCss, getBoxModel, screenshot
227
227
  - [Advanced Tools](#4-advanced-tools) - executeScript, getConsoleLogs, listNetworkRequests, getNetworkRequest, filterNetworkRequests, hover, pressKey, setStyles, setViewport, getViewport, navigateTo
228
228
  - [Tab Management Tools](#5-tab-management-tools) - listTabs, switchTab
229
+ - [Frame Tools](#5a-frame-tools-iframe-automation) - listFrames, switchFrame (cross-origin iframe automation)
229
230
  - [Recorder Tools](#7-recorder-tools) - enableRecorder, executeScenario, listScenarios, searchScenarios, getScenarioInfo, deleteScenario, exportScenarioAsCode, appendScenarioToFile, generatePageObject
230
231
  - [API / Swagger Tools](#8-api--swagger-tools) - loadSwagger, generateApiModels
231
232
  - [Typical Workflow Example](#typical-workflow-example)
@@ -357,6 +358,8 @@ executeScenario({ name: "login_flow", parameters: { email: "user@test.com" } })
357
358
  - **Parameters**:
358
359
  - `description` (required): Natural language (e.g., "login button", "email field")
359
360
  - `maxResults` (optional): Max candidates to return (default: 5)
361
+ - `minConfidence` (optional): 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 instead. 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.
362
+ - **Candidate coverage**: besides `button`/`input`/`a`/`[role=button]`, also considers `[onclick]`, `[role=menuitem]`, `[role=tab]`, and links inside `nav`/`[role=navigation]`/`[role=menu]` — so menu items rendered as `div`/`span[onclick]` are found. Scoring penalizes candidates whose text doesn't match the description and rewards navigation/menu context.
360
363
  - **Use case**: When you don't know the exact selector
361
364
  - **Returns**: Ranked candidates with confidence scores, selectors, and reasoning
362
365
  - **Example**:
@@ -519,6 +522,7 @@ Click an element with optional result screenshot. **PREFERRED**: Use APOM ID fro
519
522
  - `networkWaitTimeout` (optional): Custom network wait timeout in ms (default: 10000). Only used if skipNetworkWait is false.
520
523
  - `waitForSelector` (optional): CSS selector to wait for **after** the click — atomic click+wait. Use for dropdowns/popups that render into a React Portal and otherwise race with the next MCP call. Example: `click({ id: 'button_47', waitForSelector: '#menu-popup-root > div' })`.
521
524
  - `waitTimeoutMs` (optional): Timeout for `waitForSelector` in ms (default: 2000). On timeout the click still succeeds but the result text reports `⚠️ WAIT_TIMEOUT`.
525
+ - `waitForRouteChange` (optional): For SPAs (React Router etc.). After the click, waits for `location.pathname + location.search` to change relative to before, then reports `Route changed: "/a" → "/b"` or `⚠️ ROUTE_UNCHANGED`. Because SPAs navigate via `history.pushState`, plain network diagnostics may not register the navigation — this makes "success" mean the view actually changed, not just that the click was delivered. Never fails the click on timeout. For view changes that don't alter the URL, use `waitForSelector` instead.
522
526
  - `autoAnalyzeAfter` (optional): After click, automatically diff APOM and append the delta to the result text (e.g. `+3 appeared: button_42:"Статистика", button_43:"Настройки", link_44:"Удалить"`). New element ids are pre-registered so the next `click({ id })`/`type({ id })` call works **without an extra `analyzePage`**. Designed for the dropdown/menu pattern: one MCP call instead of three.
523
527
  - **Use case**: Buttons, links, form submissions, Django admin forms
524
528
  - **Returns**: Confirmation text + optional screenshot + network diagnostics
@@ -725,7 +729,8 @@ Execute arbitrary JavaScript in page context with optional screenshot.
725
729
  - **Use case**: Complex interactions, custom manipulations
726
730
  - **Returns**: Execution result + optional screenshot
727
731
  - **Performance**: 2-10x faster without screenshot
728
- - **Top-level `return`**: snippets that start with `return ...` (e.g. `return document.title`) are auto-wrapped in an async IIFE no need to manually wrap in `(() => { ... })()`. Scripts that declare a `function` are left unmodified so implicit-return patterns keep working.
732
+ - **Top-level `return`**: any snippet using a top-level `return` (e.g. `return document.title`, `const x = 1; return x`, or code that contains a `function` in a callback) just works — it's run as-is first, and only re-wrapped in an async IIFE if the engine reports an "Illegal return statement". Bare expressions like `document.title` still return their value. No manual `(() => { ... })()` wrapping needed.
733
+ - **Frames**: runs inside the active frame after `switchFrame` (see [Frame Tools](#5a-frame-tools-iframe-automation)); defaults to the main frame.
729
734
 
730
735
  #### getConsoleLogs
731
736
  Retrieve browser console logs (log, warn, error, etc.).
@@ -848,7 +853,8 @@ Navigate to different URL while keeping browser instance.
848
853
  - `url` (required)
849
854
  - `waitUntil` (optional): load event type
850
855
  - **Use case**: Moving between pages in workflow
851
- - **Returns**: New page title
856
+ - **Returns**: New page title. The post-navigation network summary lists only **XHR/Fetch** requests (static assets — JS chunks, CSS, fonts, images — are hidden and counted) and is capped at the first 12 with a `… N more` note, so a heavy SPA load doesn't bury the signal.
857
+ - **Note**: resets the active frame back to the main frame (see [Frame Tools](#5a-frame-tools-iframe-automation)).
852
858
 
853
859
  ### 5. Tab Management Tools
854
860
  Tools for managing multiple browser tabs. New tabs opened via `window.open()`, `target="_blank"`, or user actions are automatically detected and tracked.
@@ -891,6 +897,45 @@ switchTab({ tab: 0 })
891
897
  switchTab({ tab: "google.com" })
892
898
  ```
893
899
 
900
+ ### 5a. Frame Tools (iframe automation)
901
+
902
+ Tools for automating pages whose UI lives inside an `<iframe>` — including **cross-origin** iframes (e.g. a widget hosted on a different subdomain). Cross-origin frames can't be reached from page JavaScript (Same-Origin Policy blocks `iframe.contentDocument`), but ChromeTools resolves them over CDP, so the SOP doesn't apply.
903
+
904
+ By default all tools operate on the **main frame**. After `switchFrame`, the element tools — `click`, `type`, `hover`, `selectOption`, `pressKey`, `scrollTo`, `waitForElement`, `analyzePage`, `findElementsByText`, `smartFindElement`, `executeScript` — run **inside the selected frame** until you reset. The active frame is reset automatically on `navigateTo`.
905
+
906
+ #### listFrames
907
+ List all frames on the current page so you can discover which one to switch into.
908
+ - **Parameters**: None
909
+ - **Returns**: `{ count, activeFrame, frames: [{ url, name, isMain }], hint }`
910
+ - **Use case**: Find a cross-origin iframe (e.g. `app.example.com`) before switching into it
911
+
912
+ #### switchFrame
913
+ Set the active frame for subsequent element tools. Call with **no arguments** to reset back to the main frame.
914
+ - **Parameters** (provide one, or none to reset):
915
+ - `frameUrl` (optional): substring matched against each frame's URL (e.g. `"app.example.com"`). Selects the first match.
916
+ - `frameSelector` (optional): CSS selector of the `<iframe>` element; its content frame becomes active.
917
+ - **Returns**: `{ active: <frame url>, matcher, frames }` (or `{ active: null }` on reset)
918
+ - **Use case**: Enter a cross-origin iframe to click/fill a form rendered there
919
+
920
+ ```javascript
921
+ // Discover frames
922
+ listFrames()
923
+ // → { count: 2, frames: [ {url:".../app", isMain:true}, {url:"https://app.example.com/...", isMain:false} ] }
924
+
925
+ // Enter the cross-origin iframe
926
+ switchFrame({ frameUrl: "app.example.com" })
927
+
928
+ // Now element tools target the iframe
929
+ analyzePage() // returns the iframe's APOM tree
930
+ findElementsByText({ text: "Save" }) // searches inside the iframe
931
+ click({ id: "button_3" }) // clicks inside the iframe
932
+
933
+ // Back to the main document
934
+ switchFrame()
935
+ ```
936
+
937
+ > `analyzePage` also includes a `frames` array in its output whenever the page has more than one frame, so the agent can discover iframes without a separate `listFrames` call.
938
+
894
939
  ### 6. Figma Tools
895
940
  Design-to-code validation, file browsing, design system extraction, and comparison tools with automatic 3 MB compression.
896
941
 
@@ -1614,14 +1659,14 @@ Each tool definition is sent to the AI in every request, consuming context token
1614
1659
  | Group | Description | Tools (count) |
1615
1660
  |-------|-------------|---------------|
1616
1661
  | `core` | Basic tools | `ping`, `openBrowser` (2) |
1617
- | `interaction` | User interaction | `click`, `type`, `scrollTo`, `waitForElement`, `hover` (5) |
1662
+ | `interaction` | User interaction & frames | `click`, `type`, `scrollTo`, `waitForElement`, `hover`, `selectOption`, `selectFromGroup`, `drag`, `scrollHorizontal`, `switchFrame`, `listFrames` (11) |
1618
1663
  | `inspection` | Page inspection | `getComputedCss`, `getBoxModel`, `screenshot`, `saveScreenshot` (4) |
1619
1664
  | `debug` | Debugging & network | `getConsoleLogs`, `listNetworkRequests`, `getNetworkRequest`, `filterNetworkRequests` (4) |
1620
1665
  | `advanced` | Advanced automation & AI | `executeScript`, `setStyles`, `setViewport`, `getViewport`, `navigateTo`, `smartFindElement`, `analyzePage`, `findElementsByText` (8) |
1621
1666
  | `recorder` | Scenario recording | `enableRecorder`, `executeScenario`, `listScenarios`, `searchScenarios`, `getScenarioInfo`, `deleteScenario`, `exportScenarioAsCode`, `appendScenarioToFile`, `generatePageObject` (9) |
1622
1667
  | `figma` | Figma integration | `getFigmaFrame`, `compareFigmaToElement`, `getFigmaSpecs`, `parseFigmaUrl`, `listFigmaPages`, `searchFigmaFrames`, `getFigmaComponents`, `getFigmaStyles`, `getFigmaColorPalette`, `convertFigmaToCode` (10) |
1623
1668
 
1624
- **Total:** 42 tools across 7 groups
1669
+ **Total:** 48 tools across 7 groups
1625
1670
 
1626
1671
  **Configuration:**
1627
1672
 
@@ -1746,6 +1791,48 @@ To use Figma tools, you need to configure your Figma Personal Access Token.
1746
1791
 
1747
1792
  ---
1748
1793
 
1794
+ ## Browser Connection (use your real, logged-in Chrome)
1795
+
1796
+ By default ChromeTools connects to a Chrome with remote debugging on port **9222**, and if none is found it launches a fresh Chrome with a **temporary profile** (no cookies, no logins). To automate a site that needs your **authenticated session** (and to reach cross-origin iframes that depend on it), point ChromeTools at your own Chrome via environment variables — all optional:
1797
+
1798
+ | Variable | Default | Purpose |
1799
+ |----------|---------|---------|
1800
+ | `CHROMETOOLS_BROWSER_WS_ENDPOINT` | _(unset)_ | Direct CDP WebSocket URL (e.g. `ws://127.0.0.1:9222/devtools/browser/<id>`). When set, ChromeTools connects straight to it, skipping port discovery and launch. |
1801
+ | `CHROMETOOLS_DEBUG_PORT` | `9222` | Remote-debugging port to discover/connect to (and to launch with). |
1802
+ | `CHROMETOOLS_USER_DATA_DIR` | `<temp>/chrome-mcp-profile` | Chrome profile used when launching a new instance. Point at a real (or cloned) profile to reuse its cookies/logins. |
1803
+ | `CHROMETOOLS_CHROME_PATH` | platform default | Path to the Chrome executable. |
1804
+
1805
+ **Quick path — attach to your already-logged-in Chrome:**
1806
+
1807
+ 1. Fully close Chrome (a profile can't open a debug port while another Chrome holds it).
1808
+ 2. Launch it with remote debugging on your normal profile:
1809
+ ```bash
1810
+ # Windows
1811
+ chrome.exe --remote-debugging-port=9222
1812
+ # macOS
1813
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
1814
+ ```
1815
+ 3. Log into the target site in that Chrome. ChromeTools will `connect()` to it — cross-origin iframes load authenticated, and `listTabs` sees your real tabs.
1816
+
1817
+ ```json
1818
+ {
1819
+ "mcpServers": {
1820
+ "chrometools": {
1821
+ "type": "stdio",
1822
+ "command": "npx",
1823
+ "args": ["chrometools-mcp"],
1824
+ "env": {
1825
+ "CHROMETOOLS_USER_DATA_DIR": "C:/Users/you/chrome-mcp-real-profile"
1826
+ }
1827
+ }
1828
+ }
1829
+ }
1830
+ ```
1831
+
1832
+ Without any of these variables, behavior is unchanged.
1833
+
1834
+ ---
1835
+
1749
1836
  ## WSL Setup Guide
1750
1837
 
1751
1838
  If you're using **Windows Subsystem for Linux (WSL)**, special configuration is required to display Chrome GUI windows.
@@ -9,7 +9,7 @@ import { spawn } from 'child_process';
9
9
  import http from 'http';
10
10
  import path from 'path';
11
11
  import { fileURLToPath } from 'url';
12
- import { getChromePath, getTempDir, isWSL, CHROME_DEBUG_PORT } from '../utils/platform-utils.js';
12
+ import { getChromePath, getUserDataDir, isWSL, CHROME_DEBUG_PORT } from '../utils/platform-utils.js';
13
13
  import { handleNewTab, openPages, lastPage } from './page-manager.js';
14
14
 
15
15
  // Get extension path (use forward slashes for Chrome on Windows)
@@ -97,6 +97,30 @@ export async function getBrowser() {
97
97
  let browser;
98
98
  let endpoint;
99
99
 
100
+ // Highest priority: explicit CDP WebSocket endpoint (e.g. a real
101
+ // logged-in Chrome the user already launched). Skips port-discovery
102
+ // and spawning entirely — connect straight to the live session.
103
+ const wsEndpoint = process.env.CHROMETOOLS_BROWSER_WS_ENDPOINT;
104
+ if (wsEndpoint) {
105
+ browser = await puppeteer.connect({
106
+ browserWSEndpoint: wsEndpoint,
107
+ defaultViewport: null,
108
+ });
109
+ debugLog("Connected via CHROMETOOLS_BROWSER_WS_ENDPOINT:", wsEndpoint);
110
+ connectedToExistingChrome = true;
111
+ console.error("[chrometools-mcp] Connected to Chrome via CHROMETOOLS_BROWSER_WS_ENDPOINT.");
112
+
113
+ browser.on('disconnected', () => {
114
+ debugLog("Browser disconnected");
115
+ browserPromise = null;
116
+ connectedToExistingChrome = false;
117
+ });
118
+
119
+ setupNewTabTracking(browser);
120
+ scheduleBridgeReconnect();
121
+ return browser;
122
+ }
123
+
100
124
  // Try to connect to existing Chrome with remote debugging
101
125
  try {
102
126
  endpoint = await getChromeWebSocketEndpoint(CHROME_DEBUG_PORT, 2);
@@ -132,7 +156,7 @@ export async function getBrowser() {
132
156
 
133
157
  // Launch new Chrome with remote debugging enabled
134
158
  const chromePath = getChromePath();
135
- const userDataDir = `${getTempDir()}/chrome-mcp-profile`;
159
+ const userDataDir = getUserDataDir();
136
160
 
137
161
  debugLog("Chrome path:", chromePath);
138
162
  debugLog("User data dir:", userDataDir);
@@ -346,6 +346,95 @@ export function setLastPage(page) {
346
346
  lastPage = page;
347
347
  }
348
348
 
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+ // Active frame state (P0-1: cross-origin iframe support)
351
+ //
352
+ // Tools default to the main frame. switchFrame() stores a *matcher* (not a Frame
353
+ // object — Frames get invalidated when the iframe re-navigates) keyed by Page.
354
+ // getTargetFrame() re-resolves the matcher against page.frames() on every call,
355
+ // so it survives iframe reloads. Cleared on top-level navigation.
356
+ // ─────────────────────────────────────────────────────────────────────────────
357
+ const activeFrames = new WeakMap(); // Page -> { frameUrl?, frameSelector? }
358
+
359
+ /**
360
+ * Set (or clear) the active frame matcher for a page.
361
+ * @param {Page} page
362
+ * @param {{frameUrl?: string, frameSelector?: string}|null} matcher - null resets to main frame
363
+ */
364
+ export function setActiveFrame(page, matcher) {
365
+ if (!matcher || (!matcher.frameUrl && !matcher.frameSelector)) {
366
+ activeFrames.delete(page);
367
+ } else {
368
+ activeFrames.set(page, matcher);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Clear the active frame for a page (back to main frame).
374
+ * @param {Page} page
375
+ */
376
+ export function clearActiveFrame(page) {
377
+ activeFrames.delete(page);
378
+ }
379
+
380
+ /**
381
+ * Get the matcher currently stored for a page (or null).
382
+ * @param {Page} page
383
+ * @returns {{frameUrl?: string, frameSelector?: string}|null}
384
+ */
385
+ export function getActiveFrameMatcher(page) {
386
+ return activeFrames.get(page) || null;
387
+ }
388
+
389
+ /**
390
+ * Resolve the execution context (Frame) for a page based on its active matcher.
391
+ * Returns the main frame when no active frame is set. A Frame exposes the same
392
+ * surface tools rely on (.evaluate, .$, .$$, .click, .type, .waitForSelector),
393
+ * so handlers can use the returned context in place of `page` for DOM work.
394
+ * @param {Page} page
395
+ * @returns {Promise<Frame>} - Target Puppeteer Frame
396
+ */
397
+ export async function getTargetFrame(page) {
398
+ const matcher = activeFrames.get(page);
399
+ if (!matcher) {
400
+ return page.mainFrame();
401
+ }
402
+
403
+ if (matcher.frameSelector) {
404
+ const handle = await page.$(matcher.frameSelector);
405
+ const frame = handle ? await handle.contentFrame() : null;
406
+ if (!frame) {
407
+ throw new Error(
408
+ `Active frame (selector "${matcher.frameSelector}") not found. Call listFrames() and switchFrame() again, or switchFrame() with no args to reset.`
409
+ );
410
+ }
411
+ return frame;
412
+ }
413
+
414
+ // frameUrl: substring match against all frames
415
+ const frame = page.frames().find(f => f.url().includes(matcher.frameUrl));
416
+ if (!frame) {
417
+ throw new Error(
418
+ `Active frame (url contains "${matcher.frameUrl}") not found. Call listFrames() and switchFrame() again, or switchFrame() with no args to reset.`
419
+ );
420
+ }
421
+ return frame;
422
+ }
423
+
424
+ /**
425
+ * List all frames on a page with metadata (for listFrames tool / analyzePage output).
426
+ * @param {Page} page
427
+ * @returns {Array<{url: string, name: string, isMain: boolean}>}
428
+ */
429
+ export function listFramesForPage(page) {
430
+ const main = page.mainFrame();
431
+ return page.frames().map(f => ({
432
+ url: f.url(),
433
+ name: f.name() || '',
434
+ isMain: f === main,
435
+ }));
436
+ }
437
+
349
438
  /**
350
439
  * Setup a new page with all monitoring (console, network, recorder)
351
440
  * Used for both explicitly created pages and auto-detected new tabs
@@ -177,6 +177,12 @@ function analyzeButtonContextInPage(element) {
177
177
 
178
178
  // Primary button indicators
179
179
  isPrimary: /primary|main|btn-primary|button-primary/i.test(element.className || ''),
180
+
181
+ // Navigation/menu context (P1-3): menu items and nav links must be able to
182
+ // out-score a form's primary submit button when the user asked for navigation.
183
+ hasMenuRole: element.getAttribute('role') === 'menuitem' || element.getAttribute('role') === 'tab',
184
+ hasOnClick: element.hasAttribute('onclick'),
185
+ isNavContext: element.closest('nav, [role="navigation"], [role="menu"], [role="menubar"], [role="tablist"]') !== null,
180
186
  };
181
187
 
182
188
  // Check if it's the last button in form
@@ -217,6 +223,10 @@ function scoreSubmitButton(element, context, description) {
217
223
  if (context.hasSubmitIcon) score += 5; // submit icon
218
224
  if (context.isPrimary) score += 15; // primary button style
219
225
 
226
+ // Navigation/menu context bonus (P1-3)
227
+ if (context.hasMenuRole) score += 15; // role=menuitem/tab
228
+ if (context.isNavContext) score += 10; // inside nav/menu container
229
+
220
230
  // Visibility bonus
221
231
  if (context.isVisible) score += 10;
222
232
 
@@ -235,6 +245,22 @@ function scoreSubmitButton(element, context, description) {
235
245
  score -= 20;
236
246
  }
237
247
 
248
+ // Text-mismatch penalty (P1-3): when the description carries actual words but
249
+ // the element's text neither contains nor is contained by it AND it doesn't hit
250
+ // a submit keyword, it's almost certainly the wrong control (e.g. asked
251
+ // "Настройки", element says "Пополнить"). Without this, a primary form submit
252
+ // wins on structural points alone and gets auto-clicked.
253
+ if (descLower.trim().length > 0 && text.length > 0) {
254
+ const textMatchesDesc = text.includes(descLower) || descLower.includes(text);
255
+ if (!textMatchesDesc && !matchesSubmitKeyword(context.text, description)) {
256
+ // Strong enough to overcome a full house of structural submit points
257
+ // (type=submit + in-form + last + primary + visible ≈ +115), so a button
258
+ // whose text is unrelated to the request can't be auto-clicked. Submit
259
+ // keywords (Save/Войти/…) are exempt above, so real submits aren't harmed.
260
+ score -= 60;
261
+ }
262
+ }
263
+
238
264
  return score;
239
265
  }
240
266
 
@@ -572,6 +598,18 @@ function explainScore(element, context, description, score) {
572
598
  if (context.isLink && !matchesSubmitKeyword(context.text, description)) {
573
599
  reasons.push('link without submit keyword (-20)');
574
600
  }
601
+ if (context.hasMenuRole) reasons.push('menu/tab role (+15)');
602
+ if (context.isNavContext) reasons.push('nav/menu context (+10)');
603
+ {
604
+ const t = (context.text || '').toLowerCase();
605
+ const d = description.toLowerCase();
606
+ if (d.trim().length > 0 && t.length > 0) {
607
+ const m = t.includes(d) || d.includes(t);
608
+ if (!m && !matchesSubmitKeyword(context.text, description)) {
609
+ reasons.push('text ≠ description (-60)');
610
+ }
611
+ }
612
+ }
575
613
 
576
614
  // Common reasons
577
615
  if (context.inForm) reasons.push('in form (+20)');