Skip to content

Commit eeb8a53

Browse files
0xmariowuclaude
andcommitted
test(unit): deterministic postinstall detection test + drop fragile CI sim
The Windows-only CI simulation step kept breaking because Git Bash and cmd.exe interpret PATH differently (';' vs ':' separators), and the stubbed claude.cmd was not reliably discoverable by node's execSync → cmd.exe → where.exe chain. Replace it with a pure-Node unit test that exercises all 5 detection branches by mocking child_process.execSync and overriding process.platform, running in a spawned child process so the shipped postinstall.js is tested without modification: Scenarios (all pass locally, 5/5): - linux + claude missing → exit 1, "Claude Code not found" - darwin + claude present → exit nonzero (real network failure) - win32 + claude missing → exit 1, "Claude Code not found" - win32 + claude OK, bash miss → exit 1, "Git for Windows|WSL" - win32 + claude OK, bash OK → exit nonzero (real network failure) CI changes: - test job: run tests/unit/test-postinstall-detection.js on every OS × Node combo (6 runs). Identical behavior on every runner. - npm-e2e job: keep Path A (tarball inspection) and the clean-runner claude-missing assertion. Remove the Windows PATH-simulation step that was causing the failures on run 24491268716. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dea3252 commit eeb8a53

3 files changed

Lines changed: 166 additions & 35 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
shell: bash
8080
run: bash tests/windows/smoke.sh
8181

82+
- name: Run postinstall detection unit test
83+
shell: bash
84+
run: node tests/unit/test-postinstall-detection.js
85+
8286
- name: Run full Linux test suite
8387
if: runner.os == 'Linux'
8488
shell: bash
@@ -205,38 +209,3 @@ jobs:
205209
echo "--- end ---"
206210
test "$code" -eq 1
207211
echo "$output" | grep -qi "Claude Code not found"
208-
209-
# Path B (Windows only): stub a fake claude.cmd on PATH so the first
210-
# check passes, then give the node subprocess a PATH with no bash so
211-
# the Windows bash-detection branch fires. System32 stays on PATH so
212-
# the stubbed claude.cmd and `where.exe` are both reachable. We
213-
# invoke node by absolute path so Git Bash's command lookup (which
214-
# uses Unix-style PATH with `:` separators) is not affected by our
215-
# Windows-style semicolon PATH override.
216-
- name: Path B — simulate bash-missing on Windows only
217-
if: runner.os == 'Windows'
218-
shell: bash
219-
run: |
220-
STUB_DIR="$RUNNER_TEMP/stub-bin"
221-
mkdir -p "$STUB_DIR"
222-
cat > "$STUB_DIR/claude.cmd" <<'EOF'
223-
@echo off
224-
echo stub-claude
225-
EOF
226-
NODE_UNIX="$(which node)"
227-
NODE_DIR_WIN="$(cygpath -w "$(dirname "$NODE_UNIX")")"
228-
STUB_WIN="$(cygpath -w "$STUB_DIR")"
229-
# Windows-style PATH: stub dir + node dir + System32, intentionally
230-
# omitting C:\Program Files\Git\bin so bash --version fails.
231-
SAFE_PATH="$STUB_WIN;$NODE_DIR_WIN;C:\\Windows\\System32;C:\\Windows"
232-
set +e
233-
output=$(PATH="$SAFE_PATH" "$NODE_UNIX" npm/postinstall.js 2>&1)
234-
code=$?
235-
set -e
236-
echo "exit=$code"
237-
echo "--- output ---"
238-
echo "$output"
239-
echo "--- end ---"
240-
test "$code" -eq 1
241-
echo "$output" | grep -qi "bash"
242-
echo "$output" | grep -qi "Git for Windows\\|WSL"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
const assert = require('node:assert/strict');
5+
const path = require('node:path');
6+
const { spawnSync } = require('node:child_process');
7+
8+
const postinstallPath = path.join(__dirname, '..', '..', 'npm', 'postinstall.js');
9+
10+
const scenarios = [
11+
{
12+
name: 'linux-claude-missing',
13+
platform: 'linux',
14+
mocks: { claude: 'fail' },
15+
expectExit: 1,
16+
expectStdout: /^$/,
17+
expectStderr: /Claude Code not found/i,
18+
},
19+
{
20+
name: 'darwin-claude-present-download-fails',
21+
platform: 'darwin',
22+
mocks: { claude: 'ok', download: 'fail' },
23+
expectExit: 1,
24+
expectStdout: /Downloading AgentLint installer\.\.\./,
25+
expectStderr: /Installation failed: mock download failed/i,
26+
},
27+
{
28+
name: 'win32-claude-missing',
29+
platform: 'win32',
30+
mocks: { claude: 'fail' },
31+
expectExit: 1,
32+
expectStdout: /^$/,
33+
expectStderr: /Claude Code not found/i,
34+
},
35+
{
36+
name: 'win32-claude-present-bash-missing',
37+
platform: 'win32',
38+
mocks: { claude: 'ok', bash: 'fail' },
39+
expectExit: 1,
40+
expectStdout: /^$/,
41+
expectStderr: /Git for Windows.*WSL|WSL.*Git for Windows/is,
42+
},
43+
{
44+
name: 'win32-claude-present-bash-present-download-fails',
45+
platform: 'win32',
46+
mocks: { claude: 'ok', bash: 'ok', download: 'fail' },
47+
expectExit: 1,
48+
expectStdout: /Downloading AgentLint installer\.\.\./,
49+
expectStderr: /Installation failed: mock download failed/i,
50+
},
51+
];
52+
53+
const bootstrap = `
54+
'use strict';
55+
const { EventEmitter } = require('node:events');
56+
const childProcess = require('node:child_process');
57+
const https = require('node:https');
58+
59+
const scenario = JSON.parse(process.env.AL_SCENARIO);
60+
61+
Object.defineProperty(process, 'platform', {
62+
value: scenario.platform,
63+
configurable: true,
64+
});
65+
66+
childProcess.execSync = function mockExecSync(command) {
67+
const mocks = scenario.mocks || {};
68+
69+
if (typeof command === 'string') {
70+
if (command === 'where claude' || command === 'command -v claude') {
71+
if (mocks.claude === 'fail') {
72+
const error = new Error('mock claude missing');
73+
error.status = 1;
74+
throw error;
75+
}
76+
return Buffer.from('stub-claude\\n');
77+
}
78+
79+
if (command === 'bash --version') {
80+
if (mocks.bash === 'fail') {
81+
const error = new Error('mock bash missing');
82+
error.status = 127;
83+
throw error;
84+
}
85+
return Buffer.from('GNU bash, version 5.2.0\\n');
86+
}
87+
88+
if (command.startsWith('bash ')) {
89+
if (mocks.installer === 'fail') {
90+
const error = new Error('mock installer failed');
91+
error.status = 1;
92+
throw error;
93+
}
94+
return Buffer.from('');
95+
}
96+
}
97+
98+
throw new Error('unexpected execSync command: ' + command);
99+
};
100+
101+
https.get = function mockGet(url, callback) {
102+
const request = new EventEmitter();
103+
104+
process.nextTick(() => {
105+
const mocks = scenario.mocks || {};
106+
107+
if (mocks.download === 'fail') {
108+
request.emit('error', new Error('mock download failed'));
109+
return;
110+
}
111+
112+
const response = new EventEmitter();
113+
response.statusCode = 200;
114+
response.headers = {};
115+
callback(response);
116+
process.nextTick(() => {
117+
response.emit('data', Buffer.from('#!/usr/bin/env bash\\necho mocked\\n'));
118+
response.emit('end');
119+
});
120+
});
121+
122+
return request;
123+
};
124+
125+
require(process.env.AL_POSTINSTALL);
126+
`;
127+
128+
let passed = 0;
129+
130+
for (const scenario of scenarios) {
131+
const result = spawnSync(process.execPath, ['-e', bootstrap], {
132+
encoding: 'utf8',
133+
env: {
134+
...process.env,
135+
AL_POSTINSTALL: postinstallPath,
136+
AL_SCENARIO: JSON.stringify(scenario),
137+
},
138+
});
139+
140+
try {
141+
assert.equal(
142+
result.status,
143+
scenario.expectExit,
144+
`expected exit ${scenario.expectExit}, got ${result.status}`
145+
);
146+
assert.match(result.stdout || '', scenario.expectStdout);
147+
assert.match(result.stderr || '', scenario.expectStderr);
148+
process.stdout.write(`PASS: ${scenario.name}\n`);
149+
passed += 1;
150+
} catch (error) {
151+
process.stdout.write(`FAIL: ${scenario.name}\n`);
152+
process.stdout.write(`${error.stack}\n`);
153+
process.stdout.write('stdout:\n');
154+
process.stdout.write(`${result.stdout || ''}\n`);
155+
process.stdout.write('stderr:\n');
156+
process.stdout.write(`${result.stderr || ''}\n`);
157+
}
158+
}
159+
160+
process.stdout.write(`${passed}/${scenarios.length} scenarios passed\n`);
161+
process.exit(passed === scenarios.length ? 0 : 1);

tests/windows/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ The test creates isolated temporary git fixtures, then exercises `scanner.sh`, `
66
It also checks that key shell scripts were not checked out with CRLF endings.
77
When the fixer exposes a safe create-file path, the script verifies the generated file uses LF line endings.
88
On Git Bash, it reruns the scanner with a Windows-style path to confirm path-form tolerance.
9+
`tests/unit/test-postinstall-detection.js` is the canonical test for `npm/postinstall.js` detection branches; the old Windows-only CI PATH simulation was removed in favor of this deterministic unit test.
910

1011
Run it locally with:
1112
`bash tests/windows/smoke.sh`

0 commit comments

Comments
 (0)