Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 68 additions & 23 deletions plugins/power-pages/scripts/launch-playwright-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,95 @@
// then falls back to Playwright's bundled Chromium.
// Self-contained — no external dependencies required.

const { spawn } = require('child_process');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const path = require('path');
const { detectBrowser } = require('./lib/detect-browser');

function quoteShellArg(value, platform = process.platform) {
const argument = String(value);

if (platform === 'win32') {
if (argument.includes('"')) {
throw new Error('Cannot quote an argument containing double quotes for cmd.exe.');
}

return `"${argument}"`;
}

return `'${argument.replace(/'/g, "'\\''")}'`;
}
const PLAYWRIGHT_MCP_VERSION = '0.0.78';
const PLAYWRIGHT_MCP_PACKAGE = `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`;

function buildMcpArgs(browser, {
configPath = path.join(__dirname, 'playwright-mcp-fullscreen.config.json'),
platform = process.platform,
} = {}) {
// Marketplace installs copy only this plugin directory and do not run npm install,
// so a lockfile would not materialize a local executable. Keep the runtime package
// immutable, and disable lifecycle scripts while npx prepares the reviewed version.
return [
'-y',
'@playwright/mcp@latest',
'--yes',
'--ignore-scripts',
`--package=${PLAYWRIGHT_MCP_PACKAGE}`,
'playwright-mcp',
'--browser',
browser,
'--config',
quoteShellArg(configPath, platform),
configPath,
];
}

function resolveNpxCli({
execPath = process.execPath,
platform = process.platform,
existsSync = fs.existsSync,
} = {}) {
// Windows exposes npx as a .cmd shim that cannot run with shell:false. Invoking
// npm's JavaScript entrypoint through Node preserves raw argv on every platform.
const pathApi = platform === 'win32' ? path.win32 : path.posix;
const nodeDir = pathApi.dirname(execPath);
const candidates = [
pathApi.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
pathApi.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
];
const match = candidates.find((candidate) => existsSync(candidate));

if (!match) {
throw new Error(
'Could not locate npm/bin/npx-cli.js beside the current Node installation. Install Node.js with npm before starting the Playwright MCP server.',
);
}

return match;
}

