All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
7.3 KiB
TypeScript
232 lines
7.3 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Command } from 'commander';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { registerFleetMigrationCommand } from './fleet-migration-command.js';
|
|
|
|
let cleanup: string | undefined;
|
|
|
|
afterEach(async (): Promise<void> => {
|
|
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
|
cleanup = undefined;
|
|
});
|
|
|
|
describe('fleet migrate-v1 preview command', (): void => {
|
|
it('emits one ready JSON result without any runtime or file mutation API', async () => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
|
|
const rolesDir = join(cleanup, 'roles');
|
|
const overrideDir = join(cleanup, 'roles.local');
|
|
await mkdir(rolesDir);
|
|
await mkdir(overrideDir);
|
|
await writeFile(join(rolesDir, 'code.md'), '# code\n');
|
|
const files: Record<string, string> = {
|
|
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
|
|
decisions: JSON.stringify({
|
|
generation: 2,
|
|
defaultRuntime: 'pi',
|
|
agents: {
|
|
coder0: {
|
|
provider: 'openai',
|
|
model: 'gpt-5.6-sol',
|
|
reasoning: 'high',
|
|
enabled: true,
|
|
launchYolo: false,
|
|
toolPolicyDisposition: { action: 'replace', className: 'code' },
|
|
},
|
|
},
|
|
}),
|
|
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
|
|
};
|
|
const printJson = vi.fn();
|
|
const setExitCode = vi.fn();
|
|
const program = new Command();
|
|
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
|
|
registerFleetMigrationCommand(fleet, {
|
|
mosaicHome: cleanup,
|
|
rolesDir,
|
|
overrideDir,
|
|
readText: async (path): Promise<string> => files[path] ?? '',
|
|
printJson,
|
|
setExitCode,
|
|
});
|
|
|
|
await program.parseAsync([
|
|
'node',
|
|
'test',
|
|
'fleet',
|
|
'migrate-v1',
|
|
'preview',
|
|
'--source',
|
|
'source',
|
|
'--decisions',
|
|
'decisions',
|
|
'--observations',
|
|
'observations',
|
|
]);
|
|
|
|
expect(printJson).toHaveBeenCalledTimes(1);
|
|
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
|
|
expect(setExitCode).not.toHaveBeenCalled();
|
|
expect(fleet.helpInformation()).toContain('migrate-v1');
|
|
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
|
|
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
|
|
});
|
|
|
|
it('emits one stable blocked JSON result when --observations is missing', async () => {
|
|
const printJson = vi.fn();
|
|
const setExitCode = vi.fn();
|
|
const program = new Command().exitOverride();
|
|
program.configureOutput({
|
|
writeErr: vi.fn(),
|
|
writeOut: vi.fn(),
|
|
});
|
|
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
registerFleetMigrationCommand(fleet, {
|
|
mosaicHome: '/unused',
|
|
readText: vi.fn(),
|
|
printJson,
|
|
setExitCode,
|
|
});
|
|
|
|
await expect(
|
|
program.parseAsync([
|
|
'node',
|
|
'test',
|
|
'fleet',
|
|
'migrate-v1',
|
|
'preview',
|
|
'--source',
|
|
'source',
|
|
'--decisions',
|
|
'decisions',
|
|
]),
|
|
).resolves.toBe(program);
|
|
expect(printJson).toHaveBeenCalledTimes(1);
|
|
expect(printJson).toHaveBeenCalledWith({
|
|
status: 'blocked',
|
|
blockers: [
|
|
{
|
|
code: 'missing-migration-preview-option',
|
|
path: 'request.observations',
|
|
detail: 'Required migration preview option is missing.',
|
|
},
|
|
],
|
|
});
|
|
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it.each(['source', 'decisions', 'observations'] as const)(
|
|
'emits one stable blocked JSON result when --%s is present-empty',
|
|
async (emptyOption) => {
|
|
const printJson = vi.fn();
|
|
const setExitCode = vi.fn();
|
|
const readText = vi.fn();
|
|
const program = new Command().exitOverride();
|
|
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
|
|
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
registerFleetMigrationCommand(fleet, {
|
|
mosaicHome: '/unused',
|
|
readText,
|
|
printJson,
|
|
setExitCode,
|
|
});
|
|
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
|
|
paths[emptyOption] = '';
|
|
|
|
await expect(
|
|
program.parseAsync([
|
|
'node',
|
|
'test',
|
|
'fleet',
|
|
'migrate-v1',
|
|
'preview',
|
|
`--source=${paths.source}`,
|
|
`--decisions=${paths.decisions}`,
|
|
`--observations=${paths.observations}`,
|
|
]),
|
|
).resolves.toBe(program);
|
|
|
|
expect(printJson).toHaveBeenCalledTimes(1);
|
|
expect(printJson).toHaveBeenCalledWith({
|
|
status: 'blocked',
|
|
blockers: [
|
|
{
|
|
code: 'empty-migration-preview-option',
|
|
path: `request.${emptyOption}`,
|
|
detail: 'Required migration preview option must be a non-empty path.',
|
|
},
|
|
],
|
|
});
|
|
expect(setExitCode).toHaveBeenCalledTimes(1);
|
|
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
expect(readText).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
|
|
const printJson = vi.fn();
|
|
const setExitCode = vi.fn();
|
|
const program = new Command();
|
|
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
registerFleetMigrationCommand(fleet, {
|
|
mosaicHome: '/unused',
|
|
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
|
|
printJson,
|
|
setExitCode,
|
|
});
|
|
await program.parseAsync([
|
|
'node',
|
|
'test',
|
|
'fleet',
|
|
'migrate-v1',
|
|
'preview',
|
|
'--source',
|
|
'source',
|
|
'--decisions',
|
|
'decisions',
|
|
'--observations',
|
|
'observations',
|
|
]);
|
|
expect(printJson).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: 'blocked',
|
|
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
|
|
}),
|
|
);
|
|
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('redacts adversarial values from validation failures', async () => {
|
|
const secret = 'never-print-command-or-token';
|
|
const printJson = vi.fn();
|
|
const setExitCode = vi.fn();
|
|
const program = new Command();
|
|
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
registerFleetMigrationCommand(fleet, {
|
|
mosaicHome: '/unused',
|
|
readText: async (path): Promise<string> =>
|
|
path === 'decisions'
|
|
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
|
|
: '{}',
|
|
printJson,
|
|
setExitCode,
|
|
});
|
|
await program.parseAsync([
|
|
'node',
|
|
'test',
|
|
'fleet',
|
|
'migrate-v1',
|
|
'preview',
|
|
'--source',
|
|
'source',
|
|
'--decisions',
|
|
'decisions',
|
|
'--observations',
|
|
'observations',
|
|
]);
|
|
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
|
|
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|