chrometools-mcp 3.5.4 → 3.5.6

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.
@@ -1,115 +1,115 @@
1
- /**
2
- * Model Registry - Strategy Pattern Implementation
3
- * Manages registration and selection of element models
4
- */
5
-
6
- class ModelRegistry {
7
- constructor() {
8
- this.models = [];
9
- this.modelsMap = null; // Cached models -> actions map
10
- }
11
-
12
- /**
13
- * Register a model class (not instance)
14
- * @param {Class} ModelClass - Element model class
15
- */
16
- register(ModelClass) {
17
- const instance = new ModelClass();
18
- this.models.push(instance);
19
-
20
- // Sort by priority (higher first)
21
- this.models.sort((a, b) => b.getPriority() - a.getPriority());
22
-
23
- // Invalidate cache
24
- this.modelsMap = null;
25
- }
26
-
27
- /**
28
- * Register multiple models at once
29
- * @param {Array<Class>} modelClasses - Array of model classes
30
- */
31
- registerAll(modelClasses) {
32
- for (const ModelClass of modelClasses) {
33
- this.register(ModelClass);
34
- }
35
- }
36
-
37
- /**
38
- * Find matching model for element (Strategy Pattern)
39
- * @param {HTMLElement} element - DOM element
40
- * @param {Object} elementType - Result from determineElementType()
41
- * @returns {ElementModel} Matching model instance
42
- */
43
- findModel(element, elementType) {
44
- for (const model of this.models) {
45
- if (model.matches(element, elementType)) {
46
- return model;
47
- }
48
- }
49
-
50
- // This should never happen if DefaultModel is registered
51
- throw new Error('No matching model found (is DefaultModel registered?)');
52
- }
53
-
54
- /**
55
- * Get models map for APOM output (cached)
56
- * @returns {Object} Map of model names to actions
57
- */
58
- getModelsMap() {
59
- if (this.modelsMap === null) {
60
- this.modelsMap = {};
61
- for (const model of this.models) {
62
- this.modelsMap[model.getName()] = model.getActions();
63
- }
64
- }
65
- return this.modelsMap;
66
- }
67
-
68
- /**
69
- * Get model by name
70
- * @param {string} modelName - Model name
71
- * @returns {ElementModel|null} Model instance or null
72
- */
73
- getModelByName(modelName) {
74
- return this.models.find(m => m.getName() === modelName) || null;
75
- }
76
-
77
- /**
78
- * Get action handler name for element action
79
- * @param {HTMLElement} element - Target element
80
- * @param {Object} elementType - Element type info
81
- * @param {string} actionName - Action to execute
82
- * @returns {Object} { handlerName: string, modelName: string } or { error: string }
83
- */
84
- getActionHandler(element, elementType, actionName) {
85
- const model = this.findModel(element, elementType);
86
-
87
- // Check if action is available
88
- const actions = model.getActions();
89
- if (!actions.includes(actionName)) {
90
- return {
91
- error: `Action "${actionName}" not available for model "${model.getName()}". Available: ${actions.join(', ')}`
92
- };
93
- }
94
-
95
- const handlerName = model.getActionHandler(actionName);
96
- if (!handlerName) {
97
- return {
98
- error: `Action "${actionName}" has no handler in model "${model.getName()}"`
99
- };
100
- }
101
-
102
- return {
103
- handlerName,
104
- modelName: model.getName()
105
- };
106
- }
107
- }
108
-
109
- // Export for both Node.js and browser
110
- if (typeof module !== 'undefined' && module.exports) {
111
- module.exports = ModelRegistry;
112
- }
113
- if (typeof window !== 'undefined') {
114
- window.ModelRegistry = ModelRegistry;
115
- }
1
+ /**
2
+ * Model Registry - Strategy Pattern Implementation
3
+ * Manages registration and selection of element models
4
+ */
5
+
6
+ class ModelRegistry {
7
+ constructor() {
8
+ this.models = [];
9
+ this.modelsMap = null; // Cached models -> actions map
10
+ }
11
+
12
+ /**
13
+ * Register a model class (not instance)
14
+ * @param {Class} ModelClass - Element model class
15
+ */
16
+ register(ModelClass) {
17
+ const instance = new ModelClass();
18
+ this.models.push(instance);
19
+
20
+ // Sort by priority (higher first)
21
+ this.models.sort((a, b) => b.getPriority() - a.getPriority());
22
+
23
+ // Invalidate cache
24
+ this.modelsMap = null;
25
+ }
26
+
27
+ /**
28
+ * Register multiple models at once
29
+ * @param {Array<Class>} modelClasses - Array of model classes
30
+ */
31
+ registerAll(modelClasses) {
32
+ for (const ModelClass of modelClasses) {
33
+ this.register(ModelClass);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Find matching model for element (Strategy Pattern)
39
+ * @param {HTMLElement} element - DOM element
40
+ * @param {Object} elementType - Result from determineElementType()
41
+ * @returns {ElementModel} Matching model instance
42
+ */
43
+ findModel(element, elementType) {
44
+ for (const model of this.models) {
45
+ if (model.matches(element, elementType)) {
46
+ return model;
47
+ }
48
+ }
49
+
50
+ // This should never happen if DefaultModel is registered
51
+ throw new Error('No matching model found (is DefaultModel registered?)');
52
+ }
53
+
54
+ /**
55
+ * Get models map for APOM output (cached)
56
+ * @returns {Object} Map of model names to actions
57
+ */
58
+ getModelsMap() {
59
+ if (this.modelsMap === null) {
60
+ this.modelsMap = {};
61
+ for (const model of this.models) {
62
+ this.modelsMap[model.getName()] = model.getActions();
63
+ }
64
+ }
65
+ return this.modelsMap;
66
+ }
67
+
68
+ /**
69
+ * Get model by name
70
+ * @param {string} modelName - Model name
71
+ * @returns {ElementModel|null} Model instance or null
72
+ */
73
+ getModelByName(modelName) {
74
+ return this.models.find(m => m.getName() === modelName) || null;
75
+ }
76
+
77
+ /**
78
+ * Get action handler name for element action
79
+ * @param {HTMLElement} element - Target element
80
+ * @param {Object} elementType - Element type info
81
+ * @param {string} actionName - Action to execute
82
+ * @returns {Object} { handlerName: string, modelName: string } or { error: string }
83
+ */
84
+ getActionHandler(element, elementType, actionName) {
85
+ const model = this.findModel(element, elementType);
86
+
87
+ // Check if action is available
88
+ const actions = model.getActions();
89
+ if (!actions.includes(actionName)) {
90
+ return {
91
+ error: `Action "${actionName}" not available for model "${model.getName()}". Available: ${actions.join(', ')}`
92
+ };
93
+ }
94
+
95
+ const handlerName = model.getActionHandler(actionName);
96
+ if (!handlerName) {
97
+ return {
98
+ error: `Action "${actionName}" has no handler in model "${model.getName()}"`
99
+ };
100
+ }
101
+
102
+ return {
103
+ handlerName,
104
+ modelName: model.getName()
105
+ };
106
+ }
107
+ }
108
+
109
+ // Export for both Node.js and browser
110
+ if (typeof module !== 'undefined' && module.exports) {
111
+ module.exports = ModelRegistry;
112
+ }
113
+ if (typeof window !== 'undefined') {
114
+ window.ModelRegistry = ModelRegistry;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrometools-mcp",
3
- "version": "3.5.4",
3
+ "version": "3.5.6",
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",
@@ -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,9 @@ 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
+ 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
43
  },
41
44
  },
42
45
  },
