test/command-path.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resolveExecutable, isUsableCwd, commandExists, missingCommandMessage } from '../server/command-path.js';
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
import { join, delimiter } from 'path';
import { tmpdir } from 'os';
import { removeTempDir } from './temp-dir.js';
// CreateProcessW only ever appends `.exe`, so a command installed as a `.cmd`
// shim (`claude`, `aider`, anything from npm) has to be resolved to its real
// filename before node-pty sees it. Getting this wrong crashes the whole
// server asynchronously, so the resolution rules get covered directly.
//
// These run on the Linux CI runner too, with platform forced to 'win32' — the
// reason resolveExecutable reads Windows' `;` PATH separator from a constant
// instead of from `path.delimiter`.
describe('resolveExecutable on Windows', () => {
let base, binDir, otherDir;
const env = () => ({
PATH: [binDir, otherDir].join(';'),
PATHEXT: '.COM;.EXE;.BAT;.CMD',
});
beforeAll(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'a007-cmdpath-')));
binDir = join(base, 'bin');
otherDir = join(base, 'other');
mkdirSync(binDir);
mkdirSync(otherDir);
// The npm shape: an extensionless bash shim next to the launchable .cmd.
writeFileSync(join(binDir, 'claude'), '#!/bin/sh\n');
writeFileSync(join(binDir, 'claude.cmd'), '@echo off\n');
// Same basename in both PATH entries, to pin the search order.
writeFileSync(join(otherDir, 'claude.cmd'), '@echo off\n');
writeFileSync(join(otherDir, 'tool.exe'), 'MZ');
// Both extensions in one directory, to pin the PATHEXT order.
writeFileSync(join(binDir, 'dual.cmd'), '@echo off\n');
writeFileSync(join(binDir, 'dual.exe'), 'MZ');
// Sitting in the working directory, where nothing should look.
writeFileSync(join(base, 'hijack.cmd'), '@echo off\n');
});
afterAll(() => { removeTempDir(base); });
const resolve = (file, cwd = base) => resolveExecutable(file, env(), 'win32', cwd);
it('skips the extensionless shim for the .cmd CreateProcessW can launch', () => {
// The original bug: node-pty found the shim, Windows then looked for
// claude.exe and failed with error code 2.
expect(resolve('claude')).toBe(join(binDir, 'claude.cmd'));
});
it('finds an .exe further down PATH', () => {
expect(resolve('tool')).toBe(join(otherDir, 'tool.exe'));
});
it('honours PATHEXT order within a directory', () => {
expect(resolve('dual')).toBe(join(binDir, 'dual.exe'));
});
it('never searches the working directory, so a repo cannot hijack the command', () => {
// cwd is the agent's worktree. cmd.exe would run ./hijack.cmd here;
// child_process and PowerShell would not, and neither do we.
expect(resolve('hijack')).toBe(null);
});
it('uses a command that already carries a launchable extension verbatim', () => {
expect(resolve('claude.cmd')).toBe(join(binDir, 'claude.cmd'));
});
it('resolves an absolute path without consulting PATH', () => {
expect(resolve(join(otherDir, 'tool'))).toBe(join(otherDir, 'tool.exe'));
expect(resolve(join(otherDir, 'tool.exe'))).toBe(join(otherDir, 'tool.exe'));
});
it('does not fall back to PATH for a path-shaped command', () => {
expect(resolve(join(otherDir, 'dual'))).toBe(null);
});
it('returns null when nothing matches, so the caller keeps the bare name', () => {
expect(resolve('definitely-not-installed')).toBe(null);
});
it('leaves non-Windows platforms to execvp', () => {
expect(resolveExecutable('claude', env(), 'linux', base)).toBe(null);
expect(resolveExecutable('claude', env(), 'darwin', base)).toBe(null);
});
it('handles an empty command', () => {
expect(resolveExecutable('', env(), 'win32', base)).toBe(null);
});
});
describe('isUsableCwd', () => {
let base;
beforeAll(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'a007-cwd-')));
writeFileSync(join(base, 'file.txt'), 'x');
});
afterAll(() => { removeTempDir(base); });
it('accepts an existing directory', () => {
expect(isUsableCwd(base)).toBe(true);
});
// A deleted worktree is the re-spawn failure: CreateProcessW rejects it with
// error code 267 from the console host, far too late for a try/catch.
it('rejects a missing directory', () => {
expect(isUsableCwd(join(base, 'gone'))).toBe(false);
});
it('rejects a file and a blank path', () => {
expect(isUsableCwd(join(base, 'file.txt'))).toBe(false);
expect(isUsableCwd('')).toBe(false);
expect(isUsableCwd(null)).toBe(false);
});
});
// A missing CLI must be caught before the spawn: on macOS and Linux node-pty
// "starts" it and the tab is an empty, dead terminal.
describe('commandExists', () => {
let bin;
beforeAll(() => {
bin = realpathSync(mkdtempSync(join(tmpdir(), 'a007-cmdexists-')));
writeFileSync(join(bin, 'claude'), '#!/bin/sh\n', { mode: 0o755 });
writeFileSync(join(bin, 'notes'), 'not a program', { mode: 0o644 });
mkdirSync(join(bin, 'codex'));
});
afterAll(() => removeTempDir(bin));
it.skipIf(process.platform === 'win32')('finds an executable on PATH, and nothing else', () => {
const env = { PATH: ['/no/such/dir', bin].join(delimiter) };
expect(commandExists('claude', env, 'linux')).toBe(true);
expect(commandExists('gemini', env, 'linux')).toBe(false);
expect(commandExists('notes', env, 'linux')).toBe(false); // not executable
expect(commandExists('codex', env, 'linux')).toBe(false); // a directory
expect(commandExists('claude', {}, 'linux')).toBe(false); // no PATH at all
expect(commandExists('', env, 'linux')).toBe(false);
});
it.skipIf(process.platform === 'win32')('takes a path as a path, relative to the working directory', () => {
expect(commandExists(join(bin, 'claude'), {}, 'linux')).toBe(true);
expect(commandExists('./claude', {}, 'linux', bin)).toBe(true);
expect(commandExists('./gemini', {}, 'linux', bin)).toBe(false);
});
it('uses the Windows lookup on Windows', () => {
writeFileSync(join(bin, 'codex.cmd'), '@echo off\n');
expect(commandExists('codex', { PATH: bin, PATHEXT: '.CMD' }, 'win32')).toBe(true);
expect(commandExists('claude', { PATH: bin, PATHEXT: '.CMD' }, 'win32')).toBe(false);
});
it('says how to install the CLIs it knows', () => {
expect(missingCommandMessage('claude')).toMatch(/"claude" is not installed.*Install Claude Code/);
expect(missingCommandMessage('codex')).toMatch(/npm install -g @openai\/codex/);
expect(missingCommandMessage('gh')).toMatch(/https:\/\/cli\.github\.com/);
expect(missingCommandMessage('aider')).toBe('"aider" is not installed, or not on the PATH Agent 007 was started with.');
});
});