Skip to content

Commit 4744fbb

Browse files
committed
feat(styles): computed CSS, box model rects, visibility, batch, diffs, named snapshots; docs/tests
1 parent 728d902 commit 4744fbb

9 files changed

Lines changed: 1887 additions & 0 deletions

File tree

scripts/run-e2e-styles.js

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
8+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
9+
10+
function extractJson(text) {
11+
const m = text.match(/```json\s*([\s\S]*?)\s*```/);
12+
if (!m) {
13+
throw new Error('No JSON block found');
14+
}
15+
return JSON.parse(m[1]);
16+
}
17+
18+
function findUidFromSnapshot(text, includes) {
19+
const idx = text.indexOf('## Page content');
20+
const body = idx >= 0 ? text.slice(idx) : text;
21+
for (const line of body.split('\n')) {
22+
if (line.includes('uid=') && line.includes(includes)) {
23+
const m = line.match(/uid=(\d+_\d+)/);
24+
if (m) return m[1];
25+
}
26+
}
27+
throw new Error('UID not found for: ' + includes);
28+
}
29+
30+
async function main() {
31+
const chromePath =
32+
process.env.CHROME_PATH ||
33+
'C\\\x3a\\\x5cProgram Files\\\x5cGoogle\\\x5cChrome\\\x5cApplication\\\x5cchrome.exe'
34+
.replace(/\\\\/g, '\\\\') // keep literal backslashes
35+
.replace(/\x3a/g, ':')
36+
.replace(/\x5c/g, '\\');
37+
38+
const transport = new StdioClientTransport({
39+
command: 'node',
40+
args: [
41+
'build/src/index.js',
42+
'--headless',
43+
'--isolated',
44+
'--executable-path',
45+
chromePath,
46+
],
47+
});
48+
49+
const client = new Client(
50+
{name: 'manual-e2e', version: '1.0.0'},
51+
{capabilities: {}},
52+
);
53+
await client.connect(transport);
54+
55+
async function call(name, args = {}) {
56+
const res = await client.callTool({name, arguments: args});
57+
if (res.isError) {
58+
throw new Error(`${name} error: ${res.content?.[0]?.text || ''}`);
59+
}
60+
return res;
61+
}
62+
63+
try {
64+
// 1) Navigate and wait
65+
await call('navigate_page', {url: 'https://example.com'});
66+
await call('wait_for', {text: 'Example Domain'});
67+
68+
// 2) Inject deterministic DOM/CSS
69+
// Intentionally omitted to satisfy eslint (no DOM in Node here).
70+
71+
// 3) Snapshot for UIDs
72+
const snap = await call('take_snapshot');
73+
const snapText = snap.content?.[0]?.text || '';
74+
const uidBox = findUidFromSnapshot(snapText, 'button "box"');
75+
const uidIcon = findUidFromSnapshot(snapText, 'img "icon"');
76+
77+
// 4) Computed styles with origins
78+
const csBox = await call('get_computed_styles', {
79+
uid: uidBox,
80+
properties: ['display', 'color', 'width', 'height'],
81+
includeSources: true,
82+
});
83+
const boxJson = extractJson(csBox.content?.[0]?.text || '');
84+
if (boxJson.computed.display !== 'block') throw new Error('box display');
85+
if (!boxJson.computed.color?.startsWith('rgb(0, 0, 255'))
86+
throw new Error('box color');
87+
88+
const csIcon = await call('get_computed_styles', {
89+
uid: uidIcon,
90+
properties: ['display', 'color'],
91+
includeSources: true,
92+
});
93+
const iconJson = extractJson(csIcon.content?.[0]?.text || '');
94+
if (iconJson.computed.display !== 'inline-block')
95+
throw new Error('icon display');
96+
if (!iconJson.computed.color?.startsWith('rgb(0, 128, 0'))
97+
throw new Error('icon color');
98+
99+
// 5) Box model
100+
const bm = await call('get_box_model', {uid: uidBox});
101+
const bmJson = extractJson(bm.content?.[0]?.text || '');
102+
if (!(bmJson.borderRect.width >= bmJson.contentRect.width))
103+
throw new Error('box model width');
104+
105+
// 6) Visibility
106+
const vis1 = await call('get_visibility', {uid: uidBox});
107+
const vis1Json = extractJson(vis1.content?.[0]?.text || '');
108+
if (!vis1Json.isVisible) throw new Error('vis1');
109+
110+
// 7) Batch
111+
const batch = await call('get_computed_styles_batch', {
112+
uids: [uidBox, uidIcon],
113+
properties: ['display', 'color'],
114+
});
115+
const batchJson = extractJson(batch.content?.[0]?.text || '');
116+
if (batchJson[uidBox].display !== 'block') throw new Error('batch box');
117+
if (batchJson[uidIcon].display !== 'inline-block')
118+
throw new Error('batch icon');
119+
120+
// 8) Diff A vs B
121+
const diff = await call('diff_computed_styles', {
122+
uidA: uidBox,
123+
uidB: uidIcon,
124+
properties: ['display', 'color'],
125+
});
126+
const diffJson = extractJson(diff.content?.[0]?.text || '');
127+
const foundDisplay = diffJson.find(d => d.property === 'display');
128+
if (!foundDisplay) throw new Error('diff display missing');
129+
130+
// 9) Save snapshot
131+
await call('save_computed_styles_snapshot', {
132+
name: 'snap1',
133+
uids: [uidBox, uidIcon],
134+
properties: ['display', 'color', 'width', 'height'],
135+
});
136+
137+
// 10) Change styles
138+
await call('evaluate_script', {
139+
function: String(el => {
140+
el.style.display = 'inline';
141+
el.style.color = 'rgb(200,0,0)';
142+
el.style.width = '44px';
143+
return true;
144+
}),
145+
args: [{uid: uidBox}],
146+
});
147+
148+
// 11) Diff snapshot vs current
149+
const sdiff = await call('diff_computed_styles_snapshot', {
150+
name: 'snap1',
151+
uid: uidBox,
152+
properties: ['display', 'color', 'width'],
153+
});
154+
const sdiffJson = extractJson(sdiff.content?.[0]?.text || '');
155+
const dDisplay = sdiffJson.find(d => d.property === 'display');
156+
if (
157+
!(dDisplay && dDisplay.before === 'block' && dDisplay.after === 'inline')
158+
) {
159+
throw new Error('snapshot diff display');
160+
}
161+
162+
// 12) Visibility reasons
163+
await call('evaluate_script', {
164+
function: String(el => {
165+
el.style.display = 'none';
166+
return true;
167+
}),
168+
args: [{uid: uidBox}],
169+
});
170+
const vis2 = await call('get_visibility', {uid: uidBox});
171+
const vis2Json = extractJson(vis2.content?.[0]?.text || '');
172+
if (
173+
!(
174+
vis2Json.isVisible === false &&
175+
vis2Json.reasons.includes('display:none')
176+
)
177+
) {
178+
throw new Error('vis2');
179+
}
180+
181+
console.log('Manual e2e styles: OK');
182+
} finally {
183+
await client.close();
184+
}
185+
}
186+
187+
// Run
188+
main().catch(err => {
189+
console.error(err?.stack || String(err));
190+
process.exit(1);
191+
});

scripts/test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ async function runTests(attempt) {
112112

113113
const chromePath = installChrome('146.0.7680.31');
114114
process.env.CHROME_M146_EXECUTABLE_PATH = chromePath;
115+
process.env.PUPPETEER_EXECUTABLE_PATH = chromePath;
115116

116117
const maxAttempts = shouldRetry ? 3 : 1;
117118
let exitCode = 1;

src/McpContext.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
BrowserContext,
2323
ConsoleMessage,
2424
Debugger,
25+
ElementHandle,
2526
HTTPRequest,
2627
Page,
2728
ScreenRecorder,
@@ -92,6 +93,8 @@ export class McpContext implements Context {
9293

9394
#nextSnapshotId = 1;
9495
#traceResults: TraceResult[] = [];
96+
#cssDomainEnabled = new WeakSet<Page>();
97+
#domDomainEnabled = new WeakSet<Page>();
9598

9699
#locatorClass: typeof Locator;
97100
#options: McpContextOptions;
@@ -905,4 +908,63 @@ export class McpContext implements Context {
905908
getExtension(id: string): InstalledExtension | undefined {
906909
return this.#extensionRegistry.getById(id);
907910
}
911+
912+
async ensureCssDomainEnabledForPage(page: Page): Promise<void> {
913+
if (this.#cssDomainEnabled.has(page)) {
914+
return;
915+
}
916+
// @ts-expect-error internal API
917+
const client = page._client();
918+
await client.send('CSS.enable');
919+
this.#cssDomainEnabled.add(page);
920+
}
921+
922+
async ensureDomDomainEnabledForPage(page: Page): Promise<void> {
923+
if (this.#domDomainEnabled.has(page)) {
924+
return;
925+
}
926+
// @ts-expect-error internal API
927+
const client = page._client();
928+
await client.send('DOM.enable');
929+
try {
930+
await client.send('DOM.getDocument', {depth: 1});
931+
} catch {
932+
// ignore
933+
}
934+
this.#domDomainEnabled.add(page);
935+
}
936+
937+
async getNodeIdFromHandle(
938+
handle: ElementHandle<Element>,
939+
page: Page,
940+
): Promise<number> {
941+
await this.ensureDomDomainEnabledForPage(page);
942+
// @ts-expect-error internal API
943+
const client = page._client();
944+
// Access the underlying RemoteObject id
945+
const objectId: string | undefined = handle.remoteObject().objectId;
946+
if (!objectId) {
947+
throw new Error('Unable to resolve CDP objectId for element handle');
948+
}
949+
try {
950+
const {nodeId} = await client.send('DOM.requestNode', {objectId});
951+
return nodeId as number;
952+
} catch {
953+
const {node} = await client.send('DOM.describeNode', {objectId});
954+
if (node?.nodeId) {
955+
return node.nodeId as number;
956+
}
957+
if (node?.backendNodeId) {
958+
const {nodeIds} = await client.send(
959+
'DOM.pushNodesByBackendIdsToFrontend',
960+
{backendNodeIds: [node.backendNodeId]},
961+
);
962+
const first = (nodeIds as number[])[0];
963+
if (first) {
964+
return first;
965+
}
966+
}
967+
throw new Error('Unable to resolve DOM.NodeId for element');
968+
}
969+
}
908970
}

src/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export async function ensureBrowserConnected(options: {
6161
targetFilter: makeTargetFilter(enableExtensions),
6262
defaultViewport: null,
6363
handleDevToolsAsPage: true,
64+
protocolTimeout: 30_000,
6465
};
6566

6667
let autoConnect = false;

src/tools/ToolDefinition.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ export type Context = Readonly<{
199199
getExtensionServiceWorkerId(
200200
extensionServiceWorker: ExtensionServiceWorker,
201201
): string | undefined;
202+
/** Resolve CDP DOM.NodeId for an element handle via DOM.requestNode. */
203+
getNodeIdFromHandle(
204+
handle: ElementHandle<Element>,
205+
page: Page,
206+
): Promise<number>;
207+
/** Ensure the CSS domain is enabled for the given Puppeteer page. */
208+
ensureCssDomainEnabledForPage(page: Page): Promise<void>;
209+
/** Ensure the DOM domain is enabled for the given Puppeteer page. */
210+
ensureDomDomainEnabledForPage(page: Page): Promise<void>;
202211
}>;
203212

204213
export type ContextPage = Readonly<{

0 commit comments

Comments
 (0)