@@ -92,18 +95,18 @@ export const toolDefinitions = [
92
95
  },
93
96
  {
94
97
  name: "screenshot",
95
- description: "Capture element image (5-10k tokens). Use analyzePage for form data/validation (8-10k tokens).",
98
+ 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
99
  inputSchema: {
97
100
  type: "object",
98
101
  properties: {
99
- selector: { type: "string", description: "CSS selector" },
100
- padding: { type: "number", description: "Padding px (default: 0)" },
102
+ id: { type: "string", description: "APOM element ID. Mutually exclusive with selector. Omit both for viewport screenshot." },
103
+ selector: { type: "string", description: "CSS selector. Mutually exclusive with id. Omit both for viewport screenshot." },
104
+ padding: { type: "number", description: "Padding px (default: 0). Ignored for viewport." },
101
105
  maxWidth: { type: "number", description: "Max width px (default: 1024, null=original)" },
102
106
  maxHeight: { type: "number", description: "Max height px (default: 8000, null=original)" },
103
107
  quality: { type: "number", minimum: 1, maximum: 100, description: "JPEG quality (default: 40)" },
104
108
  format: { type: "string", enum: ["png", "jpeg", "auto"], description: "Format (default: jpeg)" },
105
109
  },
106
- required: ["selector"],
107
110
  },
108
111
  },
