Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ async function run(args: Args): Promise<number> {
return commandUse(args);
case 'sync': {
const sync = await import('../client/sync.ts');
const sub = args.positional[1] ?? 'status';
const sub = args.positional[0] ?? 'status';
const server = flagString(args, 'server', 's');
const options = { ...(server !== undefined ? { server } : {}), userAgent: userAgent() };
const force = Boolean(args.flags['force']);
Expand Down
73 changes: 73 additions & 0 deletions test/cli-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { VERSION } from '../dist/config.js';
import { calls } from './fixtures/cli-sync.ts';

const entry = new URL('../dist/cli/index.js', import.meta.url);
const fixture = new URL('./fixtures/cli-sync.ts', import.meta.url);
const statusOutput = 'here never synced\nboard nothing yet\n';
const savedOutput = 'Saved revision 7: the boards you use.\n';
const plannedOutput = 'added boards.json\nWould take revision 7; nothing written.\n';
const cases = [
{ argv: ['sync'], method: 'status', options: {}, stdout: statusOutput },
{ argv: ['sync', 'status'], method: 'status', options: {}, stdout: statusOutput },
{ argv: ['sync', 'save'], method: 'save', options: { force: false }, stdout: savedOutput },
{ argv: ['sync', 'load'], method: 'load', options: { force: false, dryRun: false }, stdout: 'Loaded revision 7.\n' },
{ argv: ['sync', 'revisions'], method: 'revisions', options: {}, stdout: ' 7 2026-09-15 00:00 fixture 42 bytes\n' },
{ argv: ['sync', 'unknown-action'], method: null, options: {}, stdout: '', exitCode: 1,
stderr: 'Unknown: agenticjobs sync unknown-action. Try status, save, load or revisions.\n' },
{ argv: ['sync', 'save', '--force'], method: 'save', options: { force: true }, stdout: savedOutput },
{ argv: ['sync', 'load', '--dry-run'], method: 'load', options: { force: false, dryRun: true }, stdout: plannedOutput },
{ argv: ['sync', 'load', '--force', '--dry-run'], method: 'load', options: { force: true, dryRun: true }, stdout: plannedOutput },
];

test('compiled CLI dispatches settings sync subcommands and flags', async (t) => {
// Redirect only this CLI import. The compiled entry point and parser run
// unchanged; other tests can still import the real sync module.
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '../client/sync.js' && context.parentURL?.startsWith(`${entry.href}?`)) {
return { url: fixture.href, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
t.after(() => hooks.deregister());
const fetch = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('Unexpected network request in sync dispatch test');
});

// Sequential subtests restore process state before reporting their results.
for (const [index, example] of cases.entries()) {
await t.test(example.argv.join(' '), async (t) => {
const argv = process.argv;
const exitCode = process.exitCode;
t.after(() => {
process.argv = argv;
process.exitCode = exitCode;
calls.length = 0;
});
calls.length = 0;
process.argv = [process.execPath, fileURLToPath(entry), ...example.argv];
process.exitCode = 0;
let stdout = '';
let stderr = '';
t.mock.method(process.stdout, 'write', (chunk) => { stdout += String(chunk); return true; });
t.mock.method(process.stderr, 'write', (chunk) => { stderr += String(chunk); return true; });

// A fresh URL reruns the actual top-level CLI for each invocation.
await import(`${entry.href}?sync-dispatch=${index}`);

assert.equal(process.exitCode, example.exitCode ?? 0, 'exit code');
assert.deepEqual([...calls], example.method === null ? [] : [{
method: example.method,
options: { userAgent: `agenticjobs-cli/${VERSION}`, ...example.options },
}], 'selected sync operation and options');
assert.equal(stdout, example.stdout, 'stdout');
assert.equal(stderr, example.stderr ?? '', 'stderr');
assert.equal(fetch.mock.callCount(), 0, 'no network requests');
});
}
});
28 changes: 28 additions & 0 deletions test/fixtures/cli-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { SyncOptions } from '../../dist/client/sync.js';

type Options = SyncOptions & { force?: boolean; dryRun?: boolean };
export const calls: { method: string; options: Options }[] = [];

export async function syncStatus(options: Options) {
calls.push({ method: 'status', options });
return { marker: null, drifted: [], behind: false };
}

export async function syncSave(options: Options) {
calls.push({ method: 'save', options });
return { status: 'saved', revision: 7 };
}

export async function syncLoad(options: Options) {
calls.push({ method: 'load', options });
return options.dryRun
? { status: 'planned', revision: 7, plan: [{ status: 'added', path: 'boards.json' }] }
: { status: 'loaded', revision: 7, added: [], directoriesAdded: [] };
}

export function syncContext(options: Options) {
return { client: { async revisions() {
calls.push({ method: 'revisions', options });
return [{ revision: 7, savedAt: '2026-09-15T00:00:00.000Z', host: 'fixture', size: 42 }];
} } };
}
Loading