Files
gstack/browse/test/claude-bin.test.ts
T
Garry Tan df9f7b69c9 feat(claude-bin): Bun.which wrapper for cross-platform claude resolution
Replaces 75 LOC of fork-side reimplementation (PATH parsing, Windows PATHEXT,
case-insensitive Path/PATH, X_OK) with a thin wrapper around Bun.which() — the
runtime built-in that already does all of it. New file is ~70 LOC including
the override + arg-prefix logic the runtime doesn't cover.

Override branch fixed: GSTACK_CLAUDE_BIN=wsl now resolves through Bun.which()
just like a bare claude lookup would. The McGluut fork's claude-bin.ts only
handled absolute-path overrides; bare commands silently returned null. Passing
the override value through Bun.which fixes the documented use case for free.

Five hardcoded claude spawn sites rewired through resolveClaudeCommand:
  - browse/src/security-classifier.ts:396 — version probe
  - browse/src/security-classifier.ts:496 — Haiku transcript classifier
  - scripts/preflight-agent-sdk.ts — preflight binary pinning
  - test/helpers/providers/claude.ts — LLM judge availability + run
  - test/helpers/agent-sdk-runner.ts — SDK harness binary resolver
All retain their existing degrade-on-missing semantics.

Tests: browse/test/claude-bin.test.ts has 9 unit tests including the
override-PATH-resolution case the fork's version got wrong.

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

92 lines
3.1 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.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-bin-test-'));
const fakeBin = path.join(tmpDir, 'fake-claude-cli');
fs.writeFileSync(fakeBin, '#!/bin/sh\necho fake\n');
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([]);
});
});