109
112
  {
@@ -150,7 +153,7 @@ export const toolDefinitions = [
150
153
  },
151
154
  {
152
155
  name: "executeScript",
153
- description: "⚠️ LAST RESORT tool - use ONLY when ALL specialized tools failed. NEVER use for: clicking (use click), typing (use type), reading page (use analyzePage), finding elements (use findElementsByText). May break React/Vue/Angular synthetic events. ALWAYS try specialized tools first.",
156
+ description: "⚠️ LAST RESORT tool - use ONLY when ALL specialized tools failed. NEVER use for: clicking (use click), typing (use type), scrolling (use scrollTo), reading page elements (use analyzePage), finding elements (use findElementsByText), fetching API data (use listNetworkRequests + getNetworkRequest). May break React/Vue/Angular synthetic events. ALWAYS try specialized tools first.",
154
157
  inputSchema: {
155
158
  type: "object",
156
159
  properties: {
@@ -532,6 +535,8 @@ Examples:
532
535
  groupBy: { type: "string", description: "Group elements: 'type' or 'flat' (default: 'type')", enum: ["type", "flat"] },
533
536
  viewportOnly: { type: "boolean", description: "Only analyze elements in current viewport (default: false). Reduces output for long pages." },
534
537
  diff: { type: "boolean", description: "Return only changes since last analysis: {added, removed, changed} (default: false)." },
538
+ includePortals: { type: "boolean", description: "Include React Portal contents — menus, tooltips, popovers outside main root (default: true). Without this, dropdown items are invisible." },
539
+ 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
540
  },
536
541
  },
537
542
  },
@@ -23,6 +23,9 @@ 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
+ 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
29
  }).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
27
30
  message: "Either 'id' or 'selector' must be provided, but not both"
28
31
  });
@@ -110,15 +113,15 @@ export const SetStylesSchema = z.object({
110
113
 
111
114
  // Screenshot tools
112
115
  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)"),
116
+ 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."),
117
+ 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."),
118
+ padding: z.number().optional().describe("Padding around element in pixels (default: 0). Ignored for viewport screenshot."),
116
119
  maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
117
120
  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
121
  quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 40)"),
119
122
  format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format (default: 'jpeg')"),
120
- }).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
121
- message: "Either 'id' or 'selector' must be provided, but not both"
123
+ }).refine(data => !(data.id && data.selector), {
124
+ message: "Provide only one of 'id' or 'selector' (or neither for a viewport screenshot)"
122
125
  });
123
126
 
