Skip to content

Commit 5c5e9ff

Browse files
authored
Merge pull request #23 from webdriverio/feature/snapshot-exports
feat: Add snapshot Subpath Export with Browser Accessibility Tree Support
2 parents 9620dd1 + 97a4a0f commit 5c5e9ff

File tree

7 files changed

+205
-87
lines changed

7 files changed

+205
-87
lines changed

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
"main": "./lib/server.js",
1111
"module": "./lib/server.js",
1212
"types": "./lib/server.d.ts",
13+
"exports": {
14+
".": {
15+
"import": "./lib/server.js",
16+
"types": "./lib/server.d.ts"
17+
},
18+
"./snapshot": {
19+
"import": "./lib/snapshot.js",
20+
"types": "./lib/snapshot.d.ts"
21+
}
22+
},
1323
"bin": {
1424
"wdio-mcp": "lib/server.js"
1525
},
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Browser accessibility tree snapshot
3+
* Extracts semantic information about page elements (roles, names, states)
4+
*/
5+
6+
export interface AccessibilityNode {
7+
role: string;
8+
name: string;
9+
value: string;
10+
description: string;
11+
disabled: string;
12+
focused: string;
13+
selected: string;
14+
checked: string;
15+
expanded: string;
16+
pressed: string;
17+
readonly: string;
18+
required: string;
19+
level: string | number;
20+
valuemin: string | number;
21+
valuemax: string | number;
22+
autocomplete: string;
23+
haspopup: string;
24+
invalid: string;
25+
modal: string;
26+
multiline: string;
27+
multiselectable: string;
28+
orientation: string;
29+
keyshortcuts: string;
30+
roledescription: string;
31+
valuetext: string;
32+
}
33+
34+
/**
35+
* Flatten a hierarchical accessibility tree into a flat list
36+
* Uses uniform fields (all nodes have same keys) to enable tabular format
37+
*/
38+
function flattenAccessibilityTree(node: any, result: AccessibilityNode[] = []): AccessibilityNode[] {
39+
if (!node) return result;
40+
41+
// Add current node (excluding root WebArea unless it has meaningful content)
42+
if (node.role !== 'WebArea' || node.name) {
43+
const entry: AccessibilityNode = {
44+
role: node.role || '',
45+
name: node.name || '',
46+
value: node.value ?? '',
47+
description: node.description || '',
48+
disabled: node.disabled ? 'true' : '',
49+
focused: node.focused ? 'true' : '',
50+
selected: node.selected ? 'true' : '',
51+
checked: node.checked === true ? 'true' : node.checked === false ? 'false' : node.checked === 'mixed' ? 'mixed' : '',
52+
expanded: node.expanded === true ? 'true' : node.expanded === false ? 'false' : '',
53+
pressed: node.pressed === true ? 'true' : node.pressed === false ? 'false' : node.pressed === 'mixed' ? 'mixed' : '',
54+
readonly: node.readonly ? 'true' : '',
55+
required: node.required ? 'true' : '',
56+
level: node.level ?? '',
57+
valuemin: node.valuemin ?? '',
58+
valuemax: node.valuemax ?? '',
59+
autocomplete: node.autocomplete || '',
60+
haspopup: node.haspopup || '',
61+
invalid: node.invalid ? 'true' : '',
62+
modal: node.modal ? 'true' : '',
63+
multiline: node.multiline ? 'true' : '',
64+
multiselectable: node.multiselectable ? 'true' : '',
65+
orientation: node.orientation || '',
66+
keyshortcuts: node.keyshortcuts || '',
67+
roledescription: node.roledescription || '',
68+
valuetext: node.valuetext || '',
69+
};
70+
result.push(entry);
71+
}
72+
73+
// Recursively process children
74+
if (node.children && Array.isArray(node.children)) {
75+
for (const child of node.children) {
76+
flattenAccessibilityTree(child, result);
77+
}
78+
}
79+
80+
return result;
81+
}
82+
83+
/**
84+
* Get browser accessibility tree snapshot
85+
* Browser-only - requires Puppeteer access via WebDriverIO browser instance
86+
*
87+
* @param browser - WebDriverIO browser instance
88+
* @returns Flattened accessibility tree nodes
89+
*/
90+
export async function getBrowserAccessibilityTree(
91+
browser: WebdriverIO.Browser,
92+
): Promise<AccessibilityNode[]> {
93+
// Get Puppeteer instance for native accessibility API
94+
const puppeteer = await browser.getPuppeteer();
95+
const pages = await puppeteer.pages();
96+
97+
if (pages.length === 0) {
98+
return [];
99+
}
100+
101+
const page = pages[0];
102+
103+
// Get accessibility snapshot with interestingOnly filter
104+
const snapshot = await page.accessibility.snapshot({
105+
interestingOnly: true,
106+
});
107+
108+
if (!snapshot) {
109+
return [];
110+
}
111+
112+
return flattenAccessibilityTree(snapshot);
113+
}

src/scripts/get-interactable-browser-elements.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
1+
export type ElementType = 'interactable' | 'visual' | 'all';
2+
3+
export interface BrowserElementInfo {
4+
tagName: string;
5+
type: string;
6+
id: string;
7+
className: string;
8+
textContent: string;
9+
value: string;
10+
placeholder: string;
11+
href: string;
12+
ariaLabel: string;
13+
role: string;
14+
src: string;
15+
alt: string;
16+
cssSelector: string;
17+
isInViewport: boolean;
18+
}
19+
20+
export interface GetBrowserElementsOptions {
21+
/** Type of elements to return. Default: 'interactable' */
22+
elementType?: ElementType;
23+
}
24+
125
/**
226
* Browser script to get visible elements on the page
327
* Supports interactable elements, visual elements, or both
428
*
5-
* @param elementType - Type of elements to return: 'interactable', 'visual', or 'all'
29+
* NOTE: This script runs in browser context via browser.execute()
30+
* It must be self-contained with no external dependencies
631
*/
7-
const elementsScript = (elementType: 'interactable' | 'visual' | 'all' = 'interactable') => (function () {
32+
const elementsScript = (elementType: ElementType = 'interactable') => (function () {
833
const interactableSelectors = [
934
'a[href]', // Links with href
1035
'button', // Buttons
@@ -193,4 +218,21 @@ const elementsScript = (elementType: 'interactable' | 'visual' | 'all' = 'intera
193218
return getElements();
194219
})();
195220

221+
/**
222+
* Get browser interactable elements
223+
* Wrapper function that executes the script in browser context
224+
*
225+
* @param browser - WebDriverIO browser instance
226+
* @param options - Options for element filtering
227+
* @returns Array of visible element information
228+
*/
229+
export async function getBrowserInteractableElements(
230+
browser: WebdriverIO.Browser,
231+
options: GetBrowserElementsOptions = {},
232+
): Promise<BrowserElementInfo[]> {
233+
const { elementType = 'interactable' } = options;
234+
// browser.execute runs in browser context, returns untyped data
235+
return browser.execute(elementsScript, elementType) as unknown as Promise<BrowserElementInfo[]>;
236+
}
237+
196238
export default elementsScript;

src/snapshot.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Element snapshot utilities for browser and mobile
3+
*
4+
* Lightweight subpath export - does NOT include MCP server dependencies
5+
* Usage: import { getBrowserAccessibilityTree, getBrowserInteractableElements, getMobileVisibleElements } from '@wdio/mcp/snapshot'
6+
*/
7+
8+
// Browser accessibility tree
9+
export { getBrowserAccessibilityTree, type AccessibilityNode } from './scripts/get-browser-accessibility-tree';
10+
11+
// Browser interactable elements
12+
export {
13+
getBrowserInteractableElements,
14+
type BrowserElementInfo,
15+
type GetBrowserElementsOptions,
16+
type ElementType,
17+
} from './scripts/get-interactable-browser-elements';
18+
19+
// Mobile element detection (requires xmldom + xpath)
20+
export {
21+
getMobileVisibleElements,
22+
type MobileElementInfo,
23+
type GetMobileElementsOptions,
24+
} from './scripts/get-visible-mobile-elements';

src/tools/get-accessibility-tree.tool.ts

Lines changed: 6 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getBrowser } from './browser.tool';
2+
import { getBrowserAccessibilityTree } from '../scripts/get-browser-accessibility-tree';
23
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
34
import type { CallToolResult } from '@modelcontextprotocol/sdk/types';
45
import type { ToolDefinition } from '../types/tool';
@@ -23,63 +24,6 @@ export const getAccessibilityToolDefinition: ToolDefinition = {
2324
},
2425
};
2526

26-
/**
27-
* Flatten a hierarchical accessibility tree into a flat list
28-
* Uses uniform fields (all nodes have same keys) to enable tabular format
29-
* @param node - The accessibility node
30-
* @param result - Accumulator array
31-
*/
32-
function flattenAccessibilityTree(node: any, result: any[] = []): any[] {
33-
if (!node) return result;
34-
35-
// Add current node (excluding root WebArea unless it has meaningful content)
36-
if (node.role !== 'WebArea' || node.name) {
37-
// Build object with ALL fields for uniform schema (enables tabular format)
38-
// Empty string '' used for missing values to keep schema consistent
39-
const entry: Record<string, any> = {
40-
// Primary identifiers (most useful)
41-
role: node.role || '',
42-
name: node.name || '',
43-
value: node.value ?? '',
44-
description: node.description || '',
45-
// Boolean states (empty string = not applicable/false)
46-
disabled: node.disabled ? 'true' : '',
47-
focused: node.focused ? 'true' : '',
48-
selected: node.selected ? 'true' : '',
49-
checked: node.checked === true ? 'true' : node.checked === false ? 'false' : node.checked === 'mixed' ? 'mixed' : '',
50-
expanded: node.expanded === true ? 'true' : node.expanded === false ? 'false' : '',
51-
pressed: node.pressed === true ? 'true' : node.pressed === false ? 'false' : node.pressed === 'mixed' ? 'mixed' : '',
52-
readonly: node.readonly ? 'true' : '',
53-
required: node.required ? 'true' : '',
54-
// Less common properties
55-
level: node.level ?? '',
56-
valuemin: node.valuemin ?? '',
57-
valuemax: node.valuemax ?? '',
58-
autocomplete: node.autocomplete || '',
59-
haspopup: node.haspopup || '',
60-
invalid: node.invalid ? 'true' : '',
61-
modal: node.modal ? 'true' : '',
62-
multiline: node.multiline ? 'true' : '',
63-
multiselectable: node.multiselectable ? 'true' : '',
64-
orientation: node.orientation || '',
65-
keyshortcuts: node.keyshortcuts || '',
66-
roledescription: node.roledescription || '',
67-
valuetext: node.valuetext || '',
68-
};
69-
70-
result.push(entry);
71-
}
72-
73-
// Recursively process children
74-
if (node.children && Array.isArray(node.children)) {
75-
for (const child of node.children) {
76-
flattenAccessibilityTree(child, result);
77-
}
78-
}
79-
80-
return result;
81-
}
82-
8327
export const getAccessibilityTreeTool: ToolCallback = async (args: {
8428
limit?: number;
8529
offset?: number;
@@ -101,41 +45,23 @@ export const getAccessibilityTreeTool: ToolCallback = async (args: {
10145

10246
const { limit = 100, offset = 0, roles, namedOnly = true } = args || {};
10347

104-
// Get Puppeteer instance for native accessibility API
105-
const puppeteer = await browser.getPuppeteer();
106-
const pages = await puppeteer.pages();
48+
let nodes = await getBrowserAccessibilityTree(browser);
10749

108-
if (pages.length === 0) {
109-
return {
110-
content: [{ type: 'text', text: 'No active pages found' }],
111-
};
112-
}
113-
114-
const page = pages[0];
115-
116-
// Get accessibility snapshot with interestingOnly filter
117-
const snapshot = await page.accessibility.snapshot({
118-
interestingOnly: true, // Filter to only interesting/semantic nodes
119-
});
120-
121-
if (!snapshot) {
50+
if (nodes.length === 0) {
12251
return {
12352
content: [{ type: 'text', text: 'No accessibility tree available' }],
12453
};
12554
}
12655

127-
// Flatten the hierarchical tree into a flat list
128-
let nodes = flattenAccessibilityTree(snapshot);
129-
13056
// Filter to named nodes only (removes anonymous containers, StaticText duplicates)
13157
if (namedOnly) {
132-
nodes = nodes.filter(n => n.name && n.name.trim() !== '');
58+
nodes = nodes.filter((n) => n.name && n.name.trim() !== '');
13359
}
13460

13561
// Filter to specific roles if provided
13662
if (roles && roles.length > 0) {
137-
const roleSet = new Set(roles.map(r => r.toLowerCase()));
138-
nodes = nodes.filter(n => n.role && roleSet.has(n.role.toLowerCase()));
63+
const roleSet = new Set(roles.map((r) => r.toLowerCase()));
64+
nodes = nodes.filter((n) => n.role && roleSet.has(n.role.toLowerCase()));
13965
}
14066

14167
const total = nodes.length;

src/tools/get-visible-elements.tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getBrowser } from './browser.tool';
2-
import getInteractableElements from '../scripts/get-interactable-browser-elements';
2+
import { getBrowserInteractableElements } from '../scripts/get-interactable-browser-elements';
33
import { getMobileVisibleElements } from '../scripts/get-visible-mobile-elements';
44
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
55
import type { ToolDefinition } from '../types/tool';
@@ -78,7 +78,7 @@ export const getVisibleElementsTool: ToolCallback = async (args: {
7878
elements = await getMobileVisibleElements(browser, platform, { includeContainers, includeBounds });
7979
} else {
8080
// Keep uniform fields (no stripping) to enable CSV tabular format
81-
elements = await browser.execute(getInteractableElements, elementType);
81+
elements = await getBrowserInteractableElements(browser, { elementType });
8282
}
8383

8484
if (inViewportOnly) {

tsup.config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { defineConfig } from 'tsup';
22

33
export default defineConfig({
4-
entry: ['src/server.ts'], // Entry points
5-
format: ['esm'], // Build for commonJS and ESmodules
6-
dts: true, // Generate declaration file (.d.ts)
4+
entry: {
5+
server: 'src/server.ts',
6+
snapshot: 'src/snapshot.ts',
7+
},
8+
format: ['esm'],
9+
dts: true,
710
splitting: false,
811
sourcemap: true,
912
clean: true,

0 commit comments

Comments
 (0)