diff --git a/tests/lib/claude-plugin-setup.test.js b/tests/lib/claude-plugin-setup.test.js index cdda9f5f0..f3715d3c5 100644 --- a/tests/lib/claude-plugin-setup.test.js +++ b/tests/lib/claude-plugin-setup.test.js @@ -4,6 +4,7 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { withoutShadowingClaude } = require('./helpers/windows-test-env'); const repoRoot = path.join(__dirname, '..', '..'); const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js'); @@ -87,7 +88,7 @@ function withFixture(initialState, fn) { process.chdir(fixture.projectRoot); process.env.HOME = fixture.homeDir; process.env.USERPROFILE = fixture.homeDir; - process.env.PATH = `${fixture.binDir}${path.delimiter}${previous.PATH || ''}`; + process.env.PATH = `${fixture.binDir}${path.delimiter}${withoutShadowingClaude(previous.PATH)}`; process.env.CLAUDE_CONFIG_DIR = fixture.configDir; process.env.ECC_TEST_CLAUDE_STATE = fixture.statePath; process.env.ECC_TEST_CLAUDE_CALLS = fixture.callsPath; diff --git a/tests/lib/claude-scope-migration.test.js b/tests/lib/claude-scope-migration.test.js index 91ce940ba..c3ab6606f 100644 --- a/tests/lib/claude-scope-migration.test.js +++ b/tests/lib/claude-scope-migration.test.js @@ -4,6 +4,7 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { withoutShadowingClaude } = require('./helpers/windows-test-env'); const repoRoot = path.join(__dirname, '..', '..'); const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js'); @@ -100,7 +101,7 @@ function withFixture(initialState, fn) { process.chdir(fixture.projectRoot); process.env.HOME = fixture.homeDir; process.env.USERPROFILE = fixture.homeDir; - process.env.PATH = `${fixture.binDir}${path.delimiter}${previous.PATH || ''}`; + process.env.PATH = `${fixture.binDir}${path.delimiter}${withoutShadowingClaude(previous.PATH)}`; process.env.CLAUDE_CONFIG_DIR = fixture.configDir; process.env.ECC_TEST_CLAUDE_STATE = fixture.statePath; process.env.ECC_TEST_CLAUDE_CALLS = fixture.callsPath; diff --git a/tests/lib/helpers/windows-test-env.js b/tests/lib/helpers/windows-test-env.js new file mode 100644 index 000000000..9ccfeefa6 --- /dev/null +++ b/tests/lib/helpers/windows-test-env.js @@ -0,0 +1,38 @@ +/** + * Keep tests independent of tools installed on a Windows developer machine. + * + * Linux CI never sees these leaks, so they surface only as local failures. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// On Windows, spawning a bare `claude` resolves only .com/.exe on PATH, never +// .cmd. A natively installed claude.exe therefore wins over a test's fake +// claude.cmd even when the fake's directory comes first on PATH. +const SHADOWING_CLAUDE_BINARIES = ['claude.com', 'claude.exe']; + +/** + * Drop PATH entries holding a real Claude binary that would shadow a fake + * `claude.cmd` launcher. A no-op off Windows, where PATH order is honoured. + */ +function withoutShadowingClaude(pathValue, platform = process.platform) { + if (platform !== 'win32') return pathValue || ''; + return String(pathValue || '') + .split(path.win32.delimiter) + .filter(dir => dir && !SHADOWING_CLAUDE_BINARIES.some(name => fs.existsSync(path.join(dir, name)))) + .join(path.win32.delimiter); +} + +/** + * Name an archive relative to the directory tar runs in, with forward slashes. + * GNU tar, which Git for Windows puts first on PATH, reads an archive argument + * containing a colon (C:\...) as a host:path remote spec; bsdtar does not. + */ +function toRelativeTarPath(cwd, archivePath) { + return path.relative(cwd, archivePath).split(path.sep).join('/'); +} + +module.exports = { withoutShadowingClaude, toRelativeTarPath }; diff --git a/tests/lib/windows-test-env.test.js b/tests/lib/windows-test-env.test.js new file mode 100644 index 000000000..be888271f --- /dev/null +++ b/tests/lib/windows-test-env.test.js @@ -0,0 +1,62 @@ +/** + * Tests for tests/lib/helpers/windows-test-env.js + * + * Run with: node tests/lib/windows-test-env.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { test, banner, summary } = require('./helpers/mini-test-runner'); +const { withoutShadowingClaude, toRelativeTarPath } = require('./helpers/windows-test-env'); + +banner('windows-test-env helper'); + +let passed = 0; +let failed = 0; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-test-env-')); +const exeDir = path.join(root, 'native-install'); +const comDir = path.join(root, 'com-install'); +const cmdDir = path.join(root, 'npm-global'); +const plainDir = path.join(root, 'tools'); +for (const dir of [exeDir, comDir, cmdDir, plainDir]) fs.mkdirSync(dir); +fs.writeFileSync(path.join(exeDir, 'claude.exe'), ''); +fs.writeFileSync(path.join(comDir, 'claude.com'), ''); +// A .cmd shim cannot shadow a fake .cmd placed earlier on PATH, so it stays. +fs.writeFileSync(path.join(cmdDir, 'claude.cmd'), ''); + +try { + if (test('drops PATH entries holding claude.exe or claude.com on Windows', () => { + const pathValue = [plainDir, exeDir, cmdDir, comDir].join(';'); + assert.strictEqual(withoutShadowingClaude(pathValue, 'win32'), [plainDir, cmdDir].join(';')); + })) passed++; else failed++; + + if (test('drops empty entries and tolerates a missing PATH on Windows', () => { + assert.strictEqual(withoutShadowingClaude(`;${plainDir};;`, 'win32'), plainDir); + assert.strictEqual(withoutShadowingClaude(undefined, 'win32'), ''); + })) passed++; else failed++; + + if (test('leaves PATH untouched off Windows', () => { + const pathValue = [plainDir, exeDir].join(':'); + assert.strictEqual(withoutShadowingClaude(pathValue, 'linux'), pathValue); + assert.strictEqual(withoutShadowingClaude(undefined, 'darwin'), ''); + })) passed++; else failed++; + + if (test('names an archive relatively with forward slashes and no drive letter', () => { + const cwd = path.join(root, 'extract', 'node_modules'); + const archive = path.join(root, 'pkg-1.0.0.tgz'); + const relative = toRelativeTarPath(cwd, archive); + assert.ok(!relative.includes(':'), `tar reads a colon as host:path: ${relative}`); + assert.ok(!relative.includes('\\'), `expected forward slashes: ${relative}`); + assert.strictEqual(path.resolve(cwd, relative), archive); + })) passed++; else failed++; +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} + +summary(passed, failed); diff --git a/tests/scripts/ecc-universal-bin.test.js b/tests/scripts/ecc-universal-bin.test.js index c4336d26a..27f508e95 100644 --- a/tests/scripts/ecc-universal-bin.test.js +++ b/tests/scripts/ecc-universal-bin.test.js @@ -12,6 +12,7 @@ const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const { getNpmPackEntry } = require('../lib/npm-pack-output'); +const { toRelativeTarPath } = require('../lib/helpers/windows-test-env'); const repoRoot = path.join(__dirname, '..', '..'); const packageJson = JSON.parse( @@ -170,8 +171,8 @@ function prepareLocalPackedProject(packageManager) { }); } fs.mkdirSync(modulesDirectory, { recursive: true }); - run('tar', ['-xzf', fixture.archivePath, '-C', modulesDirectory], { - cwd: projectDirectory, + run('tar', ['-xzf', toRelativeTarPath(modulesDirectory, fixture.archivePath)], { + cwd: modulesDirectory, timeout: archiveExtractionTimeoutMs, }); fs.renameSync(extractedDirectory, packageDirectory); diff --git a/tests/scripts/eval-harness-package.test.js b/tests/scripts/eval-harness-package.test.js index b29adf69d..5d413ca8c 100644 --- a/tests/scripts/eval-harness-package.test.js +++ b/tests/scripts/eval-harness-package.test.js @@ -8,6 +8,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { getNpmPackEntry } = require('../lib/npm-pack-output'); const { test, tempDir, cleanup, finish, runNpm } = require('../lib/eval-harness/helpers'); +const { toRelativeTarPath } = require('../lib/helpers/windows-test-env'); const repo = path.resolve(__dirname, '../..'); const work = tempDir('package smoke'); @@ -61,7 +62,7 @@ try { assert.ok(archive, 'packing must succeed before extracting'); const extract = path.join(work, 'extracted'); fs.mkdirSync(extract); - const unpack = command('tar', ['-xzf', archive, '-C', extract]); + const unpack = command('tar', ['-xzf', toRelativeTarPath(extract, archive)], { cwd: extract }); assert.strictEqual(unpack.status, 0, unpack.error?.message || unpack.stderr); const installed = path.join(extract, 'package'); assert.ok(!fs.existsSync(path.join(installed, 'node_modules'))); diff --git a/tests/scripts/harness-audit.test.js b/tests/scripts/harness-audit.test.js index c0087475d..c7572777c 100644 --- a/tests/scripts/harness-audit.test.js +++ b/tests/scripts/harness-audit.test.js @@ -635,8 +635,12 @@ function runTests() { ); } + // findPluginInstall also reads USERPROFILE and os.homedir() (USERPROFILE + // on Windows), so isolating HOME alone lets a real install win. const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; try { const found = findPluginInstall(projectRoot); assert.ok(found); @@ -647,6 +651,11 @@ function runTests() { } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } } } finally { cleanup(homeDir); diff --git a/tests/scripts/setup.test.js b/tests/scripts/setup.test.js index babc6d946..715d383c2 100644 --- a/tests/scripts/setup.test.js +++ b/tests/scripts/setup.test.js @@ -5,6 +5,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); +const { withoutShadowingClaude } = require('../lib/helpers/windows-test-env'); const repoRoot = path.join(__dirname, '..', '..'); const setupScript = path.join(repoRoot, 'scripts', 'setup.js'); const eccScript = path.join(repoRoot, 'scripts', 'ecc.js'); @@ -62,7 +63,7 @@ function runSetup(fixture, args, options = {}) { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, CLAUDE_CONFIG_DIR: fixture.configDir, - PATH: options.path || `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`, + PATH: options.path || `${fixture.binDir}${path.delimiter}${withoutShadowingClaude(process.env.PATH)}`, ECC_TEST_CLAUDE_STATE: fixture.statePath, ECC_TEST_CLAUDE_CALLS: fixture.callsPath, ...options.env, @@ -147,7 +148,7 @@ function runInteractiveEccSetup(fixture, options = {}) { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, CLAUDE_CONFIG_DIR: fixture.configDir, - PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`, + PATH: `${fixture.binDir}${path.delimiter}${withoutShadowingClaude(process.env.PATH)}`, ECC_TEST_CLAUDE_STATE: fixture.statePath, ECC_TEST_CLAUDE_CALLS: fixture.callsPath, },