mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
test(browser-launcher): add cross-platform browser launch tests
Solution-ID: SOL-1735386000002 Issue-ID: ISS-1766921318981-16 Task-ID: T4
This commit is contained in:
182
ccw/tests/browser-launcher.test.ts
Normal file
182
ccw/tests/browser-launcher.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for browser-launcher utility module.
|
||||
*
|
||||
* Notes:
|
||||
* - Targets the runtime implementation shipped in `ccw/dist`.
|
||||
* - Prevents real browser launches by stubbing `child_process.spawn` used by the `open` package.
|
||||
* - Stubs `os.platform` and `path.resolve` to exercise platform-specific URL formatting.
|
||||
*/
|
||||
|
||||
import { after, beforeEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const os = require('node:os') as typeof import('node:os');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const childProcess = require('node:child_process') as typeof import('node:child_process');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pathModule = require('node:path') as typeof import('node:path');
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
type SpawnCall = { command: string; args: string[] };
|
||||
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const spawnPlan: Array<{ type: 'return' } | { type: 'throw'; error: Error }> = [];
|
||||
|
||||
const originalPlatform = os.platform;
|
||||
const originalSpawn = childProcess.spawn;
|
||||
const originalResolve = pathModule.resolve;
|
||||
|
||||
let platformValue: NodeJS.Platform = originalPlatform();
|
||||
let resolveImpl = (...args: string[]) => originalResolve(...args);
|
||||
|
||||
os.platform = (() => platformValue) as any;
|
||||
pathModule.resolve = ((...args: string[]) => resolveImpl(...args)) as any;
|
||||
|
||||
childProcess.spawn = ((command: string, args: string[] = []) => {
|
||||
spawnCalls.push({ command: String(command), args: args.map(String) });
|
||||
|
||||
const next = spawnPlan.shift();
|
||||
if (next?.type === 'throw') {
|
||||
throw next.error;
|
||||
}
|
||||
|
||||
return { unref() {} } as any;
|
||||
}) as any;
|
||||
|
||||
const browserLauncherUrl = new URL('../dist/utils/browser-launcher.js', import.meta.url).href;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mod: any;
|
||||
|
||||
function extractOpenTargets(): string[] {
|
||||
const targets: string[] = [];
|
||||
|
||||
for (const call of spawnCalls) {
|
||||
const encodedIndex = call.args.indexOf('-EncodedCommand');
|
||||
if (encodedIndex !== -1) {
|
||||
const base64 = call.args[encodedIndex + 1];
|
||||
if (!base64) continue;
|
||||
|
||||
const decoded = Buffer.from(base64, 'base64').toString('utf16le');
|
||||
const match = decoded.match(/Start\\s+\"([^\"]+)\"/);
|
||||
targets.push(match?.[1] ?? decoded);
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.push([call.command, ...call.args].join(' '));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function normalizedTargets(): string[] {
|
||||
return extractOpenTargets().map((t) => t.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
spawnPlan.length = 0;
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
platformValue = originalPlatform();
|
||||
resolveImpl = (...args: string[]) => originalResolve(...args);
|
||||
});
|
||||
|
||||
describe('browser-launcher utility module', async () => {
|
||||
mod = await import(browserLauncherUrl);
|
||||
|
||||
it('launchBrowser opens HTTP/HTTPS URLs', async () => {
|
||||
await mod.launchBrowser('http://example.com');
|
||||
await mod.launchBrowser('https://example.com');
|
||||
|
||||
const targets = normalizedTargets().join('\n');
|
||||
assert.ok(targets.includes('http://example.com'));
|
||||
assert.ok(targets.includes('https://example.com'));
|
||||
});
|
||||
|
||||
it('launchBrowser converts file path to file:// URL (Windows)', async () => {
|
||||
platformValue = 'win32';
|
||||
resolveImpl = () => 'C:\\tmp\\file.html';
|
||||
|
||||
await mod.launchBrowser('file.html');
|
||||
assert.ok(normalizedTargets().join('\n').includes('file:///C:/tmp/file.html'));
|
||||
});
|
||||
|
||||
it('launchBrowser converts file path to file:// URL (Unix)', async () => {
|
||||
platformValue = 'linux';
|
||||
resolveImpl = () => '/tmp/file.html';
|
||||
|
||||
await mod.launchBrowser('file.html');
|
||||
assert.ok(normalizedTargets().join('\n').includes('file:///tmp/file.html'));
|
||||
});
|
||||
|
||||
it('isHeadlessEnvironment detects common CI env vars', () => {
|
||||
for (const key of ['CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL']) {
|
||||
delete process.env[key];
|
||||
}
|
||||
assert.equal(mod.isHeadlessEnvironment(), false);
|
||||
|
||||
process.env.CI = '1';
|
||||
assert.equal(mod.isHeadlessEnvironment(), true);
|
||||
|
||||
delete process.env.CI;
|
||||
process.env.GITHUB_ACTIONS = 'true';
|
||||
assert.equal(mod.isHeadlessEnvironment(), true);
|
||||
});
|
||||
|
||||
it('wraps browser launch errors for URLs', async () => {
|
||||
spawnPlan.push({ type: 'throw', error: new Error('boom') });
|
||||
await assert.rejects(
|
||||
mod.launchBrowser('https://example.com'),
|
||||
(err: any) => err instanceof Error && err.message.includes('Failed to open browser: boom'),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to opening file path directly when URL open fails', async () => {
|
||||
platformValue = 'win32';
|
||||
resolveImpl = () => 'C:\\tmp\\file.html';
|
||||
spawnPlan.push({ type: 'throw', error: new Error('primary fail') });
|
||||
spawnPlan.push({ type: 'return' });
|
||||
|
||||
await mod.launchBrowser('file.html');
|
||||
const targets = normalizedTargets().join('\n');
|
||||
assert.ok(targets.includes('file:///C:/tmp/file.html'));
|
||||
assert.ok(targets.includes('C:/tmp/file.html'));
|
||||
});
|
||||
|
||||
it('throws when both primary and fallback file opens fail', async () => {
|
||||
platformValue = 'win32';
|
||||
resolveImpl = () => 'C:\\tmp\\file.html';
|
||||
spawnPlan.push({ type: 'throw', error: new Error('primary fail') });
|
||||
spawnPlan.push({ type: 'throw', error: new Error('fallback fail') });
|
||||
|
||||
await assert.rejects(
|
||||
mod.launchBrowser('file.html'),
|
||||
(err: any) =>
|
||||
err instanceof Error && err.message.includes('Failed to open browser: primary fail'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
os.platform = originalPlatform;
|
||||
childProcess.spawn = originalSpawn;
|
||||
pathModule.resolve = originalResolve;
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user