chromedevtools--chrome-devtools-mcp
b4546ef86b
release-please / release-please (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 26 (push) Has been cancelled
Check code before submitting / [Required] Check correct format (push) Has been cancelled
Check code before submitting / [Required] Check docs updated (push) Has been cancelled
Compile and run tests / [Required] Tests passed (push) Has been cancelled
Assortment of various flakiness conditions found running tests in a loop locally: This PR introduces a comprehensive set of hermetic retry layers and aggressive timeout handlers across the test suite to insulate it from random Chromium startup hangs, CDP deadlocks, and Puppeteer lifecycle flakes. It guarantees that temporary browser infrastructure failures are automatically retried without failing the CI, while actual code assertion failures still fail fast. ### Test Harness & Retry Improvements • tests/utils.ts: Rewrote withBrowser to include a 30-second internal timeout and a 3-attempt retry loop. If Chromium locks up or disconnects (Target closed / socket hang up), the browser is forcibly evicted (via SIGKILL if browser.close() hangs) and the test setup is cleanly retried. • tests/index.test.ts: Wrapped withClient (used by E2E tests) in a 3-attempt retry loop to handle the daemon/Chromium hanging during launch and triggering the 60-second MCP client timeout. • tests/browser.test.ts: Added a safeClose helper that imposes a 2-second timeout before SIGKILLing browsers, and wrapped raw Puppeteer tests in runWithRetry to handle startup hangs. • tests/shutdown.test.ts: Added a setupServerWithRetry helper to prevent random 60s RPC timeouts when the server's Chrome instance hangs during boot. ### Flaky Operations & Navigation Fixes • src/tools/performance.ts & tests/tools/performance.test.ts: Replaced the notoriously flaky waitUntil: ['networkidle0'] with 'load' when navigating to about:blank in performance_start_trace. This prevents random 10-second Navigation timeout exceeded errors. Also stubbed goto in the associated unit tests for better hermeticity. • src/McpContext.ts: Wrapped browser.installExtension() with a 15-second timeout to prevent deadlocks when an extension fails to load. • tests/tools/extensions.test.ts: Removed flaky headless UI navigations to chrome://extensions in favor of using the context.listExtensions() API. • tests/tools/pages.test.js.snapshot: Synced test snapshots to reflect updated environment baselines.
248 行
6.7 KiB
TypeScript
248 行
6.7 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import {executablePath} from 'puppeteer';
|
|
|
|
import {detectDisplay, ensureBrowserConnected, launch} from '../src/browser.js';
|
|
import type {Browser} from '../src/third_party/index.js';
|
|
|
|
import {serverHooks} from './server.js';
|
|
|
|
async function safeClose(browser: Browser) {
|
|
try {
|
|
await Promise.race([
|
|
browser.close(),
|
|
new Promise((_, rej) =>
|
|
setTimeout(() => rej(new Error('timeout')), 2000),
|
|
),
|
|
]);
|
|
} catch {
|
|
browser.process()?.kill('SIGKILL');
|
|
}
|
|
}
|
|
|
|
async function runWithRetry(fn: () => Promise<void>) {
|
|
let lastError: Error | undefined;
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
await Promise.race([
|
|
fn(),
|
|
new Promise((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error('Test execution timeout exceeded')),
|
|
20000,
|
|
),
|
|
),
|
|
]);
|
|
return;
|
|
} catch (e) {
|
|
lastError = e as Error;
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
describe('browser', () => {
|
|
it('detects display does not crash', () => {
|
|
detectDisplay();
|
|
});
|
|
|
|
it('cannot launch multiple times with the same profile', async () => {
|
|
await runWithRetry(async () => {
|
|
const tmpDir = os.tmpdir();
|
|
const folderPath = path.join(
|
|
tmpDir,
|
|
`temp-folder-${crypto.randomUUID()}`,
|
|
);
|
|
const browser1 = await launch({
|
|
headless: true,
|
|
isolated: false,
|
|
userDataDir: folderPath,
|
|
executablePath: await executablePath(),
|
|
devtools: false,
|
|
});
|
|
try {
|
|
try {
|
|
const browser2 = await launch({
|
|
headless: true,
|
|
isolated: false,
|
|
userDataDir: folderPath,
|
|
executablePath: await executablePath(),
|
|
devtools: false,
|
|
});
|
|
await safeClose(browser2);
|
|
assert.fail('not reached');
|
|
} catch (err) {
|
|
assert.strictEqual(
|
|
(err as Error).message,
|
|
`The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`,
|
|
);
|
|
}
|
|
} finally {
|
|
await safeClose(browser1);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('launches with the initial viewport', async () => {
|
|
await runWithRetry(async () => {
|
|
const tmpDir = os.tmpdir();
|
|
const folderPath = path.join(
|
|
tmpDir,
|
|
`temp-folder-${crypto.randomUUID()}`,
|
|
);
|
|
const browser = await launch({
|
|
headless: true,
|
|
isolated: false,
|
|
userDataDir: folderPath,
|
|
executablePath: await executablePath(),
|
|
viewport: {
|
|
width: 1501,
|
|
height: 801,
|
|
},
|
|
devtools: false,
|
|
});
|
|
try {
|
|
const [page] = await browser.pages();
|
|
const result = await page.evaluate(() => {
|
|
return {width: window.innerWidth, height: window.innerHeight};
|
|
});
|
|
assert.deepStrictEqual(result, {
|
|
width: 1501,
|
|
height: 801,
|
|
});
|
|
} finally {
|
|
await safeClose(browser);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('connects to an existing browser with userDataDir', async () => {
|
|
await runWithRetry(async () => {
|
|
const tmpDir = os.tmpdir();
|
|
const folderPath = path.join(
|
|
tmpDir,
|
|
`temp-folder-${crypto.randomUUID()}`,
|
|
);
|
|
const browser = await launch({
|
|
headless: true,
|
|
isolated: false,
|
|
userDataDir: folderPath,
|
|
executablePath: await executablePath(),
|
|
devtools: false,
|
|
chromeArgs: ['--remote-debugging-port=0'],
|
|
});
|
|
try {
|
|
const connectedBrowser = await ensureBrowserConnected({
|
|
userDataDir: folderPath,
|
|
devtools: false,
|
|
});
|
|
assert.ok(connectedBrowser);
|
|
assert.ok(connectedBrowser.connected);
|
|
connectedBrowser.disconnect();
|
|
} finally {
|
|
await safeClose(browser);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Blocking', () => {
|
|
const server = serverHooks();
|
|
|
|
it('blocks URLs in blocklist', async () => {
|
|
await runWithRetry(async () => {
|
|
server.addHtmlRoute(
|
|
'/allowed.html',
|
|
'<html><body>Allowed</body></html>',
|
|
);
|
|
server.addHtmlRoute(
|
|
'/blocked.html',
|
|
'<html><body>Blocked</body></html>',
|
|
);
|
|
|
|
const browser = await launch({
|
|
headless: true,
|
|
isolated: true,
|
|
executablePath: await executablePath(),
|
|
devtools: false,
|
|
blocklist: ['*://*:*/blocked.html'],
|
|
});
|
|
try {
|
|
const page = await browser.newPage();
|
|
|
|
// Access allowed URL
|
|
await page.goto(server.getRoute('/allowed.html'));
|
|
const content = await page.evaluate(() => document.body.textContent);
|
|
assert.strictEqual(content, 'Allowed');
|
|
|
|
// Fetch of blocked URL from the page
|
|
const fetchSucceeded = await page.evaluate(async url => {
|
|
try {
|
|
await fetch(url, {signal: AbortSignal.timeout(5000)});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, server.getRoute('/blocked.html'));
|
|
|
|
assert.strictEqual(fetchSucceeded, false);
|
|
} finally {
|
|
await safeClose(browser);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('blocks URLs not in allowlist', async () => {
|
|
await runWithRetry(async () => {
|
|
server.addHtmlRoute(
|
|
'/allowed.html',
|
|
'<html><body>Allowed</body></html>',
|
|
);
|
|
server.addHtmlRoute(
|
|
'/blocked.html',
|
|
'<html><body>Blocked</body></html>',
|
|
);
|
|
|
|
const browser = await launch({
|
|
headless: true,
|
|
isolated: true,
|
|
executablePath: await executablePath(),
|
|
devtools: false,
|
|
allowlist: ['*://*:*/allowed.html'],
|
|
});
|
|
try {
|
|
const page = await browser.newPage();
|
|
|
|
// Access allowed URL
|
|
await page.goto(server.getRoute('/allowed.html'));
|
|
const content = await page.evaluate(() => document.body.textContent);
|
|
assert.strictEqual(content, 'Allowed');
|
|
|
|
// Fetch of blocked URL from the page
|
|
const fetchSucceeded = await page.evaluate(async url => {
|
|
try {
|
|
await fetch(url, {signal: AbortSignal.timeout(5000)});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, server.getRoute('/blocked.html'));
|
|
|
|
assert.strictEqual(fetchSucceeded, false);
|
|
} finally {
|
|
await safeClose(browser);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|