mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
feat(config): introduce layered ServerConfig for live reload (#3203)
Introduces a layered `ServerConfig` class to hold explicit CLI arguments, config file arguments, and resolved options separately during live config reloads. Most of the validation and default-resolution logic moved directly out of `ConfigParser.ts` into `ServerConfig.ts`. ### Moved from `ConfigParser.ts` to `ServerConfig.ts` - `ParsedArguments` type (re-exported from `ConfigParser.ts`; `channel` and `filesystemRoot` are now typed as optional to match runtime behavior without `as` casts). - `isMcpOptionKey` and `copyOption` helpers (moved unchanged). - `validateConflicts` and `validateImplications` (moved unchanged from `ConfigParser` static methods to `ServerConfig` static methods). - `applyDefaults` (`viaCli`, `channel`, `filesystemRoot`, and `CI` / usage-statistics rules are unchanged; defaults are now parsed via `yargs([]).options(baseOptions).parseSync([])` instead of an untyped loop with `as unknown as ParsedArguments`). - Reloadable option copy loop from `ConfigParser.mergeReloadableOptions` (moved into `mergeLayer` in `ServerConfig.ts`). ### New / changed behavior - **`applyDefaults()` drops unknown keys (`filterExplicitArgs`)**: Keys that are not in `mcpOptions` are now dropped when resolving arguments. Before this change, unknown CLI arguments stayed on the resolved `ParsedArguments` object returned by `ConfigParser.parse()` / `ConfigParser.reload()` after `ConfigParser.warnUnknownArgs()` logged the warning. Consumers that iterate over `mcpOptions` keys (such as telemetry `computeFlagUsage()`) do not change output, while callers inspecting `Object.keys(resolved)` no longer observe unknown CLI keys. - **Layered `ServerConfig`**: Stores `cliArgs`, `fileArgs`, `configPath`, `env`, and `resolved` separately (replacing the module-level `explicitArgsByParsed` `WeakMap`), so reloading a config file updates reloadable file options while preserving startup CLI flags and non-reloadable startup file options. - **`ReadonlyServerConfig`, `mergeReloadable(next, options)`, and `commit(next)`**: Components read `.resolved` through `ReadonlyServerConfig`, while the server computes a candidate reload via `mergeReloadable(next, options)` and commits it in place via `commit(next)`. `ServerConfig.prototype.mergeReloadable()` and `ServerConfig.prototype.commit()` have no production caller in this PR (wired up in subsequent PRs in this stack). - **`ServerConfig.create`**: Factory method to construct `ServerConfig` from raw CLI and config-file layers. - **`ConfigParser.parseConfig()` and `reloadConfig()`**: Return `ServerConfig` while keeping `parse()` and `reload()` returning `ParsedArguments` for compatibility (`ConfigParser.prototype.reloadConfig()` is wired up in subsequent PRs in this stack). - **`mcpOptionsForViaCli` and `stripYargsPositionalArgs` (`src/config/mcp-options.ts`)**: `mcpOptionsForViaCli` is exported directly as a `satisfies` constant (replacing `getMcpOptionsForViaCli()`), and `stripYargsPositionalArgs` is shared between `ConfigParser.ts` and `ServerConfig.ts`. <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
1 parent
0f95c21b02
commit
37f1fda81b
6 files changed
+760
-463
No files matched your search
+42
-202
@@ -4,62 +4,30 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {Argv} from 'yargs';
|
||||
import type {YargsOptions, InferredOptionTypes} from '../third_party/cli.js';
|
||||
|
||||
import {yargs, hideBin} from '../third_party/cli.js';
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
import type {Argv} from 'yargs';
|
||||
|
||||
import type {YargsOptions, InferredOptionTypes} from '../third_party/cli.js';
|
||||
import {yargs, hideBin} from '../third_party/cli.js';
|
||||
import {getErrorMessage} from '../utils/errorHandling.js';
|
||||
|
||||
import {ConfigLocator} from './ConfigLocator.js';
|
||||
import {
|
||||
mcpOptions,
|
||||
getMcpOptionsForViaCli,
|
||||
CLI_EXAMPLES,
|
||||
CONFLICTING_ARGS,
|
||||
IMPLICATIONS,
|
||||
DEFAULT_FILESYSTEM_ROOT,
|
||||
isReloadableOption,
|
||||
stripYargsPositionalArgs,
|
||||
withoutDefaults,
|
||||
} from './mcp-options.js';
|
||||
import {getErrorMessage} from '../utils/errorHandling.js';
|
||||
import {ConfigLocator} from './ConfigLocator.js';
|
||||
import type {ParsedArguments} from './ServerConfig.js';
|
||||
import {ServerConfig} from './ServerConfig.js';
|
||||
|
||||
export type {ParsedArguments} from './ServerConfig.js';
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripYargsPositionalArgs<T extends {_?: unknown; $0?: unknown}>(
|
||||
parsed: T,
|
||||
): Omit<T, '_' | '$0'> {
|
||||
const copy = {...parsed};
|
||||
delete copy._;
|
||||
delete copy.$0;
|
||||
return copy;
|
||||
}
|
||||
|
||||
export type ParsedArguments = InferredOptionTypes<typeof mcpOptions>;
|
||||
|
||||
function isMcpOptionKey(key: string): key is keyof ParsedArguments {
|
||||
return key in mcpOptions;
|
||||
}
|
||||
|
||||
function copyOption<K extends keyof ParsedArguments>(
|
||||
target: Partial<ParsedArguments>,
|
||||
source: Partial<ParsedArguments>,
|
||||
key: K,
|
||||
): void {
|
||||
if (key in source) {
|
||||
target[key] = source[key];
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
|
||||
const explicitArgsByParsed = new WeakMap<
|
||||
ParsedArguments,
|
||||
{parser: ConfigParser; explicitArgs: Partial<ParsedArguments>}
|
||||
>();
|
||||
|
||||
export class ConfigParser {
|
||||
#configPath?: string;
|
||||
#usageStatisticsNoticeShown = false;
|
||||
@@ -152,130 +120,15 @@ export class ConfigParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: rejects mutually exclusive inputs. Only explicit inputs are checked,
|
||||
* so defaults never conflict.
|
||||
* Parses CLI arguments and the config file into a layered
|
||||
* {@link ServerConfig}.
|
||||
*/
|
||||
static validateConflicts(explicitArgs: Partial<ParsedArguments>): void {
|
||||
const activeArgs = new Set<string>();
|
||||
for (const [key, val] of Object.entries(explicitArgs)) {
|
||||
if (val !== undefined && val !== false) {
|
||||
activeArgs.add(key);
|
||||
}
|
||||
}
|
||||
for (const group of CONFLICTING_ARGS) {
|
||||
const activeInGroup = group.filter(arg => activeArgs.has(arg));
|
||||
if (activeInGroup.length > 1) {
|
||||
const [arg1, arg2] = activeInGroup;
|
||||
throw new Error(
|
||||
`Arguments ${String(arg1)} and ${String(arg2)} are mutually exclusive`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateImplications(explicitArgs: Partial<ParsedArguments>): void {
|
||||
for (const [key, implied] of IMPLICATIONS) {
|
||||
const isKeySet =
|
||||
explicitArgs[key] !== undefined && explicitArgs[key] !== false;
|
||||
const isImpliedSet =
|
||||
explicitArgs[implied] !== undefined && explicitArgs[implied] !== false;
|
||||
if (isKeySet && !isImpliedSet) {
|
||||
throw new Error(
|
||||
`Implications failed:\n ${String(key)} -> ${String(implied)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 5: fills in defaults for everything that was not set explicitly.
|
||||
* `viaCli` is an explicit input like any other; it selects which defaults
|
||||
* apply.
|
||||
*/
|
||||
applyDefaults(explicitArgs: Partial<ParsedArguments>): ParsedArguments {
|
||||
const isViaCli = explicitArgs.viaCli === true;
|
||||
const baseOptions = isViaCli ? getMcpOptionsForViaCli() : mcpOptions;
|
||||
const resolvedArgs = {...explicitArgs};
|
||||
// `channel` only applies when Chrome is launched by channel. Leaving it
|
||||
// unset otherwise keeps it out of telemetry (computeFlagUsage).
|
||||
const launchesByChannel =
|
||||
!resolvedArgs.browserUrl &&
|
||||
!resolvedArgs.wsEndpoint &&
|
||||
!resolvedArgs.executablePath;
|
||||
|
||||
for (const [key, option] of Object.entries(baseOptions)) {
|
||||
if (key === 'channel' && !launchesByChannel) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
resolvedArgs[key as keyof ParsedArguments] === undefined &&
|
||||
'default' in option
|
||||
) {
|
||||
resolvedArgs[key as keyof ParsedArguments] = option.default;
|
||||
}
|
||||
}
|
||||
|
||||
if (isViaCli) {
|
||||
if (
|
||||
resolvedArgs.filesystemRoot === DEFAULT_FILESYSTEM_ROOT &&
|
||||
explicitArgs.allowUnrestrictedPaths !== false
|
||||
) {
|
||||
resolvedArgs.allowUnrestrictedPaths = true;
|
||||
resolvedArgs.filesystemRoot = undefined;
|
||||
}
|
||||
const connectsToExistingBrowser =
|
||||
resolvedArgs.autoConnect ||
|
||||
resolvedArgs.browserUrl ||
|
||||
resolvedArgs.wsEndpoint;
|
||||
if (
|
||||
explicitArgs.isolated === undefined &&
|
||||
resolvedArgs.userDataDir === undefined &&
|
||||
!connectsToExistingBrowser
|
||||
) {
|
||||
resolvedArgs.isolated = true;
|
||||
}
|
||||
if (
|
||||
resolvedArgs.categoryExtensions === undefined &&
|
||||
!connectsToExistingBrowser
|
||||
) {
|
||||
resolvedArgs.categoryExtensions = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.env['CI'] || this.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
|
||||
if (!this.#usageStatisticsNoticeShown) {
|
||||
this.#usageStatisticsNoticeShown = true;
|
||||
console.error(
|
||||
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
|
||||
);
|
||||
}
|
||||
resolvedArgs.usageStatistics = false;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedArgs.categoryExperimentalThirdParty === undefined &&
|
||||
this.env['CHROME_DEVTOOLS_MCP_CATEGORY_EXPERIMENTAL_THIRD_PARTY']
|
||||
) {
|
||||
resolvedArgs.categoryExperimentalThirdParty = true;
|
||||
}
|
||||
|
||||
// The merge and the default loop lose the static type that yargs infers from
|
||||
// `mcpOptions`. Every value was produced by the same option definitions (CLI
|
||||
// parser, strict config-file parser, option defaults), so the shape matches.
|
||||
const parsed: ParsedArguments = resolvedArgs as unknown as ParsedArguments;
|
||||
explicitArgsByParsed.set(parsed, {
|
||||
parser: this,
|
||||
explicitArgs: {...explicitArgs},
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
parse(): ParsedArguments {
|
||||
parseConfig(): ServerConfig {
|
||||
try {
|
||||
const cliArgs = this.parseCliArgs();
|
||||
this.#configPath = cliArgs.config ?? this.configLocator.locate(this.env);
|
||||
this.warnUnknownArgs(cliArgs);
|
||||
return this.#resolve(cliArgs);
|
||||
return this.#resolveConfig(cliArgs);
|
||||
} catch (error) {
|
||||
if (this.exitProcess) {
|
||||
console.error(getErrorMessage(error));
|
||||
@@ -285,53 +138,40 @@ export class ConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
parse(): ParsedArguments {
|
||||
return this.parseConfig().resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the config file resolved by `parse()` and merges it with the CLI
|
||||
* arguments again into a new {@link ServerConfig}. Unlike `parseConfig()`, it
|
||||
* does not discover a new config file and throws on invalid configuration
|
||||
* instead of exiting the process.
|
||||
*/
|
||||
reloadConfig(): ServerConfig {
|
||||
return this.#resolveConfig(this.parseCliArgs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the config file resolved by `parse()` and merges it with the CLI
|
||||
* arguments again. Unlike `parse()`, it does not discover a new config file
|
||||
* and throws on invalid configuration instead of exiting the process.
|
||||
*/
|
||||
reload(): ParsedArguments {
|
||||
return this.#resolve(this.parseCliArgs());
|
||||
return this.reloadConfig().resolved;
|
||||
}
|
||||
|
||||
#resolve(cliArgs: Partial<ParsedArguments>): ParsedArguments {
|
||||
#resolveConfig(cliArgs: Partial<ParsedArguments>): ServerConfig {
|
||||
const configPath = this.#configPath;
|
||||
const configFileArgs = configPath ? this.parseConfigFile(configPath) : {};
|
||||
// Step 3: merges the explicit inputs. The CLI wins over the config file.
|
||||
const explicitArgs: Partial<ParsedArguments> = {
|
||||
...configFileArgs,
|
||||
...cliArgs,
|
||||
...(configPath ? {config: configPath} : {}),
|
||||
};
|
||||
ConfigParser.validateConflicts(explicitArgs);
|
||||
ConfigParser.validateImplications(explicitArgs);
|
||||
return this.applyDefaults(explicitArgs);
|
||||
}
|
||||
/**
|
||||
* Merges explicit inputs so that a running server continues with `next` for
|
||||
* reloadable options and `previous` for everything else, then validates and
|
||||
* applies defaults.
|
||||
*/
|
||||
static mergeReloadableOptions(
|
||||
previous: ParsedArguments,
|
||||
next: ParsedArguments,
|
||||
): ParsedArguments {
|
||||
const previousEntry = explicitArgsByParsed.get(previous);
|
||||
const nextEntry = explicitArgsByParsed.get(next);
|
||||
const previousExplicit: Partial<ParsedArguments> =
|
||||
previousEntry?.explicitArgs ?? previous;
|
||||
const nextExplicit: Partial<ParsedArguments> =
|
||||
nextEntry?.explicitArgs ?? next;
|
||||
const merged: Partial<ParsedArguments> = {...previousExplicit};
|
||||
for (const name of Object.keys(mcpOptions)) {
|
||||
if (isMcpOptionKey(name) && isReloadableOption(name)) {
|
||||
copyOption(merged, nextExplicit, name);
|
||||
}
|
||||
}
|
||||
ConfigParser.validateConflicts(merged);
|
||||
ConfigParser.validateImplications(merged);
|
||||
const parser =
|
||||
nextEntry?.parser ?? previousEntry?.parser ?? new ConfigParser('');
|
||||
return parser.applyDefaults(merged);
|
||||
const fileArgs = configPath ? this.parseConfigFile(configPath) : {};
|
||||
const config = ServerConfig.create({
|
||||
cliArgs,
|
||||
fileArgs,
|
||||
configPath,
|
||||
env: this.env,
|
||||
suppressUsageStatisticsNotice: this.#usageStatisticsNoticeShown,
|
||||
});
|
||||
this.#usageStatisticsNoticeShown = true;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {InferredOptionTypes} from '../third_party/cli.js';
|
||||
import {yargs} from '../third_party/cli.js';
|
||||
|
||||
import {
|
||||
CONFLICTING_ARGS,
|
||||
DEFAULT_FILESYSTEM_ROOT,
|
||||
IMPLICATIONS,
|
||||
isReloadableOption,
|
||||
mcpOptions,
|
||||
mcpOptionsForViaCli,
|
||||
stripYargsPositionalArgs,
|
||||
} from './mcp-options.js';
|
||||
|
||||
type InferredMcpArguments = InferredOptionTypes<typeof mcpOptions>;
|
||||
|
||||
export type ParsedArguments = Omit<
|
||||
InferredMcpArguments,
|
||||
'channel' | 'filesystemRoot'
|
||||
> & {
|
||||
channel?: InferredMcpArguments['channel'];
|
||||
filesystemRoot?: InferredMcpArguments['filesystemRoot'];
|
||||
};
|
||||
|
||||
function isMcpOptionKey(key: string): key is keyof ParsedArguments {
|
||||
return key in mcpOptions;
|
||||
}
|
||||
|
||||
function copyOption<K extends keyof ParsedArguments>(
|
||||
target: Partial<ParsedArguments>,
|
||||
source: Partial<ParsedArguments>,
|
||||
key: K,
|
||||
): void {
|
||||
if (key in source) {
|
||||
target[key] = source[key];
|
||||
} else {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
|
||||
function filterExplicitArgs(
|
||||
args: Partial<ParsedArguments>,
|
||||
baseOptions: typeof mcpOptions | typeof mcpOptionsForViaCli,
|
||||
): Partial<ParsedArguments> {
|
||||
const filtered: Partial<ParsedArguments> = {};
|
||||
for (const key of Object.keys(args)) {
|
||||
if (!isMcpOptionKey(key)) {
|
||||
continue;
|
||||
}
|
||||
if (args[key] === undefined && 'default' in baseOptions[key]) {
|
||||
continue;
|
||||
}
|
||||
copyOption(filtered, args, key);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mergeLayer(
|
||||
previous: Readonly<Partial<ParsedArguments>>,
|
||||
next: Readonly<Partial<ParsedArguments>>,
|
||||
): Partial<ParsedArguments> {
|
||||
const merged: Partial<ParsedArguments> = {...previous};
|
||||
for (const name of Object.keys(mcpOptions)) {
|
||||
if (isMcpOptionKey(name) && isReloadableOption(name)) {
|
||||
copyOption(merged, next, name);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function parseDefaultArgs(
|
||||
options: typeof mcpOptions | typeof mcpOptionsForViaCli,
|
||||
): ParsedArguments {
|
||||
const parsed = yargs([])
|
||||
.parserConfiguration({
|
||||
'strip-aliased': true,
|
||||
'camel-case-expansion': false,
|
||||
})
|
||||
.options(options)
|
||||
.parseSync([]);
|
||||
return {
|
||||
...stripYargsPositionalArgs(parsed),
|
||||
// yargs copies array defaults, so restore the shared reference that the
|
||||
// `viaCli` check in `applyDefaults()` compares with `===`.
|
||||
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReadonlyServerConfig {
|
||||
readonly resolved: Readonly<ParsedArguments>;
|
||||
}
|
||||
|
||||
export interface ServerConfigInit {
|
||||
cliArgs: Partial<ParsedArguments>;
|
||||
fileArgs: Partial<ParsedArguments>;
|
||||
configPath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
resolved: ParsedArguments;
|
||||
}
|
||||
|
||||
export interface ServerConfigCreateOptions {
|
||||
cliArgs: Partial<ParsedArguments>;
|
||||
fileArgs: Partial<ParsedArguments>;
|
||||
configPath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
suppressUsageStatisticsNotice?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds explicit CLI and config-file layers alongside the resolved arguments.
|
||||
*
|
||||
* Components read live configuration through {@link ReadonlyServerConfig}.
|
||||
* Only the owning server updates the config in place via {@link commit} after
|
||||
* computing and validating a candidate reload via {@link mergeReloadable}.
|
||||
*/
|
||||
export class ServerConfig implements ReadonlyServerConfig {
|
||||
#cliArgs: Readonly<Partial<ParsedArguments>>;
|
||||
#fileArgs: Readonly<Partial<ParsedArguments>>;
|
||||
readonly #configPath?: string;
|
||||
readonly #env: NodeJS.ProcessEnv;
|
||||
#resolved: Readonly<ParsedArguments>;
|
||||
|
||||
constructor(init: ServerConfigInit) {
|
||||
this.#cliArgs = {...init.cliArgs};
|
||||
this.#fileArgs = {...init.fileArgs};
|
||||
this.#configPath = init.configPath;
|
||||
this.#env = init.env ?? process.env;
|
||||
this.#resolved = {...init.resolved};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: rejects mutually exclusive inputs. Only explicit inputs are
|
||||
* checked, so defaults never conflict.
|
||||
*/
|
||||
static validateConflicts(explicitArgs: Partial<ParsedArguments>): void {
|
||||
const activeArgs = new Set<string>();
|
||||
for (const [key, val] of Object.entries(explicitArgs)) {
|
||||
if (val !== undefined && val !== false) {
|
||||
activeArgs.add(key);
|
||||
}
|
||||
}
|
||||
for (const group of CONFLICTING_ARGS) {
|
||||
const activeInGroup = group.filter(arg => activeArgs.has(arg));
|
||||
if (activeInGroup.length > 1) {
|
||||
const [arg1, arg2] = activeInGroup;
|
||||
throw new Error(
|
||||
`Arguments ${String(arg1)} and ${String(arg2)} are mutually exclusive`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateImplications(explicitArgs: Partial<ParsedArguments>): void {
|
||||
for (const [key, implied] of IMPLICATIONS) {
|
||||
const isKeySet =
|
||||
explicitArgs[key] !== undefined && explicitArgs[key] !== false;
|
||||
const isImpliedSet =
|
||||
explicitArgs[implied] !== undefined && explicitArgs[implied] !== false;
|
||||
if (isKeySet && !isImpliedSet) {
|
||||
throw new Error(
|
||||
`Implications failed:\n ${String(key)} -> ${String(implied)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 5: fills in defaults for everything that was not set explicitly.
|
||||
* `viaCli` is an explicit input like any other; it selects which defaults
|
||||
* apply.
|
||||
*/
|
||||
static applyDefaults(
|
||||
explicitArgs: Partial<ParsedArguments>,
|
||||
options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
suppressUsageStatisticsNotice?: boolean;
|
||||
},
|
||||
): ParsedArguments {
|
||||
const env = options?.env ?? process.env;
|
||||
const isViaCli = explicitArgs.viaCli === true;
|
||||
const baseOptions = isViaCli ? mcpOptionsForViaCli : mcpOptions;
|
||||
const resolvedArgs: ParsedArguments = {
|
||||
...parseDefaultArgs(baseOptions),
|
||||
...filterExplicitArgs(explicitArgs, baseOptions),
|
||||
};
|
||||
|
||||
// `channel` only applies when Chrome is launched by channel. Leaving it
|
||||
// unset otherwise keeps it out of telemetry (computeFlagUsage).
|
||||
const launchesByChannel =
|
||||
!resolvedArgs.browserUrl &&
|
||||
!resolvedArgs.wsEndpoint &&
|
||||
!resolvedArgs.executablePath;
|
||||
if (!launchesByChannel && explicitArgs.channel === undefined) {
|
||||
delete resolvedArgs.channel;
|
||||
}
|
||||
|
||||
if (isViaCli) {
|
||||
if (
|
||||
resolvedArgs.filesystemRoot === DEFAULT_FILESYSTEM_ROOT &&
|
||||
explicitArgs.allowUnrestrictedPaths !== false
|
||||
) {
|
||||
resolvedArgs.allowUnrestrictedPaths = true;
|
||||
resolvedArgs.filesystemRoot = undefined;
|
||||
}
|
||||
const connectsToExistingBrowser =
|
||||
resolvedArgs.autoConnect ||
|
||||
resolvedArgs.browserUrl ||
|
||||
resolvedArgs.wsEndpoint;
|
||||
if (
|
||||
explicitArgs.isolated === undefined &&
|
||||
resolvedArgs.userDataDir === undefined &&
|
||||
!connectsToExistingBrowser
|
||||
) {
|
||||
resolvedArgs.isolated = true;
|
||||
}
|
||||
if (
|
||||
resolvedArgs.categoryExtensions === undefined &&
|
||||
!connectsToExistingBrowser
|
||||
) {
|
||||
resolvedArgs.categoryExtensions = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (env['CI'] || env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
|
||||
if (!options?.suppressUsageStatisticsNotice) {
|
||||
console.error(
|
||||
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
|
||||
);
|
||||
}
|
||||
resolvedArgs.usageStatistics = false;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedArgs.categoryExperimentalThirdParty === undefined &&
|
||||
env['CHROME_DEVTOOLS_MCP_CATEGORY_EXPERIMENTAL_THIRD_PARTY']
|
||||
) {
|
||||
resolvedArgs.categoryExperimentalThirdParty = true;
|
||||
}
|
||||
|
||||
return resolvedArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3–5: merges CLI and config-file layers (`CLI > config file`),
|
||||
* validates conflicts and implications, and applies defaults.
|
||||
*/
|
||||
static create(options: ServerConfigCreateOptions): ServerConfig {
|
||||
const explicitArgs: Partial<ParsedArguments> = {
|
||||
...options.fileArgs,
|
||||
...options.cliArgs,
|
||||
...(options.configPath ? {config: options.configPath} : {}),
|
||||
};
|
||||
ServerConfig.validateConflicts(explicitArgs);
|
||||
ServerConfig.validateImplications(explicitArgs);
|
||||
const resolved = ServerConfig.applyDefaults(explicitArgs, {
|
||||
env: options.env,
|
||||
suppressUsageStatisticsNotice: options.suppressUsageStatisticsNotice,
|
||||
});
|
||||
return new ServerConfig({
|
||||
cliArgs: options.cliArgs,
|
||||
fileArgs: options.fileArgs,
|
||||
configPath: options.configPath,
|
||||
env: options.env,
|
||||
resolved,
|
||||
});
|
||||
}
|
||||
|
||||
get cliArgs(): Readonly<Partial<ParsedArguments>> {
|
||||
return this.#cliArgs;
|
||||
}
|
||||
|
||||
get fileArgs(): Readonly<Partial<ParsedArguments>> {
|
||||
return this.#fileArgs;
|
||||
}
|
||||
|
||||
get configPath(): string | undefined {
|
||||
return this.#configPath;
|
||||
}
|
||||
|
||||
get resolved(): Readonly<ParsedArguments> {
|
||||
return this.#resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a new {@link ServerConfig} that takes reloadable options from
|
||||
* `next` and keeps restart-required options from this config, preserving
|
||||
* `CLI > config file > defaults` precedence.
|
||||
*/
|
||||
mergeReloadable(
|
||||
next: ServerConfig,
|
||||
options?: {env?: NodeJS.ProcessEnv},
|
||||
): ServerConfig {
|
||||
const mergedFileArgs = mergeLayer(this.#fileArgs, next.#fileArgs);
|
||||
const mergedCliArgs = mergeLayer(this.#cliArgs, next.#cliArgs);
|
||||
return ServerConfig.create({
|
||||
cliArgs: mergedCliArgs,
|
||||
fileArgs: mergedFileArgs,
|
||||
configPath: this.#configPath,
|
||||
env: options?.env ?? this.#env,
|
||||
suppressUsageStatisticsNotice: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a previously validated {@link ServerConfig} snapshot in place so
|
||||
* all shared {@link ReadonlyServerConfig} readers observe the new values.
|
||||
*/
|
||||
commit(next: ServerConfig): void {
|
||||
this.#cliArgs = next.#cliArgs;
|
||||
this.#fileArgs = next.#fileArgs;
|
||||
this.#resolved = next.#resolved;
|
||||
}
|
||||
}
|
||||
+41
-42
@@ -240,53 +240,39 @@ export const mcpOptions = {
|
||||
},
|
||||
} satisfies Record<string, McpOption>;
|
||||
|
||||
export function getMcpOptionsForViaCli(): Record<
|
||||
keyof typeof mcpOptions,
|
||||
YargsOptions
|
||||
> {
|
||||
if (!('default' in mcpOptions.headless)) {
|
||||
throw new Error('headless cli option unexpectedly does not have a default');
|
||||
}
|
||||
if (!('default' in mcpOptions.experimentalStructuredContent)) {
|
||||
throw new Error(
|
||||
'experimentalStructuredContent cli option unexpectedly does not have a default',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...mcpOptions,
|
||||
headless: {
|
||||
...mcpOptions.headless,
|
||||
default: true,
|
||||
},
|
||||
memoryDebugging: {
|
||||
...mcpOptions.memoryDebugging,
|
||||
default: true,
|
||||
},
|
||||
categoryExtensions: {
|
||||
...mcpOptions.categoryExtensions,
|
||||
defaultDescription:
|
||||
'true unless autoConnect, browserUrl or wsEndpoint is set',
|
||||
},
|
||||
experimentalStructuredContent: {
|
||||
...mcpOptions.experimentalStructuredContent,
|
||||
default: true,
|
||||
},
|
||||
isolated: {
|
||||
...mcpOptions.isolated,
|
||||
description:
|
||||
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.',
|
||||
defaultDescription:
|
||||
'true unless userDataDir, autoConnect, browserUrl or wsEndpoint is set',
|
||||
},
|
||||
};
|
||||
}
|
||||
export const mcpOptionsForViaCli = {
|
||||
...mcpOptions,
|
||||
headless: {
|
||||
...mcpOptions.headless,
|
||||
default: true,
|
||||
},
|
||||
memoryDebugging: {
|
||||
...mcpOptions.memoryDebugging,
|
||||
default: true,
|
||||
},
|
||||
categoryExtensions: {
|
||||
...mcpOptions.categoryExtensions,
|
||||
defaultDescription:
|
||||
'true unless autoConnect, browserUrl or wsEndpoint is set',
|
||||
},
|
||||
experimentalStructuredContent: {
|
||||
...mcpOptions.experimentalStructuredContent,
|
||||
default: true,
|
||||
},
|
||||
isolated: {
|
||||
...mcpOptions.isolated,
|
||||
description:
|
||||
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.',
|
||||
defaultDescription:
|
||||
'true unless userDataDir, autoConnect, browserUrl or wsEndpoint is set',
|
||||
},
|
||||
} satisfies Record<keyof typeof mcpOptions, YargsOptions>;
|
||||
|
||||
export function getCliOptions(): Partial<
|
||||
Record<keyof typeof mcpOptions, YargsOptions>
|
||||
> {
|
||||
const options: Partial<Record<keyof typeof mcpOptions, YargsOptions>> =
|
||||
withoutDefaults(getMcpOptionsForViaCli());
|
||||
withoutDefaults(mcpOptionsForViaCli);
|
||||
|
||||
// Missing CLI serialization.
|
||||
delete options.viewport;
|
||||
@@ -397,6 +383,19 @@ export function withoutDefaults(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripYargsPositionalArgs<T extends {_?: unknown; $0?: unknown}>(
|
||||
parsed: T,
|
||||
): {
|
||||
[
|
||||
K in keyof T as string extends K ? never : K extends '_' | '$0' ? never : K
|
||||
]: T[K];
|
||||
} {
|
||||
const copy = {...parsed};
|
||||
delete copy._;
|
||||
delete copy.$0;
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function isReloadableOption(name: keyof typeof mcpOptions): boolean {
|
||||
const option: McpOption = mcpOptions[name];
|
||||
return option.reloadable === true;
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {ConfigLocator} from '../../src/config/ConfigLocator.js';
|
||||
import {ConfigParser} from '../../src/config/ConfigParser.js';
|
||||
import {DEFAULT_FILESYSTEM_ROOT} from '../../src/config/mcp-options.js';
|
||||
import {
|
||||
type ReadonlyServerConfig,
|
||||
ServerConfig,
|
||||
} from '../../src/config/ServerConfig.js';
|
||||
import {createTempFile} from '../utils.js';
|
||||
|
||||
describe('ServerConfig', () => {
|
||||
function parseConfig(argv: string[] = [], env: NodeJS.ProcessEnv = {}) {
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(undefined);
|
||||
return new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js', ...argv],
|
||||
env,
|
||||
false,
|
||||
locator,
|
||||
).parseConfig();
|
||||
}
|
||||
|
||||
describe('validateConflicts', () => {
|
||||
it('accepts a single argument from a conflict group', () => {
|
||||
ServerConfig.validateConflicts({browserUrl: 'http://localhost:9222'});
|
||||
});
|
||||
|
||||
it('ignores false and undefined values', () => {
|
||||
ServerConfig.validateConflicts({
|
||||
browserUrl: 'http://localhost:9222',
|
||||
wsEndpoint: undefined,
|
||||
categoryExtensions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects two arguments from the same group', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ServerConfig.validateConflicts({
|
||||
browserUrl: 'http://localhost:9222',
|
||||
channel: 'canary',
|
||||
}),
|
||||
/Arguments channel and browserUrl are mutually exclusive/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateImplications', () => {
|
||||
it('accepts when implying key is not set', () => {
|
||||
ServerConfig.validateImplications({wsEndpoint: 'ws://localhost:9222'});
|
||||
});
|
||||
|
||||
it('accepts when both implying and implied keys are set', () => {
|
||||
ServerConfig.validateImplications({
|
||||
wsHeaders: {Auth: 'token'},
|
||||
wsEndpoint: 'ws://localhost:9222',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when implying key is set but implied key is missing', () => {
|
||||
assert.throws(
|
||||
() => ServerConfig.validateImplications({wsHeaders: {Auth: 'token'}}),
|
||||
/Implications failed:\n {2}wsHeaders -> wsEndpoint/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when implying key is set but implied key is negated', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ServerConfig.validateImplications({
|
||||
experimentalFfmpegPath: '/bin/ffmpeg',
|
||||
experimentalScreencast: false,
|
||||
}),
|
||||
/Implications failed:\n {2}experimentalFfmpegPath -> experimentalScreencast/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefaults', () => {
|
||||
it('keeps explicit values and fills in defaults', () => {
|
||||
const args = ServerConfig.applyDefaults({headless: true}, {env: {}});
|
||||
assert.strictEqual(args.headless, true);
|
||||
assert.strictEqual(args.isolated, false);
|
||||
assert.strictEqual(args.channel, 'stable');
|
||||
assert.strictEqual(args.categoryExtensions, undefined);
|
||||
});
|
||||
|
||||
it('drops unknown CLI keys that are not in mcpOptions', () => {
|
||||
sinon.stub(console, 'error');
|
||||
const config = parseConfig(['--unknownFlag=value']);
|
||||
assert.strictEqual('unknownFlag' in config.resolved, false);
|
||||
});
|
||||
|
||||
for (const explicitArgs of [
|
||||
{browserUrl: 'http://localhost:9222'},
|
||||
{wsEndpoint: 'ws://localhost:9222'},
|
||||
{executablePath: '/tmp/chrome'},
|
||||
]) {
|
||||
it(`does not default channel with ${Object.keys(explicitArgs)[0]}`, () => {
|
||||
assert.strictEqual(
|
||||
ServerConfig.applyDefaults(explicitArgs, {env: {}}).channel,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('applies viaCli defaults when launching a browser', () => {
|
||||
const args = ServerConfig.applyDefaults(
|
||||
{
|
||||
viaCli: true,
|
||||
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
|
||||
},
|
||||
{env: {}},
|
||||
);
|
||||
assert.strictEqual(args.headless, true);
|
||||
assert.strictEqual(args.isolated, true);
|
||||
assert.strictEqual(args.categoryExtensions, true);
|
||||
assert.strictEqual(args.allowUnrestrictedPaths, true);
|
||||
assert.strictEqual(args.filesystemRoot, undefined);
|
||||
});
|
||||
|
||||
it('keeps the default filesystem root for viaCli with explicit allowUnrestrictedPaths=false', () => {
|
||||
const args = ServerConfig.applyDefaults(
|
||||
{
|
||||
viaCli: true,
|
||||
allowUnrestrictedPaths: false,
|
||||
},
|
||||
{env: {}},
|
||||
);
|
||||
assert.strictEqual(args.allowUnrestrictedPaths, false);
|
||||
assert.deepStrictEqual(args.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
|
||||
});
|
||||
|
||||
it('does not enable isolated or extensions for viaCli with browserUrl', () => {
|
||||
const args = ServerConfig.applyDefaults(
|
||||
{
|
||||
viaCli: true,
|
||||
browserUrl: 'http://localhost:9222',
|
||||
},
|
||||
{env: {}},
|
||||
);
|
||||
assert.strictEqual(args.isolated, false);
|
||||
assert.strictEqual(args.categoryExtensions, undefined);
|
||||
});
|
||||
|
||||
it('turns off usage statistics in CI', () => {
|
||||
sinon.stub(console, 'error');
|
||||
const args = ServerConfig.applyDefaults(
|
||||
{usageStatistics: true},
|
||||
{env: {CI: 'true'}},
|
||||
);
|
||||
assert.strictEqual(args.usageStatistics, false);
|
||||
});
|
||||
|
||||
it('prints the usage statistics notice once across reloads', () => {
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(undefined);
|
||||
const ciParser = new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js'],
|
||||
{CI: 'true'},
|
||||
false,
|
||||
locator,
|
||||
);
|
||||
|
||||
ciParser.parseConfig();
|
||||
const reloaded = ciParser.reloadConfig();
|
||||
|
||||
assert.strictEqual(reloaded.resolved.usageStatistics, false);
|
||||
sinon.assert.calledOnce(consoleError);
|
||||
});
|
||||
|
||||
it('prints the usage statistics notice after a failed first parse', () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({
|
||||
browserUrl: 'http://localhost:9222',
|
||||
channel: 'canary',
|
||||
}),
|
||||
'cd4a.conflict.config.json',
|
||||
);
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(configFile.path);
|
||||
const ciParser = new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js'],
|
||||
{CI: 'true'},
|
||||
false,
|
||||
locator,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => ciParser.parseConfig(),
|
||||
/Arguments channel and browserUrl are mutually exclusive/,
|
||||
);
|
||||
sinon.assert.notCalled(consoleError);
|
||||
|
||||
fs.writeFileSync(configFile.path, JSON.stringify({headless: true}));
|
||||
const reloaded = ciParser.reloadConfig();
|
||||
|
||||
assert.strictEqual(reloaded.resolved.usageStatistics, false);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
consoleError,
|
||||
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeReloadable', () => {
|
||||
it('applies reloadable options', () => {
|
||||
const merged = parseConfig().mergeReloadable(
|
||||
parseConfig([
|
||||
'--memoryDebugging',
|
||||
'--no-category-network',
|
||||
'--no-file-navigations',
|
||||
'--slim',
|
||||
]),
|
||||
).resolved;
|
||||
|
||||
assert.strictEqual(merged.memoryDebugging, true);
|
||||
assert.strictEqual(merged.categoryNetwork, false);
|
||||
assert.strictEqual(merged.fileNavigations, false);
|
||||
assert.strictEqual(merged.slim, true);
|
||||
});
|
||||
|
||||
it('keeps restart-required options', () => {
|
||||
const merged = parseConfig().mergeReloadable(
|
||||
parseConfig([
|
||||
'--categoryExtensions',
|
||||
'--blockedUrlPattern=https://example.com/*',
|
||||
'--experimentalDevtools',
|
||||
]),
|
||||
).resolved;
|
||||
|
||||
assert.strictEqual(merged.categoryExtensions, undefined);
|
||||
assert.strictEqual(merged.blockedUrlPattern, undefined);
|
||||
assert.strictEqual(merged.experimentalDevtools, false);
|
||||
});
|
||||
|
||||
it('keeps options derived from restart-required options', () => {
|
||||
const previous = parseConfig(['--viaCli']);
|
||||
const next = parseConfig([
|
||||
'--viaCli',
|
||||
'--browserUrl=http://127.0.0.1:9222',
|
||||
]);
|
||||
|
||||
const merged = previous.mergeReloadable(next).resolved;
|
||||
|
||||
assert.strictEqual(merged.categoryExtensions, true);
|
||||
assert.strictEqual(merged.browserUrl, undefined);
|
||||
});
|
||||
|
||||
it('preserves CLI precedence over reloaded config file values', () => {
|
||||
using configFile = createTempFile(
|
||||
JSON.stringify({memoryDebugging: false, performanceCrux: true}),
|
||||
'cd4a.config.json',
|
||||
);
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(configFile.path);
|
||||
const parser = new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js', '--memoryDebugging'],
|
||||
{},
|
||||
false,
|
||||
locator,
|
||||
);
|
||||
const initial = parser.parseConfig();
|
||||
|
||||
fs.writeFileSync(
|
||||
configFile.path,
|
||||
JSON.stringify({memoryDebugging: false, performanceCrux: false}),
|
||||
);
|
||||
const reloaded = parser.reloadConfig();
|
||||
const merged = initial.mergeReloadable(reloaded).resolved;
|
||||
|
||||
assert.strictEqual(merged.memoryDebugging, true);
|
||||
assert.strictEqual(merged.performanceCrux, false);
|
||||
});
|
||||
|
||||
it('rejects reloadable options that conflict with startup restart-required options', () => {
|
||||
const previous = parseConfig(['--browserUrl=http://127.0.0.1:9222']);
|
||||
const next = parseConfig(['--categoryPwa']);
|
||||
|
||||
assert.throws(
|
||||
() => previous.mergeReloadable(next),
|
||||
/Arguments categoryPwa and browserUrl are mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not conflict with default options', () => {
|
||||
const merged = parseConfig().mergeReloadable(
|
||||
parseConfig(['--allow-unrestricted-paths']),
|
||||
).resolved;
|
||||
|
||||
assert.strictEqual(merged.allowUnrestrictedPaths, true);
|
||||
assert.strictEqual(merged.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
|
||||
});
|
||||
|
||||
it('applies env overrides when merging reloadable options', () => {
|
||||
const initial = parseConfig();
|
||||
const next = parseConfig();
|
||||
|
||||
const mergedWithCi = initial.mergeReloadable(next, {
|
||||
env: {CI: 'true'},
|
||||
}).resolved;
|
||||
const mergedWithoutCi = initial.mergeReloadable(next).resolved;
|
||||
|
||||
assert.strictEqual(mergedWithCi.usageStatistics, false);
|
||||
assert.strictEqual(mergedWithoutCi.usageStatistics, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConfig.commit()', () => {
|
||||
it('updates cliArgs, fileArgs, and resolved in place', () => {
|
||||
const initial = ServerConfig.create({
|
||||
cliArgs: {headless: true},
|
||||
fileArgs: {memoryDebugging: false},
|
||||
env: {},
|
||||
suppressUsageStatisticsNotice: true,
|
||||
});
|
||||
const readonlyView: ReadonlyServerConfig = initial;
|
||||
const next = initial.mergeReloadable(
|
||||
ServerConfig.create({
|
||||
cliArgs: {headless: true, slim: true},
|
||||
fileArgs: {memoryDebugging: true},
|
||||
env: {},
|
||||
suppressUsageStatisticsNotice: true,
|
||||
}),
|
||||
);
|
||||
|
||||
initial.commit(next);
|
||||
|
||||
assert.deepStrictEqual(initial.cliArgs, {headless: true, slim: true});
|
||||
assert.deepStrictEqual(initial.fileArgs, {memoryDebugging: true});
|
||||
assert.strictEqual(readonlyView.resolved.slim, true);
|
||||
assert.strictEqual(readonlyView.resolved.memoryDebugging, true);
|
||||
assert.strictEqual(readonlyView.resolved.headless, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import sinon from 'sinon';
|
||||
|
||||
import {ConfigLocator} from '../../src/config/ConfigLocator.js';
|
||||
import {ConfigParser} from '../../src/config/ConfigParser.js';
|
||||
import {DEFAULT_FILESYSTEM_ROOT} from '../../src/config/mcp-options.js';
|
||||
import {createTempFile} from '../utils.js';
|
||||
|
||||
describe('mcp-options steps', () => {
|
||||
@@ -70,137 +69,6 @@ describe('mcp-options steps', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConflicts', () => {
|
||||
it('accepts a single argument from a conflict group', () => {
|
||||
ConfigParser.validateConflicts({browserUrl: 'http://localhost:9222'});
|
||||
});
|
||||
|
||||
it('ignores false and undefined values', () => {
|
||||
ConfigParser.validateConflicts({
|
||||
browserUrl: 'http://localhost:9222',
|
||||
wsEndpoint: undefined,
|
||||
categoryExtensions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects two arguments from the same group', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ConfigParser.validateConflicts({
|
||||
browserUrl: 'http://localhost:9222',
|
||||
channel: 'canary',
|
||||
}),
|
||||
/Arguments channel and browserUrl are mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('validateImplications', () => {
|
||||
it('accepts when implying key is not set', () => {
|
||||
ConfigParser.validateImplications({wsEndpoint: 'ws://localhost:9222'});
|
||||
});
|
||||
|
||||
it('accepts when both implying and implied keys are set', () => {
|
||||
ConfigParser.validateImplications({
|
||||
wsHeaders: {Auth: 'token'},
|
||||
wsEndpoint: 'ws://localhost:9222',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when implying key is set but implied key is missing', () => {
|
||||
assert.throws(
|
||||
() => ConfigParser.validateImplications({wsHeaders: {Auth: 'token'}}),
|
||||
/Implications failed:\n {2}wsHeaders -> wsEndpoint/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when implying key is set but implied key is negated', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
ConfigParser.validateImplications({
|
||||
experimentalFfmpegPath: '/bin/ffmpeg',
|
||||
experimentalScreencast: false,
|
||||
}),
|
||||
/Implications failed:\n {2}experimentalFfmpegPath -> experimentalScreencast/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('applyDefaults', () => {
|
||||
it('keeps explicit values and fills in defaults', () => {
|
||||
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
|
||||
headless: true,
|
||||
});
|
||||
assert.strictEqual(args.headless, true);
|
||||
assert.strictEqual(args.isolated, false);
|
||||
assert.strictEqual(args.channel, 'stable');
|
||||
assert.strictEqual(args.categoryExtensions, undefined);
|
||||
});
|
||||
|
||||
for (const explicitArgs of [
|
||||
{browserUrl: 'http://localhost:9222'},
|
||||
{wsEndpoint: 'ws://localhost:9222'},
|
||||
{executablePath: '/tmp/chrome'},
|
||||
]) {
|
||||
it(`does not default channel with ${Object.keys(explicitArgs)[0]}`, () => {
|
||||
assert.strictEqual(
|
||||
new ConfigParser('0.0.0', [], {}).applyDefaults(explicitArgs).channel,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('applies viaCli defaults when launching a browser', () => {
|
||||
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
|
||||
viaCli: true,
|
||||
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
|
||||
});
|
||||
assert.strictEqual(args.headless, true);
|
||||
assert.strictEqual(args.isolated, true);
|
||||
assert.strictEqual(args.categoryExtensions, true);
|
||||
assert.strictEqual(args.allowUnrestrictedPaths, true);
|
||||
assert.strictEqual(args.filesystemRoot, undefined);
|
||||
});
|
||||
|
||||
it('keeps the default filesystem root for viaCli with explicit allowUnrestrictedPaths=false', () => {
|
||||
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
|
||||
viaCli: true,
|
||||
allowUnrestrictedPaths: false,
|
||||
});
|
||||
assert.strictEqual(args.allowUnrestrictedPaths, false);
|
||||
assert.deepStrictEqual(args.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
|
||||
});
|
||||
|
||||
it('does not enable isolated or extensions for viaCli with browserUrl', () => {
|
||||
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
|
||||
viaCli: true,
|
||||
browserUrl: 'http://localhost:9222',
|
||||
});
|
||||
assert.strictEqual(args.isolated, false);
|
||||
assert.strictEqual(args.categoryExtensions, undefined);
|
||||
});
|
||||
|
||||
it('turns off usage statistics in CI', () => {
|
||||
sinon.stub(console, 'error');
|
||||
const args = new ConfigParser('0.0.0', [], {
|
||||
CI: 'true',
|
||||
}).applyDefaults({
|
||||
usageStatistics: true,
|
||||
});
|
||||
assert.strictEqual(args.usageStatistics, false);
|
||||
});
|
||||
|
||||
it('prints the usage statistics notice once', () => {
|
||||
const consoleError = sinon.stub(console, 'error');
|
||||
const parser = new ConfigParser('0.0.0', [], {CI: 'true'});
|
||||
|
||||
parser.applyDefaults({});
|
||||
const args = parser.applyDefaults({});
|
||||
|
||||
assert.strictEqual(args.usageStatistics, false);
|
||||
sinon.assert.calledOnce(consoleError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config discovery', () => {
|
||||
it('does not discover a config file when locator returns undefined', () => {
|
||||
const locator = new ConfigLocator();
|
||||
@@ -308,83 +176,4 @@ describe('mcp-options steps', () => {
|
||||
assert.throws(() => parser.reload(), /Invalid JSON config file/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeReloadableOptions', () => {
|
||||
function parseArgs(argv: string[] = []) {
|
||||
const locator = new ConfigLocator();
|
||||
sinon.stub(locator, 'locate').returns(undefined);
|
||||
return new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js', ...argv],
|
||||
{},
|
||||
false,
|
||||
locator,
|
||||
).parse();
|
||||
}
|
||||
|
||||
it('applies reloadable options', () => {
|
||||
const merged = ConfigParser.mergeReloadableOptions(
|
||||
parseArgs(),
|
||||
parseArgs([
|
||||
'--memoryDebugging',
|
||||
'--no-category-network',
|
||||
'--no-file-navigations',
|
||||
'--slim',
|
||||
]),
|
||||
);
|
||||
|
||||
assert.strictEqual(merged.memoryDebugging, true);
|
||||
assert.strictEqual(merged.categoryNetwork, false);
|
||||
assert.strictEqual(merged.fileNavigations, false);
|
||||
assert.strictEqual(merged.slim, true);
|
||||
});
|
||||
|
||||
it('keeps restart-required options', () => {
|
||||
const merged = ConfigParser.mergeReloadableOptions(
|
||||
parseArgs(),
|
||||
parseArgs([
|
||||
'--categoryExtensions',
|
||||
'--blockedUrlPattern=https://example.com/*',
|
||||
'--experimentalDevtools',
|
||||
]),
|
||||
);
|
||||
|
||||
assert.strictEqual(merged.categoryExtensions, undefined);
|
||||
assert.strictEqual(merged.blockedUrlPattern, undefined);
|
||||
assert.strictEqual(merged.experimentalDevtools, false);
|
||||
});
|
||||
|
||||
it('keeps options derived from restart-required options', () => {
|
||||
const previous = parseArgs(['--viaCli']);
|
||||
const next = parseArgs([
|
||||
'--viaCli',
|
||||
'--browserUrl=http://127.0.0.1:9222',
|
||||
]);
|
||||
|
||||
const merged = ConfigParser.mergeReloadableOptions(previous, next);
|
||||
|
||||
assert.strictEqual(merged.categoryExtensions, true);
|
||||
assert.strictEqual(merged.browserUrl, undefined);
|
||||
});
|
||||
|
||||
it('rejects reloadable options that conflict with startup restart-required options', () => {
|
||||
const previous = parseArgs(['--browserUrl=http://127.0.0.1:9222']);
|
||||
const next = parseArgs(['--categoryPwa']);
|
||||
|
||||
assert.throws(
|
||||
() => ConfigParser.mergeReloadableOptions(previous, next),
|
||||
/Arguments categoryPwa and browserUrl are mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not conflict with default options', () => {
|
||||
const merged = ConfigParser.mergeReloadableOptions(
|
||||
parseArgs(),
|
||||
parseArgs(['--allow-unrestricted-paths']),
|
||||
);
|
||||
|
||||
assert.strictEqual(merged.allowUnrestrictedPaths, true);
|
||||
assert.strictEqual(merged.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-8
@@ -29,8 +29,8 @@ import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
type ParsedArguments,
|
||||
ConfigParser,
|
||||
} from '../src/config/ConfigParser.js';
|
||||
ServerConfig,
|
||||
} from '../src/config/ServerConfig.js';
|
||||
import {McpContext} from '../src/McpContext.js';
|
||||
import {McpPage} from '../src/McpPage.js';
|
||||
import {McpResponse} from '../src/McpResponse.js';
|
||||
@@ -1307,12 +1307,10 @@ export function createMockContextAnalysisResult(): DevTools.HeapSnapshotModel.He
|
||||
export function createMockParsedArguments(
|
||||
options: Partial<ParsedArguments> = {},
|
||||
): ParsedArguments {
|
||||
const defaultArgs = new ConfigParser(
|
||||
'0.0.0',
|
||||
['node', 'main.js'],
|
||||
process.env,
|
||||
false,
|
||||
).parse();
|
||||
const defaultArgs = ServerConfig.applyDefaults(
|
||||
{},
|
||||
{suppressUsageStatisticsNotice: true},
|
||||
);
|
||||
return {...defaultArgs, ...options};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user