mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 05:57:40 +02:00
test: stop Windows developer tooling from leaking into setup, tar, and audit tests
On a Windows machine with Claude Code installed natively, Git for Windows,
and the ECC plugin installed, `node tests/run-all.js` on main fails 44 tests
in 7 files. Linux CI never sees them. Three of the four causes are test
isolation gaps, fixed here (43 tests):
- claude-plugin-setup, claude-scope-migration, setup (38): fixtures put a
fake claude.cmd first on PATH, but on Windows spawning a bare `claude`
resolves only .com/.exe, so a native ~/.local/bin/claude.exe ran instead
of the fake. Fixture PATHs now drop entries holding claude.exe/claude.com.
- ecc-universal-bin, eval-harness-package (4): tar received an absolute
C:\ archive path, which Git Bash's GNU tar reads as a host:path remote
spec ("Cannot connect to C: resolve failed"). Extract from inside the
destination with a relative, forward-slash archive name, which both GNU
tar and bsdtar accept.
- harness-audit (1): the cache-layout test overrode HOME only, while
findPluginInstall also reads USERPROFILE and os.homedir(), so the real
installed plugin won over the fixture. Override USERPROFILE too.
The two helpers live in tests/lib/helpers/windows-test-env.js with a
platform-independent test, so Linux CI holds both invariants too.
The 44th failure, plugin-hook-bootstrap's no-bash test, is a production
bug rather than a test leak: System32\bash.exe launches an installed WSL
distro, which passes the bash probe. It gets a separate fix.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user