Add bd exec bridge and mutation API routes with tests
This commit is contained in:
parent
0e3815ac3c
commit
2c80265258
15 changed files with 904 additions and 5 deletions
55
tests/api/mutations-routes.test.ts
Normal file
55
tests/api/mutations-routes.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { POST as createPost } from '../../src/app/api/beads/create/route';
|
||||
import { POST as reopenPost } from '../../src/app/api/beads/reopen/route';
|
||||
import { POST as commentPost } from '../../src/app/api/beads/comment/route';
|
||||
|
||||
async function readJson(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
test('create route returns 400 for invalid payload', async () => {
|
||||
const response = await createPost(
|
||||
new Request('http://localhost/api/beads/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: '', title: '' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
assert.equal(data.error.classification, 'bad_args');
|
||||
});
|
||||
|
||||
test('reopen route returns 400 for missing id', async () => {
|
||||
const response = await reopenPost(
|
||||
new Request('http://localhost/api/beads/reopen', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: 'C:/repo' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
});
|
||||
|
||||
test('comment route returns 400 for missing comment text', async () => {
|
||||
const response = await commentPost(
|
||||
new Request('http://localhost/api/beads/comment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: 'C:/repo', id: 'bb-1' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
assert.equal(typeof data.error.message, 'string');
|
||||
});
|
||||
43
tests/lib/bd-path.test.ts
Normal file
43
tests/lib/bd-path.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from '../../src/lib/bd-path';
|
||||
|
||||
test('resolveBdExecutable prefers explicit configured path when provided', async () => {
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-'));
|
||||
const explicit = path.join(temp, 'tools', 'bd.exe');
|
||||
await fs.mkdir(path.dirname(explicit), { recursive: true });
|
||||
await fs.writeFile(explicit, '');
|
||||
|
||||
const resolved = await resolveBdExecutable({ explicitPath: explicit, env: { Path: '', NODE_ENV: 'test' } });
|
||||
|
||||
assert.equal(resolved.executable, explicit);
|
||||
assert.equal(resolved.source, 'config');
|
||||
});
|
||||
|
||||
test('resolveBdExecutable finds bd.exe on PATH when explicit path is not set', async () => {
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-env-'));
|
||||
const candidate = path.join(temp, 'bd.exe');
|
||||
await fs.writeFile(candidate, '');
|
||||
|
||||
const resolved = await resolveBdExecutable({ env: { Path: temp, NODE_ENV: 'test' } });
|
||||
|
||||
assert.equal(resolved.executable, candidate);
|
||||
assert.equal(resolved.source, 'path');
|
||||
});
|
||||
|
||||
test('resolveBdExecutable throws actionable setup guidance when executable is missing', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveBdExecutable({ env: { Path: '', NODE_ENV: 'test' } }),
|
||||
(error: unknown) => {
|
||||
assert.equal(error instanceof BdExecutableNotFoundError, true);
|
||||
const message = String((error as Error).message).toLowerCase();
|
||||
assert.equal(message.includes('npm install -g @beads/bd'), true);
|
||||
assert.equal(message.includes('bd.exe'), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
86
tests/lib/bridge.test.ts
Normal file
86
tests/lib/bridge.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { runBdCommand } from '../../src/lib/bridge';
|
||||
|
||||
test('runBdCommand returns structured success payload from execFile output', async () => {
|
||||
const result = await runBdCommand(
|
||||
{
|
||||
projectRoot: 'C:/repo/project',
|
||||
args: ['list', '--json'],
|
||||
timeoutMs: 2000,
|
||||
explicitBdPath: 'C:/tools/bd.exe',
|
||||
},
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async (command, args, options) => {
|
||||
assert.equal(command, 'C:/tools/bd.exe');
|
||||
assert.deepEqual(args, ['list', '--json']);
|
||||
assert.equal(options.cwd, 'C:/repo/project');
|
||||
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.classification, null);
|
||||
assert.equal(result.stdout, '[{"id":"bb-1"}]');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies missing executable as not_found', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['list'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'not_found');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies timeout failures', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
|
||||
error.code = 'ETIMEDOUT';
|
||||
error.killed = true;
|
||||
error.signal = 'SIGTERM';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'timeout');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies non-zero bad-argument exits', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
(error as any).code = 1;
|
||||
error.stderr = 'unknown flag: --bad-flag';
|
||||
error.stdout = '';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'bad_args');
|
||||
});
|
||||
101
tests/lib/mutations.test.ts
Normal file
101
tests/lib/mutations.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
MutationValidationError,
|
||||
buildBdMutationArgs,
|
||||
validateMutationPayload,
|
||||
executeMutation,
|
||||
type MutationOperation,
|
||||
} from '../../src/lib/mutations';
|
||||
|
||||
const root = 'C:/Users/Zenchant/codex/beadboard';
|
||||
|
||||
test('validateMutationPayload rejects invalid payloads', () => {
|
||||
assert.throws(
|
||||
() => validateMutationPayload('create', { projectRoot: '', title: '' }),
|
||||
(error: unknown) => error instanceof MutationValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps reopen correctly', () => {
|
||||
const payload = validateMutationPayload('reopen', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
reason: 'retry work',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('reopen', payload);
|
||||
assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps comment correctly', () => {
|
||||
const payload = validateMutationPayload('comment', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
text: 'Added notes',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('comment', payload);
|
||||
assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']);
|
||||
});
|
||||
|
||||
test('executeMutation surfaces bridge failures in normalized response', async () => {
|
||||
const payload = validateMutationPayload('close', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
reason: 'completed',
|
||||
});
|
||||
|
||||
const result = await executeMutation('close', payload, {
|
||||
runBdCommand: async ({ args }) => {
|
||||
assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']);
|
||||
return {
|
||||
success: false,
|
||||
classification: 'non_zero_exit',
|
||||
command: 'bd.exe',
|
||||
args,
|
||||
cwd: root,
|
||||
stdout: '',
|
||||
stderr: 'cannot close',
|
||||
code: 1,
|
||||
durationMs: 3,
|
||||
error: 'cannot close',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error?.classification, 'non_zero_exit');
|
||||
});
|
||||
|
||||
test('executeMutation returns successful normalized response', async () => {
|
||||
const payload = validateMutationPayload('update', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const result = await executeMutation('update', payload, {
|
||||
runBdCommand: async ({ args }) => {
|
||||
assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']);
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command: 'bd.exe',
|
||||
args,
|
||||
cwd: root,
|
||||
stdout: '{"id":"bb-123"}',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
durationMs: 2,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.operation, 'update');
|
||||
assert.equal(result.command.success, true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue