fix: doctor/setup edge cases — format detection, dynamic profiles, fish shell

- upsertJsonConfigToken: detect format by file path (opencode → mcp format,
  others → mcpServers). Previously empty files always got OpenCode format.
- Dynamic Chrome profile enumeration: scan for Default/Profile N directories
  instead of hardcoding 4 profiles.
- Fish shell: use 'set -gx' syntax for config.fish, not 'export'.
- Pass filePath through all callers (setup.ts, applyBrowserDoctorFix).
- Reduce setup auto-verify timeout from 8s to 5s.
- Add 7 new tests (19 total): empty file format, opencode path detection,
  claude.json path detection, fish shell set/replace/append, zshrc fallback.
This commit is contained in:
jackwener committed 2026-03-17 16:13:05 +08:00
1 parent ff3e5c6887
commit 25bd872a24
3 files changed
+112 -17

No files matched your search

+48
View File
@@ -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', () => {
+61 -14
View File
@@ -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<string>();
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);
}
+3 -3
View File
@@ -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 {