diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 3673ff8..8f4b33b 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -83,6 +83,54 @@ describe('json token helpers', () => { const parsed = JSON.parse(next); expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); }); + + it('creates standard mcpServers format for empty file (not OpenCode)', () => { + const next = upsertJsonConfigToken('', 'abc123'); + const parsed = JSON.parse(next); + expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); + expect(parsed.mcp).toBeUndefined(); + }); + + it('creates OpenCode format when filePath contains opencode', () => { + const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json'); + const parsed = JSON.parse(next); + expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); + expect(parsed.mcpServers).toBeUndefined(); + }); + + it('creates standard format when filePath is claude.json', () => { + const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json'); + const parsed = JSON.parse(next); + expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); + }); +}); + +describe('fish shell support', () => { + it('generates fish set -gx syntax for fish config path', () => { + const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish'); + expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"'); + expect(next).not.toContain('export'); + }); + + it('replaces existing fish set line', () => { + const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n'; + const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish'); + expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"'); + expect(next).not.toContain('"old"'); + }); + + it('appends fish syntax to existing fish config', () => { + const content = 'set -gx PATH /usr/bin\n'; + const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish'); + expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"'); + expect(next).toContain('set -gx PATH /usr/bin'); + }); + + it('uses export syntax for zshrc even with filePath', () => { + const next = upsertShellToken('', 'abc123', '/home/user/.zshrc'); + expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"'); + expect(next).not.toContain('set -gx'); + }); }); describe('doctor report rendering', () => { diff --git a/src/doctor.ts b/src/doctor.ts index 9e7f43a..16f5ed7 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -111,6 +111,15 @@ export function getDefaultShellRcPath(): string { return path.join(os.homedir(), '.zshrc'); } +function isFishConfig(filePath: string): boolean { + return filePath.endsWith('config.fish') || filePath.includes('/fish/'); +} + +/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */ +function isOpenCodeConfig(filePath: string): boolean { + return filePath.includes('opencode'); +} + export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] { const home = os.homedir(); const candidates = [ @@ -136,7 +145,15 @@ export function readTokenFromShellContent(content: string): string | null { return m?.[3] ?? null; } -export function upsertShellToken(content: string, token: string): string { +export function upsertShellToken(content: string, token: string, filePath?: string): string { + if (filePath && isFishConfig(filePath)) { + // Fish shell uses `set -gx` instead of `export` + const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`; + const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m; + if (!content.trim()) return `${fishLine}\n`; + if (fishRe.test(content)) return content.replace(fishRe, fishLine); + return `${content.replace(/\s*$/, '')}\n${fishLine}\n`; + } const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`; if (!content.trim()) return `${nextLine}\n`; if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${ @@ -162,16 +179,16 @@ function readTokenFromJsonObject(parsed: any): string | null { return null; } -export function upsertJsonConfigToken(content: string, token: string): string { +export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string { const parsed = content.trim() ? JSON.parse(content) : {}; - if (parsed?.mcpServers) { - parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? { - command: 'npx', - args: ['-y', '@playwright/mcp@latest', '--extension'], - }; - parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {}; - parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token; - } else { + + // Determine format: use OpenCode format only if explicitly an opencode config, + // or if the existing content already uses `mcp` key (not `mcpServers`) + const useOpenCodeFormat = filePath + ? isOpenCodeConfig(filePath) + : (!parsed.mcpServers && parsed.mcp); + + if (useOpenCodeFormat) { parsed.mcp = parsed.mcp ?? {}; parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? { command: ['npx', '-y', '@playwright/mcp@latest', '--extension'], @@ -180,6 +197,14 @@ export function upsertJsonConfigToken(content: string, token: string): string { }; parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {}; parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token; + } else { + parsed.mcpServers = parsed.mcpServers ?? {}; + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? { + command: 'npx', + args: ['-y', '@playwright/mcp@latest', '--extension'], + }; + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {}; + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token; } return `${JSON.stringify(parsed, null, 2)}\n`; } @@ -262,6 +287,26 @@ function readConfigStatus(filePath: string): McpConfigStatus { } } +/** + * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *' + * directories across all browser base paths. Falls back to ['Default'] if none found. + */ +function enumerateProfiles(baseDirs: string[]): string[] { + const profiles = new Set(); + for (const base of baseDirs) { + if (!fileExists(base)) continue; + try { + for (const entry of fs.readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) { + profiles.add(entry.name); + } + } + } catch { /* permission denied, etc. */ } + } + return profiles.size > 0 ? [...profiles].sort() : ['Default']; +} + /** * Discover the auth token stored by the Playwright MCP Bridge extension * by scanning Chrome's LevelDB localStorage files directly. @@ -298,7 +343,7 @@ export function discoverExtensionToken(): string | null { ); } - const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3']; + const profiles = enumerateProfiles(bases); const tokenRe = /([A-Za-z0-9_-]{40,50})/; for (const base of bases) { @@ -424,7 +469,7 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin ); } - const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3']; + const profiles = enumerateProfiles(browserDirs.map(d => d.base)); const foundBrowsers: string[] = []; for (const { name, base } of browserDirs) { @@ -643,7 +688,7 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp const written: string[] = []; if (plannedWrites.includes(shellPath)) { const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : ''; - writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token)); + writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath)); written.push(shellPath); } @@ -651,7 +696,9 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp if (!plannedWrites.includes(config.path)) continue; if (config.parseError) continue; const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : ''; - const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token); + const next = config.format === 'toml' + ? upsertTomlConfigToken(before, token) + : upsertJsonConfigToken(before, token, config.path); writeFileWithMkdir(config.path, next); written.push(config.path); } diff --git a/src/setup.ts b/src/setup.ts index 3a49e5c..d03f522 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -144,7 +144,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { if (sel.startsWith('shell:')) { const p = sel.slice('shell:'.length); const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : ''; - writeFileWithMkdir(p, upsertShellToken(before, token)); + writeFileWithMkdir(p, upsertShellToken(before, token, p)); written.push(p); wroteShell = true; } else if (sel.startsWith('config:')) { @@ -153,7 +153,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { if (config && config.parseError) continue; const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : ''; const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json'); - const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token); + const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p); writeFileWithMkdir(p, next); written.push(p); } @@ -180,7 +180,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { // Step 7: Auto-verify browser connectivity console.log(chalk.dim(' Verifying browser connectivity...')); try { - const result = await checkTokenConnectivity(); + const result = await checkTokenConnectivity({ timeout: 5 }); if (result.ok) { console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`); } else {