mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
#356 introduced safeRmHomeTree/isUnderTestHome to stop tests from deleting the PRODUCTION ~/codeman-cases tree on platforms where os.homedir() ignores the $HOME override -- but only applied it to the one file caught doing it live. CASES_DIR has no CODEMAN_DATA_DIR-style env override at all, so every other test file's raw rmSync(join(CASES_DIR, ...)) was the same unguarded pattern, just not yet triggered. Routes every CASES_DIR delete in these 10 files through safeRmHomeTree: cli-skill-target, edge-cases, integration-flows, operation-lightspeed, ralph-integration, routes/case-clone-routes, routes/voice-routes, session-cleanup, sse-events, sse-subscription-filter. Also fixes one instance in case-clone-routes.test.ts that mkdirSync'd then rmSync'd a CASES_DIR path directly with no guard at all -- the exact clobbering pattern #356 exists to prevent, found by extending the sweep. Held as a separate commit (and intended as a separate PR once #356 merges) rather than folding into #356 -- keeps the already-checked skinny fix reviewable on its own; this is the same bug class applied broadly, not new functionality. Verified: all 10 files pass (180 tests), npm run typecheck clean.
184 lines
7.8 KiB
TypeScript
184 lines
7.8 KiB
TypeScript
/**
|
|
* @fileoverview Tests for `codeman skill install|uninstall` target resolution
|
|
* (`resolveCliCasePath` / `resolveSkillTargetPath` in src/cli.ts).
|
|
*
|
|
* The linked-cases lookup shipped in 1.14.2 with no guard: before it, `--case`
|
|
* rejected every case linked in from outside `~/codeman-cases` with "Case not
|
|
* found" even though the server resolved the same name fine. These tests pin both
|
|
* halves of that resolution (registry first, cases dir as fallback) and the
|
|
* tolerance rules around a missing or malformed registry.
|
|
*
|
|
* `test/setup.ts` gives this file its own temporary HOME, so `homedir()` and
|
|
* `dataPath()` already point into a per-file fixture: no os mocking needed.
|
|
* Port: N/A (pure path resolution, no server).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { dataPath } from '../src/config/instance.js';
|
|
import { safeRmHomeTree } from './mocks/index.js';
|
|
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
|
|
import { getCasesDir } from '../src/config/cases-dir.js';
|
|
|
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
|
const CASES_DIR = join(homedir(), 'codeman-cases');
|
|
const LINKED_ROOT = join(homedir(), 'elsewhere');
|
|
|
|
/** Where the packaged skill lands under a case. Mirrors `applyAgentSkill()`. */
|
|
function skillDirIn(casePath: string): string {
|
|
return join(casePath, '.claude', 'skills', 'codeman');
|
|
}
|
|
|
|
function writeLinkedCases(content: string): void {
|
|
mkdirSync(dataPath(), { recursive: true });
|
|
writeFileSync(LINKED_CASES_FILE, content, 'utf-8');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
rmSync(LINKED_CASES_FILE, { force: true });
|
|
safeRmHomeTree(CASES_DIR);
|
|
safeRmHomeTree(LINKED_ROOT);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// LINKED_CASES_FILE is dataPath('linked-cases.json') → CODEMAN_DATA_DIR,
|
|
// which test/setup.ts points at a throwaway /tmp dir, so a plain delete is
|
|
// safe here. Only homedir()-derived paths (CASES_DIR/LINKED_ROOT) need the
|
|
// containment gate.
|
|
rmSync(LINKED_CASES_FILE, { force: true });
|
|
safeRmHomeTree(CASES_DIR);
|
|
safeRmHomeTree(LINKED_ROOT);
|
|
});
|
|
|
|
describe('resolveSkillTargetPath (global)', () => {
|
|
it('targets the user-scope skill dir when no --case is given', () => {
|
|
expect(resolveSkillTargetPath({})).toEqual({
|
|
target: join(homedir(), '.claude', 'skills', 'codeman'),
|
|
});
|
|
});
|
|
|
|
it('never consults the linked-cases registry for the global target', () => {
|
|
// A registry entry named after nothing in particular must not divert the
|
|
// global install, which is not case-scoped at all.
|
|
writeLinkedCases(JSON.stringify({ anything: join(LINKED_ROOT, 'anything') }));
|
|
expect(resolveSkillTargetPath({}).target).toBe(join(homedir(), '.claude', 'skills', 'codeman'));
|
|
});
|
|
});
|
|
|
|
describe('resolveCliCasePath / resolveSkillTargetPath (--case)', () => {
|
|
it('resolves a LINKED case through linked-cases.json, not the cases dir', () => {
|
|
// The 1.14.2 regression: a case linked in from outside ~/codeman-cases was
|
|
// resolved to a cases-dir path that does not exist, so install refused it.
|
|
const linkedPath = join(LINKED_ROOT, 'my-repo');
|
|
mkdirSync(linkedPath, { recursive: true });
|
|
writeLinkedCases(JSON.stringify({ 'my-repo': linkedPath }));
|
|
|
|
expect(resolveCliCasePath('my-repo')).toBe(linkedPath);
|
|
expect(resolveSkillTargetPath({ case: 'my-repo' })).toEqual({ target: skillDirIn(linkedPath) });
|
|
expect(existsSync(join(CASES_DIR, 'my-repo'))).toBe(false);
|
|
});
|
|
|
|
it('falls back to the cases dir for a name the registry does not list', () => {
|
|
const casePath = join(CASES_DIR, 'plain-case');
|
|
mkdirSync(casePath, { recursive: true });
|
|
writeLinkedCases(JSON.stringify({ 'other-case': join(LINKED_ROOT, 'other-case') }));
|
|
|
|
expect(resolveCliCasePath('plain-case')).toBe(casePath);
|
|
expect(resolveSkillTargetPath({ case: 'plain-case' })).toEqual({ target: skillDirIn(casePath) });
|
|
});
|
|
|
|
it('reports the resolved path instead of exiting when the case does not exist', () => {
|
|
// process.exit(1) lives in the CLI wrapper on purpose: calling it here would
|
|
// kill the test runner.
|
|
expect(resolveSkillTargetPath({ case: 'nope' })).toEqual({ missingCase: join(CASES_DIR, 'nope') });
|
|
});
|
|
|
|
it('reports the LINKED path when the registry points at a directory that is gone', () => {
|
|
const linkedPath = join(LINKED_ROOT, 'moved-away');
|
|
writeLinkedCases(JSON.stringify({ 'moved-away': linkedPath }));
|
|
|
|
expect(resolveSkillTargetPath({ case: 'moved-away' })).toEqual({ missingCase: linkedPath });
|
|
});
|
|
});
|
|
|
|
describe('linked-cases.json tolerance', () => {
|
|
const casePath = () => join(CASES_DIR, 'tolerant');
|
|
|
|
beforeEach(() => {
|
|
mkdirSync(casePath(), { recursive: true });
|
|
});
|
|
|
|
it('degrades to the cases dir when the registry file is absent', () => {
|
|
expect(existsSync(LINKED_CASES_FILE)).toBe(false);
|
|
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
|
});
|
|
|
|
it('degrades to the cases dir on malformed JSON rather than throwing', () => {
|
|
writeLinkedCases('{ not json at all');
|
|
expect(() => resolveCliCasePath('tolerant')).not.toThrow();
|
|
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
|
});
|
|
|
|
it('degrades to the cases dir when the registry is valid JSON of the wrong shape', () => {
|
|
// A null / array / non-string-valued entry must read as "no linked case",
|
|
// never as a target path.
|
|
for (const body of ['null', '[]', JSON.stringify({ tolerant: 42 }), JSON.stringify({ tolerant: '' })]) {
|
|
writeLinkedCases(body);
|
|
expect(resolveCliCasePath('tolerant')).toBe(casePath());
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('skill command wiring', () => {
|
|
it('registers install and uninstall, both accepting --case and --global', () => {
|
|
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
|
expect(skill).toBeDefined();
|
|
|
|
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
|
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
|
|
|
for (const name of ['install', 'uninstall']) {
|
|
const flags = skill!.commands
|
|
.find((cmd) => cmd.name() === name)!
|
|
.options.map((opt) => opt.long)
|
|
.sort();
|
|
expect(flags).toEqual(['--case', '--global']);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cases dir override (CODEMAN_CASES_PATH)', () => {
|
|
// The Docker Compose deployment points Codeman at a host-absolute bind mount
|
|
// so a Docker case resolves to the same path inside the container and on the
|
|
// host daemon. The override shipped on the server's CASES_DIR only, which left
|
|
// the CLI looking in the home default: `codeman skill install --case <name>`
|
|
// then reported "Case not found" on exactly the deployment it exists for.
|
|
const saved = process.env.CODEMAN_CASES_PATH;
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env.CODEMAN_CASES_PATH;
|
|
else process.env.CODEMAN_CASES_PATH = saved;
|
|
});
|
|
|
|
it('moves the CLI and the server together', () => {
|
|
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
|
|
expect(getCasesDir()).toBe('/srv/codeman-cases');
|
|
expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo'));
|
|
});
|
|
|
|
it('falls back to the home default when unset', () => {
|
|
delete process.env.CODEMAN_CASES_PATH;
|
|
expect(getCasesDir()).toBe(CASES_DIR);
|
|
expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo'));
|
|
});
|
|
|
|
it('still lets a linked case win over the override', () => {
|
|
// The registry lookup runs first, so a case linked in from outside the cases
|
|
// dir keeps resolving to its real location under Compose too.
|
|
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
|
|
writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') }));
|
|
expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked'));
|
|
});
|
|
});
|