Files
gstack/browse/test/claude-bin.test.ts
T
Garry Tan 900a619f31 fix(windows-ci): platform-aware claude-bin test + curate bin/ shebang spawns
Round 3 of windows-free-tests fixes. Round 2 (LF gitattributes + server-node.mjs
build) cleared shard 1 entirely (skill-collision-sentinel and tab-isolation
green). Shard 2 surfaced two more issues:

1. browse/test/claude-bin.test.ts:50 — the "PATH-resolvable override" test
   creates a fake binary 'fake-claude-cli' (no extension) and expects
   Bun.which to find it. On Windows, Bun.which probes PATHEXT extensions
   (.cmd, .exe, .bat) — a bare-name file is not discoverable. Production
   behavior is correct; the test was Mac/Linux-shaped.

   Fix: branch on process.platform. On Windows, write 'fake-claude-cli.cmd'
   with a Windows batch payload instead of a POSIX shebang script.

2. test/gstack-question-log.test.ts (and 18 sibling tests) — spawn a bash
   shebang script via spawnSync(BIN, args). Git Bash on Windows can run
   `bash /path/to/script` but spawnSync invokes CreateProcess directly,
   which doesn't parse #!/usr/bin/env bash. All these tests are
   Windows-fragile and can't run as-is.

   Fix: extend WINDOWS_FRAGILE_PATTERNS with `path.join(.., 'bin', ..)`
   detector. Curates 19 additional tests (benchmark-cli, brain-sync,
   builder-profile, explain-level-config, gbrain-*, gstack-question-*,
   hook-scripts, learnings, plan-tune, review-log, secret-sink-harness,
   taste-engine, telemetry, timeline, uninstall).

Curated Windows subset: 95 → 76 tests (~59% of free suite). Still
meaningful Windows coverage. The 52 excluded tests are tracked as a
follow-up TODO for full Windows parity (shebang-bin spawns + POSIX file
modes + raw /tmp/ etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:57:13 -07:00

96 lines
3.4 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { resolveClaudeCommand, resolveClaudeBinary } from '../src/claude-bin';
// Empty env baseline — no PATH, no overrides — ensures no environmental claude binary leaks in.
const EMPTY_ENV = { PATH: '', Path: '' } as NodeJS.ProcessEnv;
describe('claude-bin', () => {
test('no override, no PATH match → returns null', () => {
expect(resolveClaudeCommand(EMPTY_ENV)).toBeNull();
expect(resolveClaudeBinary(EMPTY_ENV)).toBeNull();
});
test('absolute-path override returned as-is', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
});
expect(got).toEqual({ command: '/opt/custom/claude', argsPrefix: [] });
});
test('CLAUDE_BIN works as fallback alias for GSTACK_CLAUDE_BIN', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
CLAUDE_BIN: '/opt/custom/claude',
});
expect(got?.command).toBe('/opt/custom/claude');
});
test('GSTACK_CLAUDE_BIN takes precedence over CLAUDE_BIN', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: '/explicit/path',
CLAUDE_BIN: '/fallback/path',
});
expect(got?.command).toBe('/explicit/path');
});
test('PATH-resolvable override goes through Bun.which (the bug the fork shipped)', () => {
// Make a fake binary in a temp dir, point PATH at it, set override to bare command name.
// Windows requires the file to have a PATHEXT-listed extension to be discoverable
// via Bun.which — without the extension Bun.which returns undefined.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-bin-test-'));
const isWindows = process.platform === 'win32';
const fakeBinName = isWindows ? 'fake-claude-cli.cmd' : 'fake-claude-cli';
const fakeBin = path.join(tmpDir, fakeBinName);
fs.writeFileSync(fakeBin, isWindows ? '@echo fake\r\n' : '#!/bin/sh\necho fake\n');
if (!isWindows) fs.chmodSync(fakeBin, 0o755);
try {
const got = resolveClaudeCommand({
PATH: tmpDir,
GSTACK_CLAUDE_BIN: 'fake-claude-cli',
});
expect(got?.command).toBe(fakeBin);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('override pointing at missing binary → null (no silent fallback to bare claude)', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: 'definitely-not-a-real-binary-xyz',
});
expect(got).toBeNull();
});
test('GSTACK_CLAUDE_BIN_ARGS as JSON array → parsed argsPrefix', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
GSTACK_CLAUDE_BIN_ARGS: '["--no-cache", "--verbose"]',
});
expect(got?.argsPrefix).toEqual(['--no-cache', '--verbose']);
});
test('GSTACK_CLAUDE_BIN_ARGS as scalar string → treated as single argument', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
GSTACK_CLAUDE_BIN_ARGS: 'claude',
});
expect(got?.argsPrefix).toEqual(['claude']);
});
test('argsPrefix empty when no override args set', () => {
const got = resolveClaudeCommand({
...EMPTY_ENV,
GSTACK_CLAUDE_BIN: '/opt/custom/claude',
});
expect(got?.argsPrefix).toEqual([]);
});
});