Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
2 changes: 1 addition & 1 deletion plugins/power-pages/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"command": "node",
"args": [
"-e",
"const fs=require('node:fs'); const path=require('node:path'); const root=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.cwd(); const entry=path.resolve(root,'scripts','launch-playwright-mcp.js'); if(!fs.existsSync(entry)) throw new Error('Could not resolve Power Pages plugin root; set PLUGIN_ROOT or launch from the plugin root'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') throw new Error('Power Pages Playwright MCP launcher did not export launch()'); mod.launch();"
"const fs=require('node:fs'); const path=require('node:path'); const fail=(message)=>{throw new Error('[Power Pages Playwright MCP] '+message);}; const declaredRoot=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT; if(!declaredRoot) fail('PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set; refusing to resolve the launcher from the current working directory.'); if(!path.isAbsolute(declaredRoot)) fail('Declared plugin root must be an absolute path: '+declaredRoot+'.'); let root; try{root=fs.realpathSync(declaredRoot);}catch(error){fail('Declared plugin root is invalid: '+declaredRoot+' ('+(error.code||error.message)+').');} let rootStat; try{rootStat=fs.statSync(root);}catch(error){fail('Could not inspect declared plugin root: '+root+' ('+(error.code||error.message)+').');} if(!rootStat.isDirectory()) fail('Declared plugin root is not a directory: '+declaredRoot+'.'); const candidate=path.resolve(root,'scripts','launch-playwright-mcp.js'); let entry; try{entry=fs.realpathSync(candidate);}catch(error){fail('Launcher was not found under the declared plugin root: '+candidate+'.');} const relative=path.relative(root,entry); if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); let entryStat; try{entryStat=fs.statSync(entry);}catch(error){fail('Could not inspect resolved launcher: '+entry+' ('+(error.code||error.message)+').');} if(!entryStat.isFile()) fail('Resolved launcher is not a file: '+entry+'.'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') fail('Launcher did not export launch(): '+entry+'.'); mod.launch();"
]
},
"microsoft-learn": {
Expand Down
39 changes: 14 additions & 25 deletions plugins/power-pages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,8 @@ The plugin ships with two MCP servers configured in `.mcp.json` — they start a
| **playwright** | Headless browser automation for live previews and runtime tests |
| **microsoft-learn** | Grounded search/fetch over official Microsoft Learn docs |

The plugin host must provide an absolute `PLUGIN_ROOT` (GitHub Copilot) or `CLAUDE_PLUGIN_ROOT` (Claude Code). The Playwright bootstrap resolves its launcher only from that declared plugin root and never from the workspace working directory.

## Typical Workflow

A common end-to-end workflow looks like this:
Expand Down Expand Up @@ -465,33 +467,20 @@ A common end-to-end workflow looks like this:

Steps can be run independently — you don't need to follow this exact order. Each skill checks its own prerequisites and will tell you if something is missing. If something goes wrong, `/diagnose-deployment` pattern-matches deployment errors and `/report-issue` opens a pre-filled GitHub issue.

## Running Without Interruption

The plugin invokes multiple tools during a session. To reduce approval prompts:

**Option 1 — Permission mode (recommended)**

```jsonc
// .claude/settings.json
{
"defaultMode": "acceptEdits",
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git *)",
"Bash(pac *)",
"Bash(az *)",
"Bash(node *)"
]
}
}
```
## Runtime approvals

**Option 2 — Auto-accept all**
Keep your AI host's runtime approval prompts enabled while using this plugin.
Plugin scripts run on your workstation with the filesystem access and cloud sign-in state available to your user account.
A script that invokes `pac` or `az` may therefore act on Power Platform environments, Dataverse data, and Azure tenants that you can access.

```bash
claude --dangerously-skip-permissions
```
Before approving a command, check the executable, script path, arguments, and target environment.
Pay particular attention to commands that read or change project files, environment configuration, tenant resources, or business data.
Do not grant blanket approval to command families such as `node`, `npm`, `git`, `pac`, or `az`.

If your host supports command-specific allow rules, use them only for an exact plugin script path that you have inspected and expect to run.
Keep approval prompts for commands whose arguments or environment variables select a project, environment, tenant, or data source.
Permission features and rule syntax vary by host and version, so follow the documentation for your host.
Suppressing an approval prompt does not sandbox a script, restrict the programs it can start, or guarantee that the command is safe.

## ALM prompts you may see

Expand Down
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