Files
AI/참고/playwright-main/tests/mcp/config-resolve.spec.ts

526 lines
24 KiB
TypeScript
Raw Normal View History

2026-05-12 19:40:31 +09:00
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { test, expect } from './fixtures';
import { tools } from '../../packages/playwright-core/lib/coreBundle';
import type { Config } from '../../packages/playwright-core/src/tools/mcp/config.d';
const { resolveCLIConfigForCLI, resolveCLIConfigForMCP, isSystemDirectory, outputDir } = tools;
// Empty env to isolate tests from the host environment.
const emptyEnv = {};
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Channel-agnostic tests.');
test.describe('browserName and channel', () => {
test('no browser option defaults to chromium / chrome', async () => {
const config = await resolveCLIConfigForMCP({}, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBe('chrome');
});
test('--browser=chrome sets chromium with chrome channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chrome' }, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBe('chrome');
});
test('--browser=chromium sets chromium with chrome-for-testing channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chromium' }, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBe('chrome-for-testing');
});
test('--browser=firefox sets firefox without channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'firefox' }, emptyEnv);
expect(config.browser.browserName).toBe('firefox');
expect(config.browser.launchOptions.channel).toBeUndefined();
});
test('--browser=webkit sets webkit without channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'webkit' }, emptyEnv);
expect(config.browser.browserName).toBe('webkit');
expect(config.browser.launchOptions.channel).toBeUndefined();
});
test('--browser=msedge sets chromium with msedge channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'msedge' }, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBe('msedge');
});
test('config file browserName chromium does not auto-set channel', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ browser: { browserName: 'chromium' } }));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBeUndefined();
});
test('config file browserName firefox does not set channel', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ browser: { browserName: 'firefox' } }));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.browser.browserName).toBe('firefox');
expect(config.browser.launchOptions.channel).toBeUndefined();
});
test('config file browserName + channel are both preserved', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: { browserName: 'chromium', launchOptions: { channel: 'msedge' } },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.browser.browserName).toBe('chromium');
expect(config.browser.launchOptions.channel).toBe('msedge');
});
test('cli --browser overrides config file browserName', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ browser: { browserName: 'firefox' } }));
const config = await resolveCLIConfigForMCP({ config: configFile, browser: 'webkit' }, emptyEnv);
expect(config.browser.browserName).toBe('webkit');
});
});
// ---------------------------------------------------------------------------
// Shared behavior — sandbox
// ---------------------------------------------------------------------------
test.describe('sandbox', () => {
test('chromium sandbox enabled for chrome channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chrome' }, emptyEnv);
expect(config.browser.launchOptions.chromiumSandbox).toBe(true);
});
test('chromium sandbox for chrome-for-testing channel', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chromium' }, emptyEnv);
expect(config.browser.launchOptions.channel).toBe('chrome-for-testing');
if (process.platform === 'linux')
expect(config.browser.launchOptions.chromiumSandbox).toBe(false);
else
expect(config.browser.launchOptions.chromiumSandbox).toBe(true);
});
test('sandbox not set for non-chromium browsers', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'firefox' }, emptyEnv);
expect(config.browser.launchOptions.chromiumSandbox).toBeUndefined();
});
test('explicit --sandbox overrides default', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chromium', sandbox: true }, emptyEnv);
expect(config.browser.launchOptions.chromiumSandbox).toBe(true);
});
test('explicit --no-sandbox overrides default', async () => {
const config = await resolveCLIConfigForMCP({ browser: 'chrome', sandbox: false }, emptyEnv);
expect(config.browser.launchOptions.chromiumSandbox).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Shared behavior — timeouts
// ---------------------------------------------------------------------------
test.describe('timeouts', () => {
test('default timeouts', async () => {
const config = await resolveCLIConfigForMCP({}, emptyEnv);
expect(config.timeouts.action).toBe(5000);
expect(config.timeouts.navigation).toBe(60000);
expect(config.timeouts.expect).toBe(5000);
});
test('config file timeouts override defaults', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { action: 7000 } }));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.timeouts.action).toBe(7000);
expect(config.timeouts.navigation).toBe(60000);
});
});
// ---------------------------------------------------------------------------
// Shared behavior — viewport
// ---------------------------------------------------------------------------
test.describe('viewport', () => {
test('headless sets default viewport 1280x720', async () => {
const config = await resolveCLIConfigForMCP({ headless: true }, emptyEnv);
expect(config.browser.contextOptions.viewport).toEqual({ width: 1280, height: 720 });
});
test('headed sets viewport to null', async () => {
const config = await resolveCLIConfigForMCP({ headless: false }, emptyEnv);
expect(config.browser.contextOptions.viewport).toBeNull();
});
test('explicit viewport is preserved', async () => {
const config = await resolveCLIConfigForMCP({ headless: true, viewportSize: { width: 800, height: 600 } }, emptyEnv);
expect(config.browser.contextOptions.viewport).toEqual({ width: 800, height: 600 });
});
test('config file viewport is preserved', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: { contextOptions: { viewport: { width: 640, height: 480 } } },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile, headless: true }, emptyEnv);
expect(config.browser.contextOptions.viewport).toEqual({ width: 640, height: 480 });
});
});
// ---------------------------------------------------------------------------
// Shared behavior — merge order and config file
// ---------------------------------------------------------------------------
test.describe('merge order', () => {
test('cli overrides config file', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
timeouts: { action: 1000 },
browser: { contextOptions: { viewport: { width: 640, height: 480 } } },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile, timeoutAction: 9999 }, emptyEnv);
expect(config.timeouts.action).toBe(9999);
expect(config.browser.contextOptions.viewport).toEqual({ width: 640, height: 480 });
});
test('config file values preserved when cli does not override', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
network: { allowedOrigins: ['https://example.com'] },
browser: { isolated: true },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv) as any;
expect(config.network?.allowedOrigins).toEqual(['https://example.com']);
expect(config.browser.isolated).toBe(true);
});
test('env overrides config file', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { navigation: 1000 } }));
const config = await resolveCLIConfigForMCP({ config: configFile }, {
PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION: '45000',
});
expect(config.timeouts.navigation).toBe(45000);
});
test('cli overrides env', async () => {
const config = await resolveCLIConfigForMCP({ timeoutAction: 3000, browser: 'firefox' }, {
PLAYWRIGHT_MCP_TIMEOUT_ACTION: '9000',
PLAYWRIGHT_MCP_BROWSER: 'webkit',
});
expect(config.timeouts.action).toBe(3000);
expect(config.browser.browserName).toBe('firefox');
});
test('file browser.cdpHeaders preserved when env unset', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: {
cdpEndpoint: 'ws://example.invalid',
cdpHeaders: { Authorization: 'Bearer token-from-file' },
},
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
expect(config.browser.cdpHeaders).toEqual({ Authorization: 'Bearer token-from-file' });
});
});
// ---------------------------------------------------------------------------
// Shared behavior — validation
// ---------------------------------------------------------------------------
test.describe('validation', () => {
test('isolated + userDataDir throws', async () => {
await expect(resolveCLIConfigForMCP({ isolated: true, userDataDir: '/tmp/data' }, emptyEnv))
.rejects.toThrow('Browser userDataDir is not supported in isolated mode.');
});
});
// ---------------------------------------------------------------------------
// Output directory — fallback for unsuitable cwd, throw on explicit system dir
// ---------------------------------------------------------------------------
test.describe('outputDir', () => {
test.skip(process.platform === 'win32', 'POSIX-specific cases');
test('falls back to tmpdir when cwd is /', () => {
const result = outputDir({ config: {}, cwd: '/' });
expect(result).toBe(path.join(os.tmpdir(), '.playwright-mcp'));
});
test('uses cwd-relative .playwright-mcp for normal cwd', ({}, testInfo) => {
const cwd = testInfo.outputPath('workspace');
fs.mkdirSync(cwd, { recursive: true });
const result = outputDir({ config: {}, cwd });
expect(result).toBe(path.join(cwd, '.playwright-mcp'));
});
test('uses .playwright-cli when skillMode is set', ({}, testInfo) => {
const cwd = testInfo.outputPath('workspace');
fs.mkdirSync(cwd, { recursive: true });
const result = outputDir({ config: { skillMode: true }, cwd });
expect(result).toBe(path.join(cwd, '.playwright-cli'));
});
test('skillMode falls back to tmpdir/.playwright-cli when cwd is /', () => {
const result = outputDir({ config: { skillMode: true }, cwd: '/' });
expect(result).toBe(path.join(os.tmpdir(), '.playwright-cli'));
});
test('explicit outputDir wins regardless of cwd', ({}, testInfo) => {
const explicit = testInfo.outputPath('explicit');
const result = outputDir({ config: { outputDir: explicit }, cwd: '/' });
expect(result).toBe(explicit);
});
test('falls back to tmpdir when cwd is not writable', ({}, testInfo) => {
const cwd = testInfo.outputPath('readonly');
fs.mkdirSync(cwd, { recursive: true });
fs.chmodSync(cwd, 0o500);
try {
const result = outputDir({ config: {}, cwd });
expect(result).toBe(path.join(os.tmpdir(), '.playwright-mcp'));
} finally {
fs.chmodSync(cwd, 0o700);
}
});
test('isSystemDirectory detects /', () => {
expect(isSystemDirectory('/')).toBe(true);
expect(isSystemDirectory('/tmp')).toBe(false);
expect(isSystemDirectory(os.homedir())).toBe(false);
});
test('resolveCLIConfigForMCP throws when --output-dir is /', async () => {
await expect(resolveCLIConfigForMCP({ outputDir: '/' }, emptyEnv))
.rejects.toThrow(/--output-dir cannot point to a system directory/);
});
});
// ---------------------------------------------------------------------------
// MCP-specific: headless platform default, timeout CLI options
// ---------------------------------------------------------------------------
test.describe('resolveCLIConfigForMCP', () => {
test('headless defaults based on platform', async () => {
const config = await resolveCLIConfigForMCP({}, emptyEnv);
expect(config.browser.launchOptions.headless).toBeDefined();
});
test('cli timeout overrides defaults', async () => {
const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000 }, emptyEnv);
expect(config.timeouts.action).toBe(10000);
expect(config.timeouts.navigation).toBe(30000);
expect(config.timeouts.expect).toBe(5000);
});
test('cli timeout overrides config file timeout', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { action: 1000 } }));
const config = await resolveCLIConfigForMCP({ config: configFile, timeoutAction: 9999 }, emptyEnv);
expect(config.timeouts.action).toBe(9999);
});
});
// ---------------------------------------------------------------------------
// CLI-specific: headless, isolated, userDataDir, extension, skillMode, snapshot
// ---------------------------------------------------------------------------
function resolveCLI(profilesDir: string, sessionName: string, options: any) {
return resolveCLIConfigForCLI(profilesDir, sessionName, options, emptyEnv);
}
test.describe('resolveCLIConfigForCLI - headless and viewport', () => {
test('headless by default', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {});
expect(config.browser.launchOptions.headless).toBe(true);
});
test('--headed sets headless=false', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { headed: true });
expect(config.browser.launchOptions.headless).toBe(false);
});
test('headless viewport defaults to 1280x720', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {});
expect(config.browser.contextOptions.viewport).toEqual({ width: 1280, height: 720 });
});
test('headed viewport defaults to null', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { headed: true });
expect(config.browser.contextOptions.viewport).toBeNull();
});
test('config file viewport is preserved', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: { contextOptions: { viewport: { width: 640, height: 480 } } },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile });
expect(config.browser.contextOptions.viewport).toEqual({ width: 640, height: 480 });
});
});
test.describe('resolveCLIConfigForCLI - isolated and userDataDir', () => {
test('defaults to isolated when no profile, persistent, userDataDir, or remoteEndpoint', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {});
expect(config.browser.isolated).toBe(true);
});
test('not isolated when --profile is set', async ({}, testInfo) => {
const profileDir = testInfo.outputPath('my-profile');
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { profile: profileDir });
expect(config.browser.isolated).toBe(false);
expect(config.browser.userDataDir).toBe(profileDir);
});
test('not isolated when --persistent is set', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { persistent: true });
expect(config.browser.isolated).toBe(false);
});
test('not isolated when --attach is set', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { endpoint: 'ws://localhost:1234' });
expect(config.browser.isolated).toBe(false);
});
test('not isolated when config file sets userDataDir', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const userDataDir = testInfo.outputPath('custom-data');
await fs.promises.writeFile(configFile, JSON.stringify({ browser: { userDataDir } }));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile });
expect(config.browser.isolated).toBe(false);
expect(config.browser.userDataDir).toBe(userDataDir);
});
test('auto userDataDir uses daemonProfilesDir with session and browser token', async ({}, testInfo) => {
const profilesDir = testInfo.outputPath('profiles');
const config = await resolveCLI(profilesDir, 'mysession', { persistent: true, browser: 'chrome' });
expect(config.browser.userDataDir).toBe(path.resolve(profilesDir, 'ud-mysession-chrome'));
});
test('auto userDataDir uses browserName when no channel', async ({}, testInfo) => {
const profilesDir = testInfo.outputPath('profiles');
const config = await resolveCLI(profilesDir, 'default', { persistent: true, browser: 'firefox' });
expect(config.browser.userDataDir).toBe(path.resolve(profilesDir, 'ud-default-firefox'));
});
test('auto userDataDir uses undefined token when no browser specified', async ({}, testInfo) => {
const profilesDir = testInfo.outputPath('profiles');
const config = await resolveCLI(profilesDir, 'default', { persistent: true });
expect(config.browser.userDataDir).toBe(path.resolve(profilesDir, 'ud-default-chrome'));
});
test('no auto userDataDir when isolated', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {});
expect(config.browser.isolated).toBe(true);
expect(config.browser.userDataDir).toBeUndefined();
});
test('no auto userDataDir when --profile is set', async ({}, testInfo) => {
const profileDir = testInfo.outputPath('my-profile');
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { profile: profileDir });
expect(config.browser.userDataDir).toBe(profileDir);
});
test('no auto userDataDir when remoteEndpoint is set', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { attach: 'ws://localhost:1234' });
expect(config.browser.userDataDir).toBeUndefined();
});
});
test.describe('resolveCLIConfigForCLI - timeouts', () => {
test('config file timeouts override defaults', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { action: 7000 } }));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile });
expect(config.timeouts.action).toBe(7000);
expect(config.timeouts.navigation).toBe(60000);
});
});
test.describe('resolveCLIConfigForCLI - skillMode and snapshotMode', () => {
test('skillMode is always set', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {});
expect(config.skillMode).toBe(true);
});
test('snapshot mode is full', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {}) as any;
expect(config.snapshot?.mode).toBe('full');
});
});
test.describe('resolveCLIConfigForCLI - config file discovery', () => {
test('explicit config file is used', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { action: 3000 } }));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile });
expect(config.timeouts.action).toBe(3000);
expect(config.configFile).toBe(configFile);
});
test('cli overrides env', async ({}, testInfo) => {
const config = await resolveCLIConfigForCLI(testInfo.outputPath('profiles'), 'default', { browser: 'firefox' }, {
PLAYWRIGHT_MCP_BROWSER: 'webkit',
});
expect(config.browser.browserName).toBe('firefox');
});
test('merge order: config file < daemon overrides', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: { browserName: 'firefox' },
timeouts: { action: 1000 },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile, browser: 'webkit' });
expect(config.browser.browserName).toBe('webkit');
expect(config.timeouts.action).toBe(1000);
});
test('config file values preserved when cli does not override', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
network: { allowedOrigins: ['https://example.com'] },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { config: configFile }) as any;
expect(config.network?.allowedOrigins).toEqual(['https://example.com']);
});
});
test.describe('resolveCLIConfigForCLI - extension', () => {
test('--extension disables isolated', async ({}, testInfo) => {
const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', { extension: true }) as any;
expect(config.extension).toBe(true);
expect(config.browser.isolated).toBe(false);
});
});