From bb8f44e6cd138dfddf53bced163b770c779e41ae Mon Sep 17 00:00:00 2001 From: Mathias Bynens Date: Fri, 27 Mar 2026 13:42:06 +0100 Subject: [PATCH 1/4] chore: add update notification to both binaries Both `chrome-devtools` and `chrome-devtools-mcp` now log a notification when a newer version is detected to be available. --- .npmrc | 1 + src/bin/check-latest-version.ts | 35 +++++++++ src/bin/chrome-devtools-mcp-main.ts | 3 + src/bin/chrome-devtools.ts | 5 ++ src/utils/check-for-updates.ts | 63 ++++++++++++++++ tests/check-for-updates.test.ts | 112 ++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+) create mode 100644 .npmrc create mode 100644 src/bin/check-latest-version.ts create mode 100644 src/utils/check-for-updates.ts create mode 100644 tests/check-for-updates.test.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..cafe685a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/src/bin/check-latest-version.ts b/src/bin/check-latest-version.ts new file mode 100644 index 000000000..0724d0956 --- /dev/null +++ b/src/bin/check-latest-version.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const cachePath = process.argv[2]; + +if (cachePath) { + try { + const response = await fetch( + 'https://registry.npmjs.org/chrome-devtools-mcp/latest', + ); + const data = response.ok ? await response.json() : null; + + if ( + data && + typeof data === 'object' && + 'version' in data && + typeof data.version === 'string' + ) { + await fs.mkdir(path.dirname(cachePath), {recursive: true}); + await fs.writeFile( + cachePath, + JSON.stringify({version: data.version, timestamp: Date.now()}), + ); + } + } catch { + // Ignore errors. + } +} diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index bfb6bb38e..db7d1fb50 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -12,10 +12,13 @@ import {createMcpServer, logDisclaimers} from '../index.js'; import {logger, saveLogsToFile} from '../logger.js'; import {computeFlagUsage} from '../telemetry/flagUtils.js'; import {StdioServerTransport} from '../third_party/index.js'; +import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +await checkForUpdates('Run `npm install chrome-devtools-mcp@latest` to update.'); + export const args = parseArguments(VERSION); const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 53a8eba54..43de5c6b6 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -19,11 +19,16 @@ import { import {isDaemonRunning, serializeArgs} from '../daemon/utils.js'; import {logDisclaimers} from '../index.js'; import {hideBin, yargs, type CallToolResult} from '../third_party/index.js'; +import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; import {commands} from './chrome-devtools-cli-options.js'; import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +await checkForUpdates( + 'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.', +); + async function start(args: string[]) { const combinedArgs = [...args, ...defaultArgs]; await startDaemon(combinedArgs); diff --git a/src/utils/check-for-updates.ts b/src/utils/check-for-updates.ts new file mode 100644 index 000000000..93e467d1f --- /dev/null +++ b/src/utils/check-for-updates.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import child_process from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import {VERSION} from '../version.js'; + +/** + * Notifies the user if an update is available. + * @param message The message to display in the update notification. + */ +export async function checkForUpdates(message: string) { + const cachePath = path.join( + os.homedir(), + '.cache', + 'chrome-devtools-mcp', + 'latest.json', + ); + + let cache: {version: string; timestamp: number} | undefined; + try { + const data = await fs.readFile(cachePath, 'utf8'); + cache = JSON.parse(data); + } catch { + // Ignore errors reading cache. + } + + if (cache && typeof cache.version === 'string' && cache.version !== VERSION) { + console.warn( + `\nUpdate available: ${VERSION} -> ${cache.version}\n${message}\n`, + ); + } + + const now = Date.now(); + if (cache && now - cache.timestamp < 24 * 60 * 60 * 1000) { + return; + } + + // In a separate process, check the latest available version number + // and update the local snapshot accordingly. + const scriptPath = path.join(import.meta.dirname, '..', 'bin', 'check-latest-version.js'); + + try { + const child = child_process.spawn( + process.execPath, + [scriptPath, cachePath], + { + detached: true, + stdio: 'ignore', + }, + ); + child.unref(); + } catch { + // Fail silently in case of any errors. + } +} diff --git a/tests/check-for-updates.test.ts b/tests/check-for-updates.test.ts new file mode 100644 index 000000000..ce3426b34 --- /dev/null +++ b/tests/check-for-updates.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import child_process from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import {afterEach, describe, it} from 'node:test'; + +import sinon from 'sinon'; + +import {checkForUpdates} from '../src/utils/check-for-updates.js'; +import {VERSION} from '../src/version.js'; + +describe('checkForUpdates', () => { + afterEach(() => { + sinon.restore(); + }); + + it('notifies if cache exists and version is different', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: '99.9.9', + timestamp: Date.now(), + }); + } + throw new Error(`File not found: ${filePath}`); + }); + const warnStub = sinon.stub(console, 'warn'); + const spawnStub = sinon.stub(child_process, 'spawn'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok( + warnStub.calledWith( + sinon.match('Update available: ' + VERSION + ' -> 99.9.9'), + ), + ); + assert.ok(spawnStub.notCalled); + }); + + it('does not spawn fetch process if cache is fresh', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: VERSION, + timestamp: Date.now(), + }); + } + throw new Error(`File not found: ${filePath}`); + }); + const spawnStub = sinon.stub(child_process, 'spawn'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.notCalled); + }); + + it('spawns detached process if cache is stale', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: VERSION, + timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }); + } + throw new Error(`File not found: ${filePath}`); + }); + + const unrefSpy = sinon.spy(); + const spawnStub = sinon.stub(child_process, 'spawn').returns({ + unref: unrefSpy, + } as unknown as child_process.ChildProcess); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.calledOnce); + assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); + assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js')); + assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); + assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); + assert.ok(unrefSpy.calledOnce); + }); + + it('spawns detached process if cache is missing', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + throw new Error(`File not found: ${filePath}`); + }); + + const unrefSpy = sinon.spy(); + const spawnStub = sinon.stub(child_process, 'spawn').returns({ + unref: unrefSpy, + } as unknown as child_process.ChildProcess); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.calledOnce); + assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); + assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js')); + assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); + assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); + assert.ok(unrefSpy.calledOnce); + }); +}); From c7a8ae4a07622469c3c57985fead537e21185367 Mon Sep 17 00:00:00 2001 From: Mathias Bynens Date: Wed, 1 Apr 2026 17:57:38 +0200 Subject: [PATCH 2/4] chore: use file modification time instead of explicit `timestamp` prop --- src/bin/check-latest-version.ts | 2 +- src/utils/check-for-updates.ts | 12 +++++++----- tests/check-for-updates.test.ts | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/bin/check-latest-version.ts b/src/bin/check-latest-version.ts index 0724d0956..60497da52 100644 --- a/src/bin/check-latest-version.ts +++ b/src/bin/check-latest-version.ts @@ -26,7 +26,7 @@ if (cachePath) { await fs.mkdir(path.dirname(cachePath), {recursive: true}); await fs.writeFile( cachePath, - JSON.stringify({version: data.version, timestamp: Date.now()}), + JSON.stringify({version: data.version}), ); } } catch { diff --git a/src/utils/check-for-updates.ts b/src/utils/check-for-updates.ts index 93e467d1f..b3d338cd4 100644 --- a/src/utils/check-for-updates.ts +++ b/src/utils/check-for-updates.ts @@ -24,22 +24,24 @@ export async function checkForUpdates(message: string) { 'latest.json', ); - let cache: {version: string; timestamp: number} | undefined; + let cachedVersion: string | undefined; + let stats: {mtimeMs: number} | undefined; try { + stats = await fs.stat(cachePath); const data = await fs.readFile(cachePath, 'utf8'); - cache = JSON.parse(data); + cachedVersion = JSON.parse(data).version; } catch { // Ignore errors reading cache. } - if (cache && typeof cache.version === 'string' && cache.version !== VERSION) { + if (cachedVersion && cachedVersion !== VERSION) { console.warn( - `\nUpdate available: ${VERSION} -> ${cache.version}\n${message}\n`, + `\nUpdate available: ${VERSION} -> ${cachedVersion}\n${message}\n`, ); } const now = Date.now(); - if (cache && now - cache.timestamp < 24 * 60 * 60 * 1000) { + if (stats && now - stats.mtimeMs < 24 * 60 * 60 * 1000) { return; } diff --git a/tests/check-for-updates.test.ts b/tests/check-for-updates.test.ts index ce3426b34..f1b0bc359 100644 --- a/tests/check-for-updates.test.ts +++ b/tests/check-for-updates.test.ts @@ -22,11 +22,11 @@ describe('checkForUpdates', () => { it('notifies if cache exists and version is different', async () => { sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as any); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ version: '99.9.9', - timestamp: Date.now(), }); } throw new Error(`File not found: ${filePath}`); @@ -46,11 +46,11 @@ describe('checkForUpdates', () => { it('does not spawn fetch process if cache is fresh', async () => { sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as any); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ version: VERSION, - timestamp: Date.now(), }); } throw new Error(`File not found: ${filePath}`); @@ -64,11 +64,13 @@ describe('checkForUpdates', () => { it('spawns detached process if cache is stale', async () => { sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({ + mtimeMs: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + } as any); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ version: VERSION, - timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago }); } throw new Error(`File not found: ${filePath}`); @@ -83,7 +85,9 @@ describe('checkForUpdates', () => { assert.ok(spawnStub.calledOnce); assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); - assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js')); + assert.ok( + spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'), + ); assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); assert.ok(unrefSpy.calledOnce); @@ -91,6 +95,7 @@ describe('checkForUpdates', () => { it('spawns detached process if cache is missing', async () => { sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').rejects(new Error('File not found')); sinon.stub(fs, 'readFile').callsFake(async filePath => { throw new Error(`File not found: ${filePath}`); }); @@ -104,7 +109,9 @@ describe('checkForUpdates', () => { assert.ok(spawnStub.calledOnce); assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); - assert.ok(spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js')); + assert.ok( + spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'), + ); assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); assert.ok(unrefSpy.calledOnce); From f394814c28fdf528d61fe9556a5107d7d64796a4 Mon Sep 17 00:00:00 2001 From: Mathias Bynens Date: Thu, 2 Apr 2026 09:10:21 +0200 Subject: [PATCH 3/4] chore: support `CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS` env var --- README.md | 30 +++++++++++++--------- src/bin/check-latest-version.ts | 5 +--- src/bin/chrome-devtools-mcp-cli-options.ts | 2 +- src/bin/chrome-devtools-mcp-main.ts | 4 ++- src/utils/check-for-updates.ts | 11 +++++++- tests/check-for-updates.test.ts | 25 +++++++++++++++--- 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 438024c3e..edbb171db 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,12 @@ Google handles this data in accordance with the [Google Privacy Policy](https:// Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa. -Collection is disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set. +Collection is disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set. + +## Update checks + +By default, the server periodically checks the npm registry for updates and logs a notification when a newer version is available. +You can disable these update checks by setting the `CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS` environment variable. ## Requirements @@ -74,7 +79,7 @@ Add the following config to your MCP client: } ``` -> [!NOTE] +> [!NOTE] > Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server. If you are interested in doing only basic browser tasks, use the `--slim` mode: @@ -143,7 +148,7 @@ claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest **Install as a Plugin (MCP + Skills)** -> [!NOTE] +> [!NOTE] > If you already had Chrome DevTools MCP installed previously for Claude Code, make sure to remove it first from your installation and configuration files. To install Chrome DevTools MCP with skills, add the marketplace registry in Claude Code: @@ -200,7 +205,7 @@ startup_timeout_ms = 20_000
Command Code - + Use the Command Code CLI to add the Chrome DevTools MCP server (MCP guide): ```bash @@ -402,10 +407,11 @@ qodercli mcp add -s user chrome-devtools -- npx chrome-devtools-mcp@latest
Visual Studio - - **Click the button to install:** - - [Install in Visual Studio](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D) + +**Click the button to install:** + +[Install in Visual Studio](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D) +
@@ -431,7 +437,7 @@ Check the performance of https://developers.chrome.com Your MCP client should open the browser and record a performance trace. -> [!NOTE] +> [!NOTE] > The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser. ## Tools @@ -572,7 +578,7 @@ The Chrome DevTools MCP server supports the following configuration option: - **Default:** `true` - **`--usageStatistics`/ `--usage-statistics`** - Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set. + Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set. - **Type:** boolean - **Default:** `true` @@ -686,7 +692,7 @@ Make sure your browser is running. Open gemini-cli and run the following prompt: Check the performance of https://developers.chrome.com ``` -> [!NOTE] +> [!NOTE] > The autoConnect option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile. The Chrome DevTools MCP server will try to connect to your running Chrome @@ -722,7 +728,7 @@ Add the `--browser-url` option to your MCP client configuration. The value of th **Step 2: Start the Chrome browser** -> [!WARNING] +> [!WARNING] > Enabling the remote debugging port opens up a debugging port on the running browser instance. Any application on your machine can connect to this port and control the browser. Make sure that you are not browsing any sensitive websites while the debugging port is open. Start the Chrome browser with the remote debugging port enabled. Make sure to close any running Chrome instances before starting a new one with the debugging port enabled. The port number you choose must be the same as the one you specified in the `--browser-url` option in your MCP client configuration. diff --git a/src/bin/check-latest-version.ts b/src/bin/check-latest-version.ts index 60497da52..eb45674df 100644 --- a/src/bin/check-latest-version.ts +++ b/src/bin/check-latest-version.ts @@ -24,10 +24,7 @@ if (cachePath) { typeof data.version === 'string' ) { await fs.mkdir(path.dirname(cachePath), {recursive: true}); - await fs.writeFile( - cachePath, - JSON.stringify({version: data.version}), - ); + await fs.writeFile(cachePath, JSON.stringify({version: data.version})); } } catch { // Ignore errors. diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 80046b115..8fedf2ae7 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -232,7 +232,7 @@ export const cliOptions = { type: 'boolean', default: true, describe: - 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.', + 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set.', }, clearcutEndpoint: { type: 'string', diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index db7d1fb50..46100ed94 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -17,7 +17,9 @@ import {VERSION} from '../version.js'; import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; -await checkForUpdates('Run `npm install chrome-devtools-mcp@latest` to update.'); +await checkForUpdates( + 'Run `npm install chrome-devtools-mcp@latest` to update.', +); export const args = parseArguments(VERSION); diff --git a/src/utils/check-for-updates.ts b/src/utils/check-for-updates.ts index b3d338cd4..920863bef 100644 --- a/src/utils/check-for-updates.ts +++ b/src/utils/check-for-updates.ts @@ -17,6 +17,10 @@ import {VERSION} from '../version.js'; * @param message The message to display in the update notification. */ export async function checkForUpdates(message: string) { + if (process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']) { + return; + } + const cachePath = path.join( os.homedir(), '.cache', @@ -47,7 +51,12 @@ export async function checkForUpdates(message: string) { // In a separate process, check the latest available version number // and update the local snapshot accordingly. - const scriptPath = path.join(import.meta.dirname, '..', 'bin', 'check-latest-version.js'); + const scriptPath = path.join( + import.meta.dirname, + '..', + 'bin', + 'check-latest-version.js', + ); try { const child = child_process.spawn( diff --git a/tests/check-for-updates.test.ts b/tests/check-for-updates.test.ts index f1b0bc359..f690bebfd 100644 --- a/tests/check-for-updates.test.ts +++ b/tests/check-for-updates.test.ts @@ -6,6 +6,7 @@ import assert from 'node:assert'; import child_process from 'node:child_process'; +import type {Stats} from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import {afterEach, describe, it} from 'node:test'; @@ -20,9 +21,27 @@ describe('checkForUpdates', () => { sinon.restore(); }); + it('does nothing if CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS is set', async () => { + process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS'] = 'true'; + + const warnStub = sinon.stub(console, 'warn'); + const spawnStub = sinon.stub(child_process, 'spawn'); + const readFileStub = sinon.stub(fs, 'readFile'); + const statStub = sinon.stub(fs, 'stat'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(warnStub.notCalled); + assert.ok(spawnStub.notCalled); + assert.ok(readFileStub.notCalled); + assert.ok(statStub.notCalled); + + delete process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']; + }); + it('notifies if cache exists and version is different', async () => { sinon.stub(os, 'homedir').returns('/home/user'); - sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as any); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as unknown as Stats); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ @@ -46,7 +65,7 @@ describe('checkForUpdates', () => { it('does not spawn fetch process if cache is fresh', async () => { sinon.stub(os, 'homedir').returns('/home/user'); - sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as any); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as unknown as Stats); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ @@ -66,7 +85,7 @@ describe('checkForUpdates', () => { sinon.stub(os, 'homedir').returns('/home/user'); sinon.stub(fs, 'stat').resolves({ mtimeMs: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago - } as any); + } as unknown as Stats); sinon.stub(fs, 'readFile').callsFake(async filePath => { if (filePath.toString().includes('latest.json')) { return JSON.stringify({ From f53ab369334d0ead1284149c40f9ca793c33e6ed Mon Sep 17 00:00:00 2001 From: Mathias Bynens Date: Thu, 2 Apr 2026 09:46:23 +0200 Subject: [PATCH 4/4] chore: prevent spawning concurrent subprocesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update the cache file’s `mtime` before spawning the subprocess. - Add `isChecking` flag to prevent concurrent checks in the same process, including while testing. --- src/utils/check-for-updates.ts | 24 +++++++++++++++++++++++- tests/check-for-updates.test.ts | 14 ++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/utils/check-for-updates.ts b/src/utils/check-for-updates.ts index 920863bef..36c5b13b8 100644 --- a/src/utils/check-for-updates.ts +++ b/src/utils/check-for-updates.ts @@ -16,10 +16,18 @@ import {VERSION} from '../version.js'; * Notifies the user if an update is available. * @param message The message to display in the update notification. */ +let isChecking = false; + +/** @internal Reset flag for tests only. */ +export function resetUpdateCheckFlagForTesting() { + isChecking = false; +} + export async function checkForUpdates(message: string) { - if (process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']) { + if (isChecking || process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']) { return; } + isChecking = true; const cachePath = path.join( os.homedir(), @@ -49,6 +57,20 @@ export async function checkForUpdates(message: string) { return; } + // Update mtime immediately to prevent multiple subprocesses. + try { + const parentDir = path.dirname(cachePath); + await fs.mkdir(parentDir, {recursive: true}); + const nowTime = new Date(); + if (stats) { + await fs.utimes(cachePath, nowTime, nowTime); + } else { + await fs.writeFile(cachePath, JSON.stringify({version: VERSION})); + } + } catch { + // Ignore errors. + } + // In a separate process, check the latest available version number // and update the local snapshot accordingly. const scriptPath = path.join( diff --git a/tests/check-for-updates.test.ts b/tests/check-for-updates.test.ts index f690bebfd..82413c493 100644 --- a/tests/check-for-updates.test.ts +++ b/tests/check-for-updates.test.ts @@ -9,16 +9,26 @@ import child_process from 'node:child_process'; import type {Stats} from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; -import {afterEach, describe, it} from 'node:test'; +import {afterEach, beforeEach, describe, it} from 'node:test'; import sinon from 'sinon'; -import {checkForUpdates} from '../src/utils/check-for-updates.js'; +import { + checkForUpdates, + resetUpdateCheckFlagForTesting, +} from '../src/utils/check-for-updates.js'; import {VERSION} from '../src/version.js'; describe('checkForUpdates', () => { + beforeEach(() => { + sinon.stub(fs, 'mkdir').resolves(); + sinon.stub(fs, 'utimes').resolves(); + sinon.stub(fs, 'writeFile').resolves(); + }); + afterEach(() => { sinon.restore(); + resetUpdateCheckFlagForTesting(); }); it('does nothing if CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS is set', async () => {