function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) {
const child = spawnFn('npx', buildMcpArgs(browser), {
function launch({
browser = detectBrowser(),
npxCliPath,
resolveNpxCliFn = resolveNpxCli,
spawnFn = spawn,
exitFn = (code) => process.exit(code),
writeError = (message) => process.stderr.write(message),
} = {}) {
let resolvedNpxCliPath = npxCliPath;
if (resolvedNpxCliPath === undefined) {
try {
resolvedNpxCliPath = resolveNpxCliFn();
} catch (error) {
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
exitFn(1);
return null;
}
}

const child = spawnFn(process.execPath, [resolvedNpxCliPath, ...buildMcpArgs(browser)], {
stdio: 'inherit',
shell: true,
shell: false,
});

child.on('exit', onExit);
child.once('error', (error) => {
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
exitFn(1);
});
child.once('exit', (code) => exitFn(code ?? 1));
return child;
}

if (require.main === module) {
launch();
}

module.exports = { buildMcpArgs, launch, quoteShellArg };
module.exports = {
PLAYWRIGHT_MCP_PACKAGE,
buildMcpArgs,
launch,
resolveNpxCli,
};
158 changes: 145 additions & 13 deletions plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,69 @@ const test = require('node:test');
const { EventEmitter } = require('node:events');

const {
PLAYWRIGHT_MCP_PACKAGE,
buildMcpArgs,
launch,
quoteShellArg,
resolveNpxCli,
} = require('../launch-playwright-mcp');

test('buildMcpArgs launches Playwright MCP with fullscreen config', () => {
test('buildMcpArgs launches the exact reviewed Playwright MCP version', () => {
const expectedConfigPath = path.join(__dirname, '..', 'playwright-mcp-fullscreen.config.json');
const args = buildMcpArgs('chrome');
const configIndex = args.indexOf('--config');

assert.deepEqual(args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'chrome']);
assert.equal(PLAYWRIGHT_MCP_PACKAGE, '@playwright/mcp@0.0.78');
assert.deepEqual(
args.slice(0, 6),
[
'--yes',
'--ignore-scripts',
'--package=@playwright/mcp@0.0.78',
'playwright-mcp',
'--browser',
'chrome',
],
);
assert.equal(args.some((arg) => /@(latest|next|\^|~|\*)$/.test(arg)), false);
assert.equal(args.includes('--viewport-size'), false);
assert.notEqual(configIndex, -1);
assert.equal(args[configIndex + 1], quoteShellArg(expectedConfigPath));
assert.equal(args[configIndex + 1], expectedConfigPath);
});

test('buildMcpArgs quotes Windows config paths containing spaces', () => {
const configPath = 'C:\\Users\\Power User\\.claude\\plugins\\power-pages\\scripts\\playwright-mcp-fullscreen.config.json';
const args = buildMcpArgs('msedge', { configPath, platform: 'win32' });
test('buildMcpArgs preserves config paths with spaces and shell metacharacters as raw argv', () => {
const configPath = '/tmp/Power Pages $(echo unsafe); & [preview]/config\'s "quoted" path.json';
const args = buildMcpArgs('chrome', { configPath });
const configIndex = args.indexOf('--config');

assert.equal(args[configIndex + 1], `"${configPath}"`);
assert.equal(args[configIndex + 1], configPath);
});

test('buildMcpArgs preserves Windows config paths without shell quoting', () => {
const configPath = 'C:\\Users\\Power User & Team\\Power Pages (Preview)\\playwright-mcp.config.json';
const args = buildMcpArgs('msedge', { configPath });
const configIndex = args.indexOf('--config');

assert.equal(args[configIndex + 1], configPath);
});

test('resolveNpxCli finds the Windows npm JavaScript entrypoint', () => {
const expected = 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js';
const checked = [];

const resolved = resolveNpxCli({
execPath: 'C:\\Program Files\\nodejs\\node.exe',
platform: 'win32',
existsSync(candidate) {
checked.push(candidate);
return candidate === expected;
},
});

assert.equal(resolved, expected);
assert.deepEqual(checked, [
'C:\\Program Files\\lib\\node_modules\\npm\\bin\\npx-cli.js',
expected,
]);
});

test('fullscreen config maximizes the browser and uses the real viewport size', () => {
Expand All @@ -37,25 +78,116 @@ test('fullscreen config maximizes the browser and uses the real viewport size',
assert.equal(config.browser.contextOptions.viewport, null);
});

test('launch wires spawn and process exit handling', () => {
test('launch preserves an explicit npx CLI path and uses raw argv without a shell', () => {
let spawnCall;
const child = new EventEmitter();

launch({
browser: 'msedge',
npxCliPath: 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js',
resolveNpxCliFn() {
assert.fail('explicit npxCliPath must bypass default resolution');
},
spawnFn(command, args, options) {
spawnCall = { command, args, options };
return child;
},
onExit(code) {
exitFn(code) {
spawnCall.exitCode = code;
},
});

assert.equal(spawnCall.command, 'npx');
assert.deepEqual(spawnCall.args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'msedge']);
assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: true });
assert.equal(spawnCall.command, process.execPath);
assert.deepEqual(
spawnCall.args.slice(0, 7),
[
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js',
'--yes',
'--ignore-scripts',
'--package=@playwright/mcp@0.0.78',
'playwright-mcp',
'--browser',
'msedge',
],
);
assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: false });

child.emit('exit', 7);
assert.equal(spawnCall.exitCode, 7);
});

test('launch reports missing npm once and does not spawn', () => {
const exits = [];
let spawnCalls = 0;
let stderr = '';

const child = launch({
browser: 'chrome',
resolveNpxCliFn() {
throw new Error('Could not locate npm/bin/npx-cli.js');
},
spawnFn() {
spawnCalls += 1;
return new EventEmitter();
},
exitFn(code) {
exits.push(code);
},
writeError(message) {
stderr += message;
},
});

assert.equal(child, null);
assert.equal(spawnCalls, 0);
assert.deepEqual(exits, [1]);
assert.equal(
stderr,
'Failed to start Playwright MCP: Could not locate npm/bin/npx-cli.js\n',
);
});

test('launch reports spawn errors and exits with failure', () => {
const child = new EventEmitter();
const exits = [];
let stderr = '';

launch({
browser: 'chrome',
npxCliPath: '/trusted/npm/bin/npx-cli.js',
spawnFn() {
return child;
},
exitFn(code) {
exits.push(code);
},
writeError(message) {
stderr += message;
},
});

child.emit('error', new Error('spawn ENOENT'));

assert.deepEqual(exits, [1]);
assert.match(stderr, /^Failed to start Playwright MCP: spawn ENOENT\n$/);
});

test('launch treats signal-only child exits as failures', () => {
const child = new EventEmitter();
let exitCode;

launch({
browser: 'chrome',
npxCliPath: '/trusted/npm/bin/npx-cli.js',
spawnFn() {
return child;
},
exitFn(code) {
exitCode = code;
},
});

child.emit('exit', null, 'SIGTERM');

assert.equal(exitCode, 1);
});
Loading
Loading