124
127
  export const SaveScreenshotSchema = z.object({
@@ -289,6 +292,8 @@ export const AnalyzePageSchema = z.object({
289
292
  groupBy: z.enum(['type', 'flat']).optional().describe("Group elements by type or return flat structure (default: 'type')"),
290
293
  viewportOnly: z.boolean().optional().describe("Only analyze elements visible in current viewport (default: false). Reduces output for long pages."),
291
294
  diff: z.boolean().optional().describe("Return only changes since last analysis: {added, removed, changed} (default: false). Useful after clicks to see what changed."),
295
+ 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."),
296
+ 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
297
  });
293
298
 
294
299
  export const GetElementDetailsSchema = z.object({
@@ -0,0 +1,94 @@
1
+ # Progress: SEGM-537 QA Unblockers
2
+
3
+ **Status**: NEEDS APPROVAL
4
+ **Spec**: [SEGM-537-UNBLOCKERS_SPEC.md](./SEGM-537-UNBLOCKERS_SPEC.md)
5
+ **Created**: 2026-05-28
6
+
7
+ ## Phase 0 — Approval & alignment
8
+
9
+ - [ ] Пользователь одобрил scope (все 5 блокеров)
10
+ - [ ] Пользователь одобрил дизайн API (имена параметров, дефолты)
11
+ - [ ] Дать команду «погнали, начни с Phase 1»
12
+
13
+ ## Phase 1 — Блокер #1: portal scan расширение
14
+
15
+ - [x] Расширить `pom/apom-tree-converter.js` — добавить `portalSelectors` параметр + третий проход (~120)
16
+ - [x] ~~Переименовать `forceMarkModalTree`~~ — переиспользована as-is, переименование избыточно
17
+ - [x] Прокинуть `includePortals` / `portalSelectors` через `page.evaluate` в `index.js`
18
+ - [x] Обновить `server/tool-schemas.js` + `server/tool-definitions.js` для `analyzePage`
19
+ - [x] `npm run build` — Syntax validation passed
20
+ - [ ] Verification: бенчмарк Google (требует запуска MCP — пользователь должен перезапустить и прогнать)
21
+ - [ ] Verification: ручной тест на QA-стенде (требует доступа QA — отдаём после рестарта MCP)
22
+ - [x] Обновить `README.md` — секция `analyzePage`
23
+
24
+ ## Phase 2 — Блокер #2: click + waitForSelector
25
+
26
+ - [x] Добавить `waitForSelector` / `waitTimeoutMs` в `executeClickAction` + `click` handler
27
+ - [x] Возвращать `appearedInMs` в результате (или `⚠️ WAIT_TIMEOUT` сообщение)
28
+ - [x] Обновить `server/tool-schemas.js` + `server/tool-definitions.js` для `click`
29
+ - [x] `npm run build` — Syntax validation passed
30
+ - [ ] Verification: на стенде клик «три точки» с `waitForSelector` (требует рестарта MCP)
31
+ - [ ] Verification: несуществующий селектор → таймаут (требует рестарта MCP)
32
+ - [x] Обновить `README.md` — секция `click`
33
+
34
+ ## Phase 3 — Блокер #4: ModelRegistry stale error
35
+
36
+ - [x] **Root cause fix**: `quickRegisterElements` теперь инжектит models code (раньше не делал → ReferenceError при auto-refresh после navigation)
37
+ - [x] User-friendly fallback: catch на `ReferenceError: ModelRegistry is not defined` в `resolveSelector` → сообщение «APOM registry stale, call analyzePage()»
38
+ - [x] `npm run build` — Syntax validation passed
39
+ - [ ] Verification: воспроизвести (analyzePage → navigate → click) — требует рестарта MCP
40
+
41
+ ## Phase 4 — Блокер #3: screenshot без selector
42
+
43
+ - [x] Ослабить `.refine` в `ScreenshotSchema` (запрещаем только конфликт, не отсутствие)
44
+ - [x] Без id/selector — viewport screenshot через `processScreenshot`
45
+ - [x] Обновить `server/tool-schemas.js` + `server/tool-definitions.js`
46
+ - [x] `npm run build` — Syntax validation passed
47
+ - [ ] Verification: `screenshot()` без аргументов возвращает viewport — требует рестарта MCP
48
+ - [x] Обновить `README.md` — секция `screenshot`
49
+
50
+ ## Phase 5 — Блокер #5: executeScript auto-IIFE
51
+
52
+ - [x] Препроцессор скрипта в `executeScript` handler (index.js)
53
+ - [x] Regex: оборачивает только если `^return[\s;]` И код не содержит `function ...`
54
+ - [x] `npm run build` — Syntax validation passed
55
+ - [x] Verification (offline): 5 канонических случаев работают корректно (`return document.title`, ` return 42;`, IIFE, plain expr, `function ... return`)
56
+ - [x] Обновить `README.md` — секция `executeScript`
57
+
58
+ ## Phase 7 — click autoAnalyzeAfter
59
+
60
+ - [x] Helper `getApomSnapshot(page)` в index.js
61
+ - [x] Pre/post snapshot в click handler с регистрацией новых id через `quickRegisterElements`
62
+ - [x] Дельта `+N appeared: id:"text"` / `-N disappeared` / `No APOM changes` в результате click
63
+ - [x] Schema + tool-definitions + README
64
+ - [x] `npm run build` — passed
65
+ - [x] Verified на стенде: Phase 7 механика работает (snapshots + diff + content append). На SEGM-стенде дельта пустая из-за Phase 8 проблемы (popup не попадает в дерево).
66
+
67
+ ## Phase 8 — In-tree popup detection
68
+
69
+ Открыт **при e2e-тесте Phase 7**: popup-меню стенда (Popper-style) рендерится внутри 0-height wrapper и не попадает в APOM tree, потому что `isVisible` отбрасывает wrapper.
70
+
71
+ - [x] Расширение portal-scan в `pom/apom-tree-converter.js` — новый блок после id-portalSelectors
72
+ - [x] `findPositionedPopup(el, depth=3)` — рекурсивный поиск absolute/fixed-positioned child с реальными bounds внутри 0×0 wrapper
73
+ - [x] force-mark найденного popup через существующий `forceMarkModalTree`
74
+ - [x] Используется тот же flag `portalInclude` (default `true`) — opt-out возможен через `includePortals: false`
75
+ - [x] README обновлён
76
+ - [x] `npm run build` — passed
77
+ - [ ] Verification на SEGM-стенде — требует рестарта MCP
78
+
79
+ ## Phase 6 — Финализация сессии
80
+
81
+ - [ ] Прогон всех verification на реальном QA-стенде SEGM-537 (если доступ есть)
82
+ - [ ] Спросить пользователя: bump version + CHANGELOG entry?
83
+ - [ ] Если YES — single CHANGELOG entry для всей сессии, версия += patch (3.5.4 → 3.5.5)
84
+ - [ ] Отдать QA на повтор сценария из отчёта
85
+
86
+ ## Verification status: per-phase checks
87
+
88
+ Каждая phase имеет свои verification-шаги выше. Не двигаться к следующей пока текущая не зелёная.
89
+
90
+ ## Открытые вопросы (повторяю из spec)
91
+
92
+ 1. Дефолтные id-портал-селекторы (`#menu-popup-root`, `#modal-root`, `#tooltip-root`, `#popover-root`) — норм или хотите другой набор?
93
+ 2. `keepOpen: true` для `click` — пропускаем, делаем только `waitForSelector`. Если QA увидит что попап всё равно закрывается — добавим в Phase 2.5.
94
+ 3. Авто-IIFE для `executeScript` — допустимый риск перехвата `return` в случаях, где `return` намеренно внутри функции? Митигируем regex'ом на top-level.