Files
Codeman/test/local-model-developer-tools.test.ts
T

153 lines
6.1 KiB
TypeScript
Raw Permalink Normal View History

/**
* @fileoverview Tests for src/local-model/developer-tools.ts: whether this Mac has the
* developer tools a coding agent calls.
*
* Pinned: the ONLY command it ever runs is `/usr/bin/xcode-select -p` (never `git` or
* `python3`, which open a system window on a Mac without the tools), with a timeout; exit 0
* is `present`, any other exit is `missing`, no answer is `unknown`; off macOS nothing is run
* at all; nothing it is given can make it throw; and an answer is reused for a minute, so a
* window that polls once a second does not spawn once a second.
*
* Port: N/A (the runner is injected; nothing is spawned).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
checkDeveloperTools,
createDeveloperToolsCheck,
DEVELOPER_TOOLS_TIMEOUT_MS,
resetDeveloperToolsCache,
} from '../src/local-model/developer-tools.js';
type Run = (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>;
/** A runner that answers `code` and records how it was called. */
function runner(code: number | null): { run: Run; calls: [string, readonly string[], number][] } {
const calls: [string, readonly string[], number][] = [];
return {
calls,
run: async (file, args, timeoutMs) => {
calls.push([file, args, timeoutMs]);
return code;
},
};
}
afterEach(() => {
resetDeveloperToolsCache();
vi.restoreAllMocks();
});
describe('checkDeveloperTools', () => {
it('runs exactly one command: /usr/bin/xcode-select -p, bounded', async () => {
const { run, calls } = runner(0);
expect(await checkDeveloperTools({ platform: 'darwin', run })).toBe('present');
expect(calls).toEqual([['/usr/bin/xcode-select', ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS]]);
expect(DEVELOPER_TOOLS_TIMEOUT_MS).toBe(2_000);
});
it('never names git, python or xcrun, whatever the answer is', async () => {
for (const code of [0, 1, 2, null]) {
const { run, calls } = runner(code);
await checkDeveloperTools({ platform: 'darwin', run });
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe('/usr/bin/xcode-select');
expect(JSON.stringify(calls)).not.toMatch(/git|python|xcrun/);
}
});
it('reads exit 0 as present and any other exit as missing', async () => {
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(0).run })).toBe('present');
// A Mac without the tools: "unable to get active developer directory", exit 2.
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(2).run })).toBe('missing');
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(1).run })).toBe('missing');
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(127).run })).toBe('missing');
});
it('reads no answer as unknown: a timeout, a command that could not be started, a runner that throws', async () => {
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(null).run })).toBe('unknown');
const throwing: Run = async () => {
throw new Error('spawn EACCES');
};
expect(await checkDeveloperTools({ platform: 'darwin', run: throwing })).toBe('unknown');
// An exit code that is no whole number is no answer either.
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(Number.NaN).run })).toBe('unknown');
});
it('runs nothing off macOS and answers unknown', async () => {
for (const platform of ['linux', 'win32', 'freebsd']) {
const { run, calls } = runner(0);
expect(await checkDeveloperTools({ platform, run }), platform).toBe('unknown');
expect(calls).toEqual([]);
}
});
it('spawns nothing with the default runner under vitest', async () => {
// The real `execFile` is never reached here: on a Mac this would otherwise run the command.
expect(await checkDeveloperTools({ platform: 'darwin' })).toBe('unknown');
// And with no argument at all it answers for this machine without throwing.
expect(['present', 'missing', 'unknown']).toContain(await checkDeveloperTools());
});
});
describe('the cached check', () => {
it('reuses an answer for a minute, then asks again', async () => {
const { run, calls } = runner(2);
let clock = 1_000_000;
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => clock });
expect(await check()).toBe('missing');
// A window that polls once a second for a minute.
for (let second = 1; second < 60; second++) {
clock += 1_000;
expect(await check()).toBe('missing');
}
expect(calls).toHaveLength(1);
clock += 1_000;
expect(await check()).toBe('missing');
expect(calls).toHaveLength(2);
});
it('shares one run among callers that ask at the same time', async () => {
let release: (code: number) => void = () => undefined;
let started = 0;
const run: Run = () => {
started += 1;
return new Promise((resolve) => {
release = resolve;
});
};
const check = createDeveloperToolsCheck({ platform: 'darwin', run });
const asked = [check(), check(), check()];
await Promise.resolve();
expect(started).toBe(1);
release(0);
expect(await Promise.all(asked)).toEqual(['present', 'present', 'present']);
});
it('does not remember "unknown": a check that did not work is tried again', async () => {
let calls = 0;
// No answer the first time, exit 0 from then on.
const run: Run = async () => (calls++ === 0 ? null : 0);
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => 5 });
expect(await check()).toBe('unknown');
expect(await check()).toBe('present');
expect(await check()).toBe('present');
expect(calls).toBe(2);
});
it('sees the tools once they were installed, after the minute', async () => {
const answers = [2, 0];
let calls = 0;
let clock = 0;
const check = createDeveloperToolsCheck({
platform: 'darwin',
run: async () => answers[calls++] ?? 0,
now: () => clock,
});
expect(await check()).toBe('missing');
clock = 59_999;
expect(await check()).toBe('missing');
clock = 60_000;
expect(await check()).toBe('present');
});
});