Skip to content

Commit a4f9a1b

Browse files
authored
Merge pull request #37 from freema/fix/snapshot-visibility-and-error-handling
fix: Improve snapshot visibility checking and error handling
2 parents 47b27bd + c780a3f commit a4f9a1b

File tree

14 files changed

+441
-69
lines changed

14 files changed

+441
-69
lines changed

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
## [0.6.1] - 2026-02-04
11+
12+
### Added
13+
- **Enhanced Vue/Livewire/Alpine.js support**: New snapshot options for modern JavaScript frameworks
14+
- `includeAll` parameter: Include all visible elements without relevance filtering
15+
- `selector` parameter: Scope snapshot to specific DOM subtree using CSS selector
16+
- Fixes [#36](https://github.com/freema/firefox-devtools-mcp/issues/36) - DOM filtering problem with Vue and Livewire applications
17+
- **Test fixtures**: Added new HTML fixtures for testing visibility edge cases (`visibility.html`, `selector.html`)
18+
19+
### Changed
20+
- **Improved element relevance detection**:
21+
- Fixed text content checking to use direct text only (excluding descendants)
22+
- Added check for interactive descendants to include wrapper elements
23+
- Implemented "bubble-up" pattern in tree walker to preserve nested interactive elements
24+
- Elements with `v-*`, `wire:*`, `x-*` attributes and custom components are now properly captured with `includeAll=true`
25+
26+
### Fixed
27+
- **Visibility checking now considers ancestor elements**: Elements inside hidden parents (e.g., `display:none`, `visibility:hidden`) are now correctly excluded from snapshots, even in `includeAll` mode
28+
- **Opacity parsing improved**: Fixed opacity check to properly handle various numeric formats (`0`, `0.0`, `0.00`) by parsing as float instead of string comparison
29+
- **CSS selector error handling**: Invalid CSS selectors now return clear error messages (`"Invalid selector syntax"`) instead of generic `"Unknown error"`
30+
- Interactive elements deeply nested in non-relevant wrapper divs are now correctly captured
31+
- Container elements with large descendant text content no longer incorrectly filtered out
32+
- Custom HTML elements (Vue/Livewire components) are now visible in snapshots with `includeAll=true`
33+
34+
## [0.6.0] - 2025-12-01
35+
36+
Released on npm, see GitHub releases for details.
37+
838
## [0.5.3] - 2025-01-30
939

1040
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "firefox-devtools-mcp",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "Model Context Protocol (MCP) server for Firefox DevTools automation",
55
"author": "freema",
66
"license": "MIT",

src/firefox/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { FirefoxCore } from './core.js';
77
import { ConsoleEvents, NetworkEvents } from './events/index.js';
88
import { DomInteractions } from './dom.js';
99
import { PageManagement } from './pages.js';
10-
import { SnapshotManager, type Snapshot } from './snapshot/index.js';
10+
import { SnapshotManager, type Snapshot, type SnapshotOptions } from './snapshot/index.js';
1111

1212
/**
1313
* Main Firefox Client facade
@@ -315,11 +315,11 @@ export class FirefoxClient {
315315
// Snapshot
316316
// ============================================================================
317317

318-
async takeSnapshot(): Promise<Snapshot> {
318+
async takeSnapshot(options?: SnapshotOptions): Promise<Snapshot> {
319319
if (!this.snapshot) {
320320
throw new Error('Not connected');
321321
}
322-
return await this.snapshot.takeSnapshot();
322+
return await this.snapshot.takeSnapshot(options);
323323
}
324324

325325
resolveUidToSelector(uid: string): string {

src/firefox/snapshot/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Snapshot module exports
33
*/
44

5-
export { SnapshotManager } from './manager.js';
5+
export { SnapshotManager, type SnapshotOptions } from './manager.js';
66
export type {
77
Snapshot,
88
SnapshotNode,

src/firefox/snapshot/injected/attributeCollector.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,10 @@ export function getComputedProperties(el: Element): ComputedProperties {
146146
// Visible
147147
try {
148148
const style = window.getComputedStyle(el);
149+
// Parse opacity as number to handle '0', '0.0', '0.00', etc.
150+
const opacity = parseFloat(style.opacity);
149151
computed.visible =
150-
style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
152+
style.display !== 'none' && style.visibility !== 'hidden' && opacity !== 0 && !isNaN(opacity);
151153
} catch (e) {
152154
computed.visible = false;
153155
}

src/firefox/snapshot/injected/elementCollector.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,72 @@ const SEMANTIC_TAGS = ['nav', 'main', 'section', 'article', 'header', 'footer',
2828
const CONTAINER_TAGS = ['div', 'span', 'p', 'li', 'ul', 'ol'];
2929

3030
/**
31-
* Max text content length for containers
31+
* Max direct text content length for containers
3232
*/
33-
const MAX_TEXT_CONTENT = 500;
33+
const MAX_DIRECT_TEXT_CONTENT = 500;
34+
35+
/**
36+
* Check if element is visible
37+
* Checks current element and all ancestors up to documentElement
38+
*/
39+
export function isVisible(el: Element): boolean {
40+
if (!el || el.nodeType !== Node.ELEMENT_NODE) {
41+
return false;
42+
}
43+
44+
// Check current element and all ancestors
45+
let current: Element | null = el;
46+
while (current && current !== document.documentElement) {
47+
try {
48+
const style = window.getComputedStyle(current);
49+
// Parse opacity as number to handle '0', '0.0', '0.00', etc.
50+
const opacity = parseFloat(style.opacity);
51+
if (
52+
style.display === 'none' ||
53+
style.visibility === 'hidden' ||
54+
opacity === 0 ||
55+
isNaN(opacity)
56+
) {
57+
return false;
58+
}
59+
} catch (e) {
60+
return false;
61+
}
62+
current = current.parentElement;
63+
}
64+
65+
return true;
66+
}
67+
68+
/**
69+
* Get direct text content (not including descendants)
70+
*/
71+
function getDirectTextContent(el: Element): string {
72+
let text = '';
73+
for (let i = 0; i < el.childNodes.length; i++) {
74+
const node = el.childNodes[i];
75+
if (node && node.nodeType === Node.TEXT_NODE) {
76+
text += node.textContent || '';
77+
}
78+
}
79+
return text.trim();
80+
}
81+
82+
/**
83+
* Check if element has interactive descendants
84+
*/
85+
function hasInteractiveDescendant(el: Element): boolean {
86+
for (let i = 0; i < el.children.length; i++) {
87+
const child = el.children[i];
88+
if (child) {
89+
const tag = child.tagName.toLowerCase();
90+
if (INTERACTIVE_TAGS.indexOf(tag) !== -1 || child.hasAttribute('role')) {
91+
return true;
92+
}
93+
}
94+
}
95+
return false;
96+
}
3497

3598
/**
3699
* Check if element is relevant for snapshot
@@ -42,12 +105,7 @@ export function isRelevant(el: Element): boolean {
42105
}
43106

44107
// Check visibility
45-
try {
46-
const style = window.getComputedStyle(el);
47-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
48-
return false;
49-
}
50-
} catch (e) {
108+
if (!isVisible(el)) {
51109
return false;
52110
}
53111

@@ -80,15 +138,19 @@ export function isRelevant(el: Element): boolean {
80138

81139
// Common containers - need additional checks
82140
if (CONTAINER_TAGS.indexOf(tag) !== -1) {
83-
// Has meaningful text?
84-
const textContent = (el.textContent || '').trim();
85-
if (textContent.length > 0 && textContent.length < MAX_TEXT_CONTENT) {
141+
// Has meaningful direct text (not from descendants)?
142+
const directText = getDirectTextContent(el);
143+
if (directText.length > 0 && directText.length < MAX_DIRECT_TEXT_CONTENT) {
86144
return true;
87145
}
88146
// Has id or class?
89147
if (el.id || el.className) {
90148
return true;
91149
}
150+
// Has interactive descendants?
151+
if (hasInteractiveDescendant(el)) {
152+
return true;
153+
}
92154
}
93155

94156
return false;

src/firefox/snapshot/injected/snapshot.injected.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,65 @@
33
* This gets bundled and injected into browser context
44
*/
55

6-
import { walkTree } from './treeWalker.js';
6+
import { walkTree, type TreeWalkerOptions } from './treeWalker.js';
77
import type { TreeWalkerResult } from './treeWalker.js';
88

9+
/**
10+
* Options for snapshot creation
11+
*/
12+
export interface CreateSnapshotOptions extends TreeWalkerOptions {
13+
selector?: string;
14+
}
15+
16+
/**
17+
* Result from snapshot creation
18+
*/
19+
export interface CreateSnapshotResult extends TreeWalkerResult {
20+
selectorError?: string;
21+
}
22+
923
/**
1024
* Create snapshot of current page
1125
* This function is called from executeScript
1226
*/
13-
export function createSnapshot(snapshotId: number): TreeWalkerResult {
27+
export function createSnapshot(
28+
snapshotId: number,
29+
options?: CreateSnapshotOptions
30+
): CreateSnapshotResult {
1431
try {
15-
// Walk from body
16-
const result = walkTree(document.body, snapshotId, true);
32+
// Determine root element
33+
let rootElement: Element = document.body;
34+
35+
if (options?.selector) {
36+
try {
37+
const selected = document.querySelector(options.selector);
38+
if (!selected) {
39+
return {
40+
tree: null,
41+
uidMap: [],
42+
truncated: false,
43+
selectorError: `Selector "${options.selector}" not found`,
44+
};
45+
}
46+
rootElement = selected;
47+
} catch (error: any) {
48+
return {
49+
tree: null,
50+
uidMap: [],
51+
truncated: false,
52+
selectorError: `Invalid selector syntax: "${options.selector}"`,
53+
};
54+
}
55+
}
56+
57+
// Walk from root element
58+
const treeOptions: TreeWalkerOptions = {
59+
includeIframes: options?.includeIframes ?? true,
60+
};
61+
if (options?.includeAll !== undefined) {
62+
treeOptions.includeAll = options.includeAll;
63+
}
64+
const result = walkTree(rootElement, snapshotId, treeOptions);
1765

1866
if (!result.tree) {
1967
throw new Error('Failed to generate tree');

0 commit comments

Comments
 (0)