diff --git a/packages/cli/src/artifact.spec.ts b/packages/cli/src/artifact.spec.ts index eba8082b..b3261c38 100644 --- a/packages/cli/src/artifact.spec.ts +++ b/packages/cli/src/artifact.spec.ts @@ -154,7 +154,12 @@ describe('built public artifact', () => { expect(readFileSync(join(cliRoot, 'runtime', 'web', 'index.html'), 'utf8')).toContain(''); expect(readFileSync(join(cliRoot, 'packaging', 'systemd', 'codor.service'), 'utf8')) .toContain('ExecStart='); - expect(statSync(join(outDir, 'bin', 'codor.mjs')).mode & 0o111).not.toBe(0); + // Windows has no POSIX executable permission bit — writeFileSync's `mode` + // option cannot set one there, so this property only exists to check on + // platforms where the filesystem tracks it. + if (process.platform !== 'win32') { + expect(statSync(join(outDir, 'bin', 'codor.mjs')).mode & 0o111).not.toBe(0); + } }); it('contains no source, specs, fixtures, symlinks, or workspace protocols', () => { @@ -210,4 +215,21 @@ describe('CLI test environment isolation', () => { expect(String(failure.stderr)).not.toContain('never-print-this-value'); } }); + + it('spawns a bare npm bin name by resolving its real entrypoint', () => { + // vitest in node_modules/.bin is a .CMD shim on Windows; this proves the + // wrapper resolves and runs it rather than spawning the bare name as-is. + const output = execFileSync(process.execPath, [wrapper, 'vitest', '--version'], { encoding: 'utf8' }); + expect(output).toMatch(/^vitest\//); + }); + + it('falls back to spawning a bare non-package executable directly', () => { + // `node` names no npm package in this workspace; the wrapper must still + // spawn it directly rather than requiring a package.json bin entry. + const output = execFileSync(process.execPath, [wrapper, 'node', '-e', 'process.stdout.write("ok")'], { + encoding: 'utf8', + env: poisoned, + }); + expect(output).toBe('ok'); + }); }); diff --git a/packages/cli/src/index.spec.ts b/packages/cli/src/index.spec.ts index bbd00a49..f7f7aa47 100644 --- a/packages/cli/src/index.spec.ts +++ b/packages/cli/src/index.spec.ts @@ -1264,12 +1264,14 @@ describe('@codor/cli', () => { const fixtures = fileURLToPath(new URL('../fixtures/', import.meta.url)); const claudeTranscript = join(dir, 'claude-transcript.jsonl'); writeFileSync( - JSON.stringify(claudeTranscript).slice(1, -1), + claudeTranscript, readFileSync(join(fixtures, 'claude-transcript.jsonl'), 'utf8'), ); + // JSON-escape the path before embedding it in a JSON string value — a raw + // Windows path's single backslashes are not valid JSON escape sequences. const claudeRaw = readFileSync(join(fixtures, 'claude-stop.json'), 'utf8').replace( 'CLAUDE_TRANSCRIPT_PATH', - claudeTranscript, + JSON.stringify(claudeTranscript).slice(1, -1), ); expect(parseMirrorHook('claude', claudeRaw)).toMatchObject({ type: 'mirror_turn', @@ -1314,6 +1316,9 @@ describe('@codor/cli', () => { // harn:assume empty-database-desk-uses-service-home ref=bootstrap-service-home-regression // harn:assume empty-database-desk-seeds-tutorial-atomically ref=bootstrap-tutorial-regression + // Two full startCodor/close cycles plus native module load routinely exceed + // vitest's 5s default on Windows, where process and filesystem overhead is + // higher than on POSIX; this does not indicate a hang (observed ~6s). it('bootstraps one home-rooted Desk and one durable Tutorial message', async () => { const homeDir = join(dir, 'service-home'); mkdirSync(homeDir); @@ -1407,7 +1412,7 @@ describe('@codor/cli', () => { } finally { await restarted.close(); } - }); + }, 15000); // harn:end empty-database-desk-seeds-tutorial-atomically // harn:end empty-database-desk-uses-service-home diff --git a/packages/cli/src/runtime-install.spec.ts b/packages/cli/src/runtime-install.spec.ts index 883f1116..24c872d3 100644 --- a/packages/cli/src/runtime-install.spec.ts +++ b/packages/cli/src/runtime-install.spec.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, sep } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -12,7 +12,10 @@ import { } from './runtime-install.js'; import { type RuntimePaths } from './runtime-paths.js'; -const HOME = '/home/u'; +// Built through `resolve()`, the same call the code under test makes on a +// durable install root, so this fixture already carries a Windows drive +// prefix where the implementation's own normalization would add one. +const HOME = resolve(sep, 'home', 'u'); const DATA = join(HOME, '.codor'); const LOCATION = join(DATA, 'runtime'); diff --git a/packages/cli/src/setup-tailscale.spec.ts b/packages/cli/src/setup-tailscale.spec.ts index 69d25568..2cb995b7 100644 --- a/packages/cli/src/setup-tailscale.spec.ts +++ b/packages/cli/src/setup-tailscale.spec.ts @@ -1,3 +1,5 @@ +import { resolve, sep } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { configureTailscaleServe, resolveTailscale, tailscaleServeSupported } from './setup.js'; @@ -6,7 +8,10 @@ const APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'; describe('resolveTailscale', () => { it('returns the PATH hit when present', () => { - expect(resolveTailscale(() => '/usr/bin/tailscale', 'darwin', () => true)).toBe('/usr/bin/tailscale'); + // resolveTailscale normalizes the PATH hit through resolve(); build the + // fixture the same way so the expectation matches on every platform. + const pathHit = resolve(sep, 'usr', 'bin', 'tailscale'); + expect(resolveTailscale(() => pathHit, 'darwin', () => true)).toBe(pathHit); }); it('falls back to the macOS app location when PATH misses but the app exists', () => { diff --git a/scripts/test-env.mjs b/scripts/test-env.mjs index e7ba61d2..552c6061 100755 --- a/scripts/test-env.mjs +++ b/scripts/test-env.mjs @@ -1,6 +1,10 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join } from 'node:path'; + +const require = createRequire(import.meta.url); const [command, ...args] = process.argv.slice(2); if (command === undefined) { @@ -8,6 +12,27 @@ if (command === undefined) { process.exit(2); } +// Resolve a bare package bin name (e.g. "vitest") to its real entrypoint so +// it can run under `node` directly, bypassing the platform-specific +// node_modules/.bin shim (a .CMD file on Windows, which child_process.spawn +// refuses to execute without `shell: true`). Returns undefined for a bare +// command that names no such package (e.g. a system executable like `node` +// or `git`) — the caller falls back to spawning it directly, exactly as +// before this resolution existed. A command that already looks like a path +// (e.g. an absolute executable path a caller resolved itself) is spawned +// unchanged below and never reaches this function. +function resolveBinEntry(name) { + let packageJsonPath; + try { + packageJsonPath = require.resolve(`${name}/package.json`); + } catch { + return undefined; + } + const pkg = require(packageJsonPath); + const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name]; + return bin === undefined ? undefined : join(dirname(packageJsonPath), bin); +} + // harn:assume cli-tests-delete-inherited-codor-environment ref=cli-test-environment-wrapper const env = { ...process.env }; const removed = Object.keys(env).filter((key) => key.startsWith('CODOR_')).sort(); @@ -16,7 +41,12 @@ if (removed.length > 0) { process.stderr.write(`test-env: removed ${String(removed.length)} inherited CODOR_ variable(s): ${removed.join(', ')}\n`); } -const child = spawn(command, args, { stdio: 'inherit', env }); +const looksLikePath = command.includes('/') || command.includes('\\') || isAbsolute(command); +const binEntry = looksLikePath ? undefined : resolveBinEntry(command); +const [spawnCommand, spawnArgs] = binEntry === undefined + ? [command, args] + : [process.execPath, [binEntry, ...args]]; +const child = spawn(spawnCommand, spawnArgs, { stdio: 'inherit', env }); child.on('error', (error) => { process.stderr.write(`test-env: failed to spawn ${command}: ${error.message}\n`); process.exitCode = 1;