mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
feat(config): add --watchConfig to reload config file changes (#3248)
Adds `--watchConfig` (`watchConfig: boolean`, default `false`) to watch the resolved configuration file and reload reloadable options when it changes: - Adds `--watchConfig` in `mcpOptions` and regenerates the CLI/configuration documentation and telemetry metrics. - Adds `ServerConfig.getRestartRequiredChanges(next)` to identify non-reloadable options that changed on reload. - Adds `McpServer.watchConfig(reload)`, which starts a `ConfigWatcher` on `ServerConfig.configPath`, calls `reload()` and `applyConfig()` on change, logs a warning to `stderr` when restart-required options change, keeps the previous configuration if reloading or validation fails, and disposes the watcher on `McpServer.close()`. - Wires `--watchConfig` into both the stdio server entrypoint (`chrome-devtools-mcp-main.ts`) and the daemon server (`daemon.ts`).
This commit is contained in:
1 parent
35abe350a4
commit
b48561f6ca
10 files changed
+465
-6
No files matched your search
@@ -243,6 +243,11 @@ The Chrome DevTools MCP server supports the following configuration option:
|
||||
Path to JSON configuration file.
|
||||
- **Type:** string
|
||||
|
||||
- **`--watchConfig`/ `--watch-config`**
|
||||
Watch the config file for changes and apply them without restarting. Tools (except --categoryExtensions) are enabled, disabled and updated on the fly; options used to launch or connect to the browser still require a restart.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
<!-- END AUTO GENERATED OPTIONS -->
|
||||
|
||||
Pass them via the `args` property in the JSON configuration. For example:
|
||||
@@ -296,6 +301,20 @@ Set the `CHROME_DEVTOOLS_MCP_NO_CONFIG_DISCOVERY` env variable to turn off the
|
||||
search for config files, for example in tests. A config file passed via
|
||||
`--config` is still used.
|
||||
|
||||
### Applying changes without a restart
|
||||
|
||||
Pass `--watchConfig` to reload the config file whenever it changes. Watching is
|
||||
off by default. On a change, the server re-reads the file, updates the list of
|
||||
available tools, and notifies the MCP client with
|
||||
`notifications/tools/list_changed`. If the new file is invalid, the server logs
|
||||
the error to stderr and keeps the previous configuration.
|
||||
|
||||
Tool categories (except `categoryExtensions`) and tool-specific options apply
|
||||
immediately. Options that configure the browser, such as `headless`, `channel`,
|
||||
`viewport`, `blockedUrlPattern`, or `categoryExtensions`, only apply after
|
||||
restarting chrome-devtools-mcp. The server logs a message to stderr when a
|
||||
change requires a restart.
|
||||
|
||||
### Locking down the configuration file from agent access
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -85,6 +85,9 @@ process.on('SIGHUP', () => {
|
||||
});
|
||||
|
||||
const server = await serverPromise;
|
||||
if (args.watchConfig) {
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
}
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logger?.('Chrome DevTools MCP Server connected');
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {isDeepStrictEqual} from 'node:util';
|
||||
|
||||
import type {InferredOptionTypes} from '../third_party/cli.js';
|
||||
import {yargs} from '../third_party/cli.js';
|
||||
|
||||
@@ -306,6 +308,26 @@ export class ServerConfig implements ReadonlyServerConfig {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the restart-required options whose resolved values differ between
|
||||
* this config and `next`.
|
||||
*/
|
||||
getRestartRequiredChanges(next: ServerConfig): Array<keyof ParsedArguments> {
|
||||
const changed: Array<keyof ParsedArguments> = [];
|
||||
for (const name of Object.keys(mcpOptions)) {
|
||||
if (
|
||||
isMcpOptionKey(name) &&
|
||||
!isReloadableOption(name) &&
|
||||
(!isDeepStrictEqual(this.#fileArgs[name], next.#fileArgs[name]) ||
|
||||
!isDeepStrictEqual(this.#cliArgs[name], next.#cliArgs[name])) &&
|
||||
!isDeepStrictEqual(this.#resolved[name], next.#resolved[name])
|
||||
) {
|
||||
changed.push(name);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a previously validated {@link ServerConfig} snapshot in place so
|
||||
* all shared {@link ReadonlyServerConfig} readers observe the new values.
|
||||
|
||||
@@ -238,6 +238,12 @@ export const mcpOptions = {
|
||||
return path.resolve(configPath);
|
||||
},
|
||||
},
|
||||
watchConfig: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe:
|
||||
'Watch the config file for changes and apply them without restarting. Tools (except --categoryExtensions) are enabled, disabled and updated on the fly; options used to launch or connect to the browser still require a restart.',
|
||||
},
|
||||
} satisfies Record<string, McpOption>;
|
||||
|
||||
export const mcpOptionsForViaCli = {
|
||||
|
||||
@@ -151,6 +151,9 @@ async function setupMCPServer(): Promise<void> {
|
||||
browserManager,
|
||||
logFile,
|
||||
});
|
||||
if (args.watchConfig) {
|
||||
mcpServer.watchConfig(() => configParser.reloadConfig());
|
||||
}
|
||||
ClearcutLogger.get()?.setClientName(DAEMON_CLIENT_NAME);
|
||||
void ClearcutLogger.get()?.logDailyActiveIfNeeded();
|
||||
void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, mcpOptions));
|
||||
|
||||
+67
-1
@@ -10,6 +10,7 @@ import {pathToFileURL} from 'node:url';
|
||||
|
||||
import {BrowserManager} from './BrowserManager.js';
|
||||
import type {ParsedArguments} from './config/ConfigParser.js';
|
||||
import {ConfigWatcher} from './config/ConfigWatcher.js';
|
||||
import {DEFAULT_FILESYSTEM_ROOT} from './config/mcp-options.js';
|
||||
import {ServerConfig} from './config/ServerConfig.js';
|
||||
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
|
||||
@@ -32,6 +33,7 @@ import {ToolHandler} from './ToolHandler.js';
|
||||
import type {AnyToolDefinition} from './tools/ToolDefinition.js';
|
||||
import {createTools} from './tools/tools.js';
|
||||
import {logCruxDisclaimer} from './utils/disclaimers.js';
|
||||
import {getErrorMessage} from './utils/errorHandling.js';
|
||||
import {logger} from './utils/logger.js';
|
||||
import {VERSION} from './version.js';
|
||||
|
||||
@@ -76,6 +78,7 @@ export class McpServer {
|
||||
*/
|
||||
#awaitedInitialRoots = false;
|
||||
#toolMutex = new Mutex();
|
||||
#configWatcher?: ConfigWatcher;
|
||||
#tools = new Map<string, ToolEntry>();
|
||||
|
||||
private constructor(config: ServerConfig, options: McpServerOptions) {
|
||||
@@ -178,8 +181,11 @@ export class McpServer {
|
||||
*/
|
||||
async applyConfig(config: ServerConfig): Promise<void> {
|
||||
using guard = await this.#toolMutex.acquire();
|
||||
await this.#applyConfigLocked(this.#config.mergeReloadable(config));
|
||||
}
|
||||
|
||||
async #applyConfigLocked(nextConfig: ServerConfig): Promise<void> {
|
||||
const previousArgs = this.#config.resolved;
|
||||
const nextConfig = this.#config.mergeReloadable(config);
|
||||
const nextArgs = nextConfig.resolved;
|
||||
// Build everything before committing so that a throwing tool factory
|
||||
// leaves the server untouched.
|
||||
@@ -214,10 +220,70 @@ export class McpServer {
|
||||
await this.#context.onConfigChanged(previousArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the config file and applies its changes to the running server.
|
||||
*
|
||||
* @param reload Re-reads the config file and returns the new configuration.
|
||||
*/
|
||||
watchConfig(reload: () => ServerConfig): void {
|
||||
const configPath = this.#config.configPath;
|
||||
if (!configPath) {
|
||||
console.error(
|
||||
'--watchConfig has no effect because no config file was found.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#configWatcher?.dispose();
|
||||
this.#configWatcher = undefined;
|
||||
const watcher = new ConfigWatcher(configPath, () =>
|
||||
this.#reloadConfig(watcher, configPath, reload),
|
||||
);
|
||||
try {
|
||||
watcher.start();
|
||||
} catch (err) {
|
||||
watcher.dispose();
|
||||
console.error(`Failed to watch ${configPath}: ${getErrorMessage(err)}`);
|
||||
return;
|
||||
}
|
||||
this.#configWatcher = watcher;
|
||||
}
|
||||
|
||||
async #reloadConfig(
|
||||
watcher: ConfigWatcher,
|
||||
configPath: string,
|
||||
reload: () => ServerConfig,
|
||||
): Promise<void> {
|
||||
using guard = await this.#toolMutex.acquire();
|
||||
if (this.#configWatcher !== watcher) {
|
||||
return;
|
||||
}
|
||||
let config: ServerConfig;
|
||||
let nextConfig: ServerConfig;
|
||||
try {
|
||||
config = reload();
|
||||
nextConfig = this.#config.mergeReloadable(config);
|
||||
} catch (err) {
|
||||
const message = `Keeping the previous configuration, failed to reload ${configPath}: ${getErrorMessage(err)}`;
|
||||
logger?.(message);
|
||||
console.error(message);
|
||||
return;
|
||||
}
|
||||
await this.#applyConfigLocked(nextConfig);
|
||||
const restartRequired = this.#config.getRestartRequiredChanges(config);
|
||||
logger?.(`Applied config changes from ${configPath}`);
|
||||
if (restartRequired.length > 0) {
|
||||
const message = `Restart chrome-devtools-mcp to fully apply changes to ${restartRequired.map(name => `--${name}`).join(', ')} from ${configPath}.`;
|
||||
logger?.(message);
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the MCP connection and disposes internal context/listeners.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.#configWatcher?.dispose();
|
||||
this.#configWatcher = undefined;
|
||||
try {
|
||||
this.#context.dispose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -470,5 +470,13 @@
|
||||
{
|
||||
"name": "screencast_fps_present",
|
||||
"flagType": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "watch_config_present",
|
||||
"flagType": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "watch_config",
|
||||
"flagType": "boolean"
|
||||
}
|
||||
]
|
||||
+278
-5
@@ -15,13 +15,16 @@ import sinon from 'sinon';
|
||||
|
||||
import {BrowserManager} from '../src/BrowserManager.js';
|
||||
import {getOffByDefaultCategories} from '../src/config/category-options.js';
|
||||
import {ConfigLocator} from '../src/config/ConfigLocator.js';
|
||||
import {ConfigParser} from '../src/config/ConfigParser.js';
|
||||
import {ConfigWatcher} from '../src/config/ConfigWatcher.js';
|
||||
import {mcpOptions} from '../src/config/mcp-options.js';
|
||||
import {McpServer} from '../src/index.js';
|
||||
import {McpContext} from '../src/McpContext.js';
|
||||
import {ClearcutLogger} from '../src/telemetry/ClearcutLogger.js';
|
||||
import type {ToolCategory} from '../src/tools/categories.js';
|
||||
import type {ToolDefinition} from '../src/tools/ToolDefinition.js';
|
||||
import {saveLogsToFile} from '../src/utils/logger.js';
|
||||
|
||||
import {createMockMcpContext, createMockPuppeteerBrowser} from './mocks.js';
|
||||
import {createTempFile} from './utils.js';
|
||||
@@ -31,10 +34,22 @@ describe('McpServer', () => {
|
||||
ClearcutLogger.resetForTesting();
|
||||
});
|
||||
|
||||
function createConfigParser(argv: string[] = []) {
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(undefined);
|
||||
return new ConfigParser(
|
||||
'1.0.0',
|
||||
['node', 'script.js', ...argv],
|
||||
{
|
||||
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
|
||||
},
|
||||
false,
|
||||
locator,
|
||||
);
|
||||
}
|
||||
|
||||
function parseConfig(argv: string[] = []) {
|
||||
return new ConfigParser('1.0.0', ['node', 'script.js', ...argv], {
|
||||
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
|
||||
}).parseConfig();
|
||||
return createConfigParser(argv).parseConfig();
|
||||
}
|
||||
|
||||
async function createTestServer(
|
||||
@@ -52,9 +67,10 @@ describe('McpServer', () => {
|
||||
sinon.stub(McpContext, 'create').returns(context);
|
||||
}
|
||||
|
||||
const config = parseConfig(extraArgs);
|
||||
const configParser = createConfigParser(extraArgs);
|
||||
const config = configParser.parseConfig();
|
||||
const server = await McpServer.from(config, {browserManager});
|
||||
return {server, browserManager, context, config};
|
||||
return {server, browserManager, context, config, configParser};
|
||||
}
|
||||
|
||||
async function connectClient(server: McpServer): Promise<Client> {
|
||||
@@ -528,6 +544,263 @@ describe('McpServer', () => {
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('watchConfig', () => {
|
||||
async function watchTestServer(configPath: string) {
|
||||
const logStream = sinon.createStubInstance(fs.WriteStream);
|
||||
sinon.stub(fs, 'createWriteStream').returns(logStream);
|
||||
saveLogsToFile('test.log');
|
||||
let watchListener: fs.WatchListener<string> | undefined;
|
||||
const watchStub = sinon
|
||||
.stub(fs, 'watch')
|
||||
.callsFake(
|
||||
(
|
||||
filename: fs.PathLike,
|
||||
options?:
|
||||
| fs.WatchOptions
|
||||
| BufferEncoding
|
||||
| fs.WatchListener<string>
|
||||
| null,
|
||||
listener?: fs.WatchListener<string>,
|
||||
) => {
|
||||
assert.ok(listener);
|
||||
watchListener = listener;
|
||||
return watchStub.wrappedMethod(filename, () => {}).unref();
|
||||
},
|
||||
);
|
||||
const clock = sinon.useFakeTimers({
|
||||
toFake: ['setTimeout', 'clearTimeout'],
|
||||
});
|
||||
const {server, context, config, configParser} = await createTestServer([
|
||||
'--config',
|
||||
configPath,
|
||||
]);
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
logStream.write.resetHistory();
|
||||
|
||||
async function updateConfigFile(content: string) {
|
||||
fs.writeFileSync(configPath, content);
|
||||
assert.ok(watchListener);
|
||||
watchListener('change', path.basename(configPath));
|
||||
await clock.tickAsync(200);
|
||||
}
|
||||
|
||||
return {
|
||||
server,
|
||||
context,
|
||||
config,
|
||||
logStream,
|
||||
updateConfigFile,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('does not start a watcher when no config file was resolved', async () => {
|
||||
const startStub = sinon.stub(ConfigWatcher.prototype, 'start');
|
||||
const {server, configParser} = await createTestServer();
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
|
||||
sinon.assert.notCalled(startStub);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
consoleError,
|
||||
'--watchConfig has no effect because no config file was found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an error and disposes the watcher when starting the watcher fails', async () => {
|
||||
using configFile = createTempFile('{}', 'cd4a.watch.error.json');
|
||||
sinon
|
||||
.stub(ConfigWatcher.prototype, 'start')
|
||||
.throws(new Error('watch limit reached'));
|
||||
const disposeStub = sinon.stub(ConfigWatcher.prototype, 'dispose');
|
||||
const {server, configParser} = await createTestServer([
|
||||
'--config',
|
||||
configFile.path,
|
||||
]);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
|
||||
sinon.assert.calledOnceWithExactly(disposeStub);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
consoleError,
|
||||
`Failed to watch ${configFile.path}: watch limit reached`,
|
||||
);
|
||||
});
|
||||
|
||||
it('disposes the previous watcher when called again and on close', async () => {
|
||||
using configFile = createTempFile('{}', 'cd4a.watch.lifecycle.json');
|
||||
const startStub = sinon.stub(ConfigWatcher.prototype, 'start');
|
||||
const disposeStub = sinon.stub(ConfigWatcher.prototype, 'dispose');
|
||||
const {server, configParser} = await createTestServer([
|
||||
'--config',
|
||||
configFile.path,
|
||||
]);
|
||||
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
sinon.assert.calledOnceWithExactly(startStub);
|
||||
sinon.assert.notCalled(disposeStub);
|
||||
|
||||
server.watchConfig(() => configParser.reloadConfig());
|
||||
sinon.assert.calledTwice(startStub);
|
||||
sinon.assert.calledOnceWithExactly(disposeStub);
|
||||
|
||||
await server.close();
|
||||
sinon.assert.calledTwice(disposeStub);
|
||||
|
||||
await server.close();
|
||||
sinon.assert.calledTwice(disposeStub);
|
||||
});
|
||||
|
||||
it('applies reloadable config changes when the config file changes', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({memoryDebugging: false}),
|
||||
'cd4a.watch.reloadable.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
await watched.updateConfigFile(JSON.stringify({memoryDebugging: true}));
|
||||
|
||||
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
|
||||
assert.ok(
|
||||
(await listServerToolNames(watched.server)).includes(
|
||||
'get_heapsnapshot_summary',
|
||||
),
|
||||
);
|
||||
sinon.assert.notCalled(consoleError);
|
||||
});
|
||||
|
||||
it('does not apply a queued reload after the server is closed', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({memoryDebugging: false}),
|
||||
'cd4a.watch.closed.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const {promise: toolBrowser, resolve: releaseToolCall} =
|
||||
Promise.withResolvers<ReturnType<typeof createMockPuppeteerBrowser>>();
|
||||
const {promise: toolEntered, resolve: markToolEntered} =
|
||||
Promise.withResolvers<void>();
|
||||
watched.context.ensureBrowser.onFirstCall().callsFake(async () => {
|
||||
markToolEntered();
|
||||
return await toolBrowser;
|
||||
});
|
||||
|
||||
const toolCall = watched.server.callTool('list_pages');
|
||||
await toolEntered;
|
||||
|
||||
const reloadPromise = watched.updateConfigFile(
|
||||
JSON.stringify({memoryDebugging: true}),
|
||||
);
|
||||
await watched.server.close();
|
||||
releaseToolCall(createMockPuppeteerBrowser());
|
||||
await toolCall;
|
||||
await reloadPromise;
|
||||
|
||||
assert.strictEqual(watched.config.resolved.memoryDebugging, false);
|
||||
sinon.assert.notCalled(watched.context.onConfigChanged);
|
||||
});
|
||||
|
||||
it('logs a restart notice when restart-required options change', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({
|
||||
headless: false,
|
||||
memoryDebugging: false,
|
||||
}),
|
||||
'cd4a.watch.restart.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
await watched.updateConfigFile(
|
||||
JSON.stringify({
|
||||
headless: true,
|
||||
memoryDebugging: true,
|
||||
categoryExtensions: true,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
|
||||
assert.strictEqual(watched.config.resolved.headless, false);
|
||||
assert.strictEqual(watched.config.resolved.categoryExtensions, undefined);
|
||||
const expectedNotice = `Restart chrome-devtools-mcp to fully apply changes to --categoryExtensions, --headless from ${configFile.path}.`;
|
||||
sinon.assert.calledOnceWithExactly(consoleError, expectedNotice);
|
||||
sinon.assert.calledTwice(watched.logStream.write);
|
||||
sinon.assert.calledWithMatch(
|
||||
watched.logStream.write.firstCall,
|
||||
`Applied config changes from ${configFile.path}\n`,
|
||||
);
|
||||
sinon.assert.calledWithMatch(
|
||||
watched.logStream.write.secondCall,
|
||||
`${expectedNotice}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the previous configuration when reloading the config file fails', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({memoryDebugging: true}),
|
||||
'cd4a.watch.invalid.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
await watched.updateConfigFile('[]');
|
||||
|
||||
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
|
||||
sinon.assert.notCalled(watched.context.onConfigChanged);
|
||||
const expectedError = `Keeping the previous configuration, failed to reload ${configFile.path}: Invalid JSON config file: Config must be a JSON object`;
|
||||
sinon.assert.calledOnceWithExactly(consoleError, expectedError);
|
||||
sinon.assert.calledOnceWithMatch(
|
||||
watched.logStream.write,
|
||||
`${expectedError}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the previous configuration when reloaded options conflict with startup options', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({browserUrl: 'http://127.0.0.1:9222'}),
|
||||
'cd4a.watch.conflict.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
await watched.updateConfigFile(JSON.stringify({categoryPwa: true}));
|
||||
|
||||
assert.strictEqual(watched.config.resolved.categoryPwa, undefined);
|
||||
sinon.assert.notCalled(watched.context.onConfigChanged);
|
||||
const expectedError = `Keeping the previous configuration, failed to reload ${configFile.path}: Arguments categoryPwa and browserUrl are mutually exclusive`;
|
||||
sinon.assert.calledOnceWithExactly(consoleError, expectedError);
|
||||
sinon.assert.calledOnceWithMatch(
|
||||
watched.logStream.write,
|
||||
`${expectedError}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log keeping the previous configuration when applying committed config fails', async () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({memoryDebugging: false}),
|
||||
'cd4a.watch.apply-error.json',
|
||||
);
|
||||
await using watched = await watchTestServer(configFile.path);
|
||||
const applyError = new Error('failed to update pages');
|
||||
watched.context.onConfigChanged.rejects(applyError);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
|
||||
await watched.updateConfigFile(JSON.stringify({memoryDebugging: true}));
|
||||
|
||||
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
consoleError,
|
||||
`Failed to apply ${configFile.path}:`,
|
||||
applyError,
|
||||
);
|
||||
sinon.assert.notCalled(watched.logStream.write);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function getToolsWithFilteredCategories(
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('cli args parsing', () => {
|
||||
slim: false,
|
||||
viaCli: false,
|
||||
devtoolsComments: false,
|
||||
watchConfig: false,
|
||||
};
|
||||
|
||||
it('parses with default args', async () => {
|
||||
|
||||
@@ -350,4 +350,62 @@ describe('ServerConfig', () => {
|
||||
assert.strictEqual(readonlyView.resolved.headless, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRestartRequiredChanges', () => {
|
||||
it('ignores reloadable option changes', () => {
|
||||
const previous = parseConfig();
|
||||
const next = parseConfig([
|
||||
'--memoryDebugging',
|
||||
'--no-category-network',
|
||||
'--slim',
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), []);
|
||||
});
|
||||
|
||||
it('returns restart-required options that changed', () => {
|
||||
const previous = parseConfig();
|
||||
const next = parseConfig([
|
||||
'--headless',
|
||||
'--categoryExtensions',
|
||||
'--memoryDebugging',
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), [
|
||||
'categoryExtensions',
|
||||
'headless',
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares restart-required array and object options by value', () => {
|
||||
const previous = parseConfig([
|
||||
'--blockedUrlPattern=https://example.com/*',
|
||||
]);
|
||||
const same = parseConfig(['--blockedUrlPattern=https://example.com/*']);
|
||||
const changed = parseConfig(['--blockedUrlPattern=https://other.com/*']);
|
||||
|
||||
assert.deepStrictEqual(previous.getRestartRequiredChanges(same), []);
|
||||
assert.deepStrictEqual(previous.getRestartRequiredChanges(changed), [
|
||||
'blockedUrlPattern',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores restart-required options whose defaults are derived from other options', () => {
|
||||
const previous = parseConfig(['--viaCli']);
|
||||
const next = parseConfig([
|
||||
'--viaCli',
|
||||
'--browserUrl=http://127.0.0.1:9222',
|
||||
]);
|
||||
|
||||
assert.strictEqual(previous.resolved.categoryExtensions, true);
|
||||
assert.strictEqual(next.resolved.categoryExtensions, undefined);
|
||||
assert.strictEqual(previous.resolved.isolated, true);
|
||||
assert.strictEqual(next.resolved.isolated, false);
|
||||
assert.strictEqual(previous.resolved.channel, 'stable');
|
||||
assert.strictEqual(next.resolved.channel, undefined);
|
||||
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), [
|
||||
'browserUrl',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user