From 33a2bdb9f0efc8d9cb115e0ee91a8bf6269f4e84 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 29 Dec 2025 00:19:37 +0800 Subject: [PATCH] test(browser-launcher): add cross-platform browser launch tests Solution-ID: SOL-1735386000002 Issue-ID: ISS-1766921318981-16 Task-ID: T4 --- ccw/tests/browser-launcher.test.ts | 182 +++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 ccw/tests/browser-launcher.test.ts diff --git a/ccw/tests/browser-launcher.test.ts b/ccw/tests/browser-launcher.test.ts new file mode 100644 index 00000000..7b3c9e80 --- /dev/null +++ b/ccw/tests/browser-launcher.test.ts @@ -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; + } +});