- Introduced the CliInstallerService to manage CLI installation, including status checks and installation processes. - Added IPC handlers for CLI installer operations, enabling communication between main and renderer processes. - Implemented the CliStatusBanner and CliStatusSection components to display CLI installation status and controls in the dashboard and settings. - Enhanced the dashboard and settings UI to provide users with real-time feedback on CLI installation progress and errors. - Updated the TeamDataService to support task clarification features, improving task management capabilities.
984 lines
32 KiB
TypeScript
984 lines
32 KiB
TypeScript
/**
|
|
* Integration tests for teamctl.js — the CLI tool agents use to manage tasks,
|
|
* kanban state, messages, reviews, and processes.
|
|
*
|
|
* Strategy:
|
|
* 1. Use TeamAgentToolsInstaller.ensureInstalled() to write the real script.
|
|
* 2. Create a temp directory with --claude-dir for full isolation.
|
|
* 3. Use child_process.execFileSync (no shell) to run commands.
|
|
* 4. Assert on stdout, stderr, exit codes, and written JSON files.
|
|
*/
|
|
|
|
import { execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Temp root for all tests. Cleaned up in afterAll. */
|
|
let tmpRoot: string;
|
|
|
|
/** Path to the installed teamctl.js script. */
|
|
let scriptPath: string;
|
|
|
|
const TEAM = 'test-team';
|
|
|
|
/** Create a fresh claude-dir structure for a single test. */
|
|
function makeFreshClaudeDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(tmpRoot, 'claude-'));
|
|
const teamsDir = path.join(dir, 'teams', TEAM);
|
|
const tasksDir = path.join(dir, 'tasks', TEAM);
|
|
fs.mkdirSync(teamsDir, { recursive: true });
|
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
|
|
// Minimal config.json
|
|
const config = {
|
|
name: TEAM,
|
|
description: 'Test team',
|
|
members: [
|
|
{ name: 'alice', role: 'team-lead' },
|
|
{ name: 'bob', role: 'developer' },
|
|
],
|
|
};
|
|
fs.writeFileSync(path.join(teamsDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
return dir;
|
|
}
|
|
|
|
/** Write a task fixture into the tasks dir. */
|
|
function writeTask(claudeDir: string, id: string, task: Record<string, unknown>): void {
|
|
const tasksDir = path.join(claudeDir, 'tasks', TEAM);
|
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(tasksDir, `${id}.json`), JSON.stringify(task, null, 2));
|
|
}
|
|
|
|
/** Read a task from disk. */
|
|
function readTask(claudeDir: string, id: string): Record<string, unknown> {
|
|
const filePath = path.join(claudeDir, 'tasks', TEAM, `${id}.json`);
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
/** Read kanban state from disk. */
|
|
function readKanban(claudeDir: string): Record<string, unknown> {
|
|
const filePath = path.join(claudeDir, 'teams', TEAM, 'kanban-state.json');
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Read inbox messages for a member. */
|
|
function readInbox(claudeDir: string, member: string): unknown[] {
|
|
const filePath = path.join(claudeDir, 'teams', TEAM, 'inboxes', `${member}.json`);
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
interface RunResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
exitCode: number;
|
|
}
|
|
|
|
/** Run teamctl.js and return stdout, stderr, exitCode. */
|
|
function run(claudeDir: string, args: string[]): RunResult {
|
|
try {
|
|
const stdout = execFileSync(
|
|
process.execPath, // node binary
|
|
[scriptPath, '--claude-dir', claudeDir, '--team', TEAM, ...args],
|
|
{ encoding: 'utf8', timeout: 10_000 }
|
|
);
|
|
return { stdout, stderr: '', exitCode: 0 };
|
|
} catch (err: unknown) {
|
|
const e = err as { stdout?: string; stderr?: string; status?: number };
|
|
return {
|
|
stdout: e.stdout ?? '',
|
|
stderr: e.stderr ?? '',
|
|
exitCode: e.status ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup / Teardown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeAll(async () => {
|
|
// Suppress console.error/warn mocks from setup.ts — we use real child processes
|
|
vi.restoreAllMocks();
|
|
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamctl-test-'));
|
|
|
|
// Mock getToolsBasePath to use our temp directory (setup.ts stubs HOME to /home/testuser)
|
|
const toolsDir = path.join(tmpRoot, 'tools');
|
|
fs.mkdirSync(toolsDir, { recursive: true });
|
|
vi.doMock('@main/utils/pathDecoder', async (importOriginal) => {
|
|
const orig = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
|
return { ...orig, getToolsBasePath: () => toolsDir };
|
|
});
|
|
|
|
// Install the real teamctl.js script using the installer class.
|
|
const { TeamAgentToolsInstaller } = await import('@main/services/team/TeamAgentToolsInstaller');
|
|
const installer = new TeamAgentToolsInstaller();
|
|
scriptPath = await installer.ensureInstalled();
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (tmpRoot) {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('teamctl.js', () => {
|
|
let claudeDir: string;
|
|
|
|
beforeEach(() => {
|
|
// Suppress console spies from global setup
|
|
vi.restoreAllMocks();
|
|
claudeDir = makeFreshClaudeDir();
|
|
});
|
|
|
|
// ---- Help ----
|
|
describe('help', () => {
|
|
it('prints help with --help flag', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['--help']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('teamctl.js v');
|
|
expect(stdout).toContain('Usage:');
|
|
expect(stdout).toContain('task set-status');
|
|
expect(stdout).toContain('task set-clarification');
|
|
});
|
|
|
|
it('prints help with no arguments', () => {
|
|
const { stdout, exitCode } = run(claudeDir, []);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('Usage:');
|
|
});
|
|
});
|
|
|
|
// ---- Task Create ----
|
|
describe('task create', () => {
|
|
it('creates a task with minimal fields', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'create', '--subject', 'My first task']);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.id).toBe('1');
|
|
expect(parsed.subject).toBe('My first task');
|
|
expect(parsed.status).toBe('pending');
|
|
expect(parsed.owner).toBeUndefined();
|
|
|
|
// Verify file on disk
|
|
const onDisk = readTask(claudeDir, '1');
|
|
expect(onDisk.subject).toBe('My first task');
|
|
});
|
|
|
|
it('creates a task with owner → status defaults to in_progress', () => {
|
|
const { stdout, exitCode } = run(claudeDir, [
|
|
'task',
|
|
'create',
|
|
'--subject',
|
|
'Owned task',
|
|
'--owner',
|
|
'bob',
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.owner).toBe('bob');
|
|
expect(parsed.status).toBe('in_progress');
|
|
});
|
|
|
|
it('respects explicit status even with owner', () => {
|
|
const { stdout } = run(claudeDir, [
|
|
'task',
|
|
'create',
|
|
'--subject',
|
|
'Pending owned',
|
|
'--owner',
|
|
'bob',
|
|
'--status',
|
|
'pending',
|
|
]);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.status).toBe('pending');
|
|
expect(parsed.owner).toBe('bob');
|
|
});
|
|
|
|
it('increments task IDs', () => {
|
|
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
|
const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.id).toBe('2');
|
|
});
|
|
|
|
it('creates task with description, activeForm, and from', () => {
|
|
const { stdout } = run(claudeDir, [
|
|
'task',
|
|
'create',
|
|
'--subject',
|
|
'Complex task',
|
|
'--description',
|
|
'Do something important',
|
|
'--active-form',
|
|
'Working on complex task',
|
|
'--from',
|
|
'alice',
|
|
]);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.description).toBe('Do something important');
|
|
expect(parsed.activeForm).toBe('Working on complex task');
|
|
expect(parsed.createdBy).toBe('alice');
|
|
});
|
|
|
|
it('fails without --subject', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'create']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --subject');
|
|
});
|
|
|
|
it('sends inbox notification with --notify and --owner', () => {
|
|
run(claudeDir, [
|
|
'task',
|
|
'create',
|
|
'--subject',
|
|
'Assigned task',
|
|
'--owner',
|
|
'bob',
|
|
'--notify',
|
|
'--from',
|
|
'alice',
|
|
]);
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
expect(inbox.length).toBe(1);
|
|
const msg = inbox[0] as Record<string, unknown>;
|
|
expect(msg.from).toBe('alice');
|
|
expect(String(msg.text)).toContain('New task assigned');
|
|
});
|
|
});
|
|
|
|
// ---- Task Set-Status ----
|
|
describe('task set-status', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Test task',
|
|
status: 'pending',
|
|
blocks: [],
|
|
blockedBy: [],
|
|
});
|
|
});
|
|
|
|
it('changes status to in_progress', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'set-status', '1', 'in_progress']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('status=in_progress');
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.status).toBe('in_progress');
|
|
});
|
|
|
|
it('changes status to completed', () => {
|
|
const { stdout } = run(claudeDir, ['task', 'set-status', '1', 'completed']);
|
|
expect(stdout).toContain('status=completed');
|
|
expect(readTask(claudeDir, '1').status).toBe('completed');
|
|
});
|
|
|
|
it('changes status to deleted', () => {
|
|
run(claudeDir, ['task', 'set-status', '1', 'deleted']);
|
|
expect(readTask(claudeDir, '1').status).toBe('deleted');
|
|
});
|
|
|
|
it('fails on invalid status', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '1', 'invalid']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Invalid status');
|
|
});
|
|
|
|
it('fails on missing task', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '999', 'pending']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Task not found');
|
|
});
|
|
});
|
|
|
|
// ---- Task Start / Complete shortcuts ----
|
|
describe('task start / complete', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'pending' });
|
|
});
|
|
|
|
it('task start sets in_progress', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'start', '1']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('status=in_progress');
|
|
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
|
});
|
|
|
|
it('task complete sets completed', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'complete', '1']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('status=completed');
|
|
expect(readTask(claudeDir, '1').status).toBe('completed');
|
|
});
|
|
|
|
it('task done is alias for complete', () => {
|
|
// Override the team flag resolution — run with minimal args
|
|
const result = run(claudeDir, ['task', 'done', '1']);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(readTask(claudeDir, '1').status).toBe('completed');
|
|
});
|
|
});
|
|
|
|
// ---- Task Get / List ----
|
|
describe('task get / list', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', { id: '1', subject: 'First', status: 'pending' });
|
|
writeTask(claudeDir, '2', { id: '2', subject: 'Second', status: 'in_progress' });
|
|
});
|
|
|
|
it('gets a single task by ID', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'get', '1']);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.subject).toBe('First');
|
|
expect(parsed.id).toBe('1');
|
|
});
|
|
|
|
it('lists all tasks', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'list']);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout) as Record<string, unknown>[];
|
|
expect(parsed).toHaveLength(2);
|
|
expect(parsed.map((t) => t.id)).toEqual(['1', '2']);
|
|
});
|
|
|
|
it('fails on task get with missing ID', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'get']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Usage');
|
|
});
|
|
});
|
|
|
|
// ---- Task Comment ----
|
|
describe('task comment', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Commentable task',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
comments: [],
|
|
});
|
|
});
|
|
|
|
it('adds a comment to a task', () => {
|
|
const { stdout, exitCode } = run(claudeDir, [
|
|
'task',
|
|
'comment',
|
|
'1',
|
|
'--text',
|
|
'Hello world',
|
|
'--from',
|
|
'alice',
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('comment added');
|
|
const task = readTask(claudeDir, '1');
|
|
const comments = task.comments as Record<string, unknown>[];
|
|
expect(comments).toHaveLength(1);
|
|
expect(comments[0].text).toBe('Hello world');
|
|
expect(comments[0].author).toBe('alice');
|
|
});
|
|
|
|
it('defaults author to "agent" when --from is not specified', () => {
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'No author']);
|
|
const task = readTask(claudeDir, '1');
|
|
const comments = task.comments as Record<string, unknown>[];
|
|
expect(comments[0].author).toBe('agent');
|
|
});
|
|
|
|
it('sends inbox notification to owner (skip self-notification)', () => {
|
|
// alice comments on bob's task → bob gets notified
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']);
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
expect(inbox.length).toBe(1);
|
|
|
|
// bob comments on own task → no notification
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']);
|
|
const inbox2 = readInbox(claudeDir, 'bob');
|
|
expect(inbox2.length).toBe(1); // still 1
|
|
});
|
|
|
|
it('fails without --text', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'comment', '1']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --text');
|
|
});
|
|
});
|
|
|
|
// ---- Comment Auto-Clear needsClarification ----
|
|
describe('comment auto-clear needsClarification', () => {
|
|
it('clears "lead" when non-owner comments', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Blocked task',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
needsClarification: 'lead',
|
|
comments: [],
|
|
});
|
|
|
|
// alice (not owner) comments → auto-clear
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Here is the answer', '--from', 'alice']);
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBeUndefined();
|
|
});
|
|
|
|
it('does NOT clear "lead" when owner comments', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Blocked task',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
needsClarification: 'lead',
|
|
comments: [],
|
|
});
|
|
|
|
// bob (owner) comments → no auto-clear
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Still waiting', '--from', 'bob']);
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBe('lead');
|
|
});
|
|
|
|
it('does NOT clear "user" via comment (only UI clears "user")', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Escalated task',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
needsClarification: 'user',
|
|
comments: [],
|
|
});
|
|
|
|
// alice comments → "user" should stay (only "lead" is auto-cleared by teamctl)
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Anything', '--from', 'alice']);
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBe('user');
|
|
});
|
|
});
|
|
|
|
// ---- Task Set-Clarification ----
|
|
describe('task set-clarification', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Task needing help',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
});
|
|
});
|
|
|
|
it('sets needsClarification to "lead"', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'lead']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('needsClarification=lead');
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBe('lead');
|
|
});
|
|
|
|
it('sets needsClarification to "user"', () => {
|
|
const { exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'user']);
|
|
expect(exitCode).toBe(0);
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBe('user');
|
|
});
|
|
|
|
it('clears needsClarification with "clear"', () => {
|
|
// Set first, then clear
|
|
run(claudeDir, ['task', 'set-clarification', '1', 'lead']);
|
|
expect(readTask(claudeDir, '1').needsClarification).toBe('lead');
|
|
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'clear']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('needsClarification=cleared');
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBeUndefined();
|
|
});
|
|
|
|
it('fails on invalid value', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification', '1', 'invalid']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Invalid value');
|
|
});
|
|
|
|
it('fails on missing arguments', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Usage');
|
|
});
|
|
});
|
|
|
|
// ---- Task Briefing ----
|
|
describe('task briefing', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Alice in-progress',
|
|
status: 'in_progress',
|
|
owner: 'alice',
|
|
});
|
|
writeTask(claudeDir, '2', {
|
|
id: '2',
|
|
subject: 'Bob todo',
|
|
status: 'pending',
|
|
owner: 'bob',
|
|
});
|
|
writeTask(claudeDir, '3', {
|
|
id: '3',
|
|
subject: 'Unassigned',
|
|
status: 'pending',
|
|
});
|
|
writeTask(claudeDir, '4', {
|
|
id: '4',
|
|
subject: 'Blocked task',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
needsClarification: 'lead',
|
|
});
|
|
});
|
|
|
|
it('shows briefing for a specific member', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('Task Briefing for bob');
|
|
expect(stdout).toContain('YOUR TASKS');
|
|
expect(stdout).toContain('Bob todo');
|
|
expect(stdout).toContain('TEAM BOARD');
|
|
expect(stdout).toContain('Alice in-progress');
|
|
});
|
|
|
|
it('shows needsClarification indicator in briefing', () => {
|
|
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']);
|
|
expect(stdout).toContain('NEEDS CLARIFICATION');
|
|
expect(stdout).toContain('LEAD');
|
|
});
|
|
|
|
it('fails without --for', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'briefing']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --for');
|
|
});
|
|
|
|
it('filters out _internal tasks', () => {
|
|
writeTask(claudeDir, '_internal_1', {
|
|
id: '_internal_1',
|
|
subject: 'Internal',
|
|
status: 'pending',
|
|
metadata: { _internal: true },
|
|
});
|
|
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']);
|
|
expect(stdout).not.toContain('Internal');
|
|
});
|
|
});
|
|
|
|
// ---- Kanban ----
|
|
describe('kanban', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', { id: '1', subject: 'Review me', status: 'completed' });
|
|
});
|
|
|
|
it('sets kanban column to review', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('column=review');
|
|
const kanban = readKanban(claudeDir);
|
|
const tasks = kanban.tasks as Record<string, Record<string, unknown>>;
|
|
expect(tasks['1'].column).toBe('review');
|
|
});
|
|
|
|
it('sets kanban column to approved', () => {
|
|
run(claudeDir, ['kanban', 'set-column', '1', 'approved']);
|
|
const kanban = readKanban(claudeDir);
|
|
const tasks = kanban.tasks as Record<string, Record<string, unknown>>;
|
|
expect(tasks['1'].column).toBe('approved');
|
|
});
|
|
|
|
it('clears kanban entry', () => {
|
|
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
|
const { stdout, exitCode } = run(claudeDir, ['kanban', 'clear', '1']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('cleared');
|
|
const kanban = readKanban(claudeDir);
|
|
const tasks = kanban.tasks as Record<string, Record<string, unknown>>;
|
|
expect(tasks['1']).toBeUndefined();
|
|
});
|
|
|
|
it('fails on invalid column', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['kanban', 'set-column', '1', 'invalid']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Invalid column');
|
|
});
|
|
});
|
|
|
|
// ---- Kanban Reviewers ----
|
|
describe('kanban reviewers', () => {
|
|
it('lists empty reviewers', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['kanban', 'reviewers', 'list']);
|
|
expect(exitCode).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual([]);
|
|
});
|
|
|
|
it('adds and removes reviewers', () => {
|
|
run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']);
|
|
run(claudeDir, ['kanban', 'reviewers', 'add', 'bob']);
|
|
const { stdout } = run(claudeDir, ['kanban', 'reviewers', 'list']);
|
|
expect(JSON.parse(stdout)).toEqual(['alice', 'bob']);
|
|
|
|
run(claudeDir, ['kanban', 'reviewers', 'remove', 'alice']);
|
|
const { stdout: stdout2 } = run(claudeDir, ['kanban', 'reviewers', 'list']);
|
|
expect(JSON.parse(stdout2)).toEqual(['bob']);
|
|
});
|
|
});
|
|
|
|
// ---- Review ----
|
|
describe('review', () => {
|
|
beforeEach(() => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Feature X',
|
|
status: 'completed',
|
|
owner: 'bob',
|
|
});
|
|
// Put task in review column
|
|
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
|
});
|
|
|
|
it('approves a task → moves to approved column', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['review', 'approve', '1']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('approved');
|
|
const kanban = readKanban(claudeDir);
|
|
const tasks = kanban.tasks as Record<string, Record<string, unknown>>;
|
|
expect(tasks['1'].column).toBe('approved');
|
|
});
|
|
|
|
it('approve with --notify-owner sends inbox message', () => {
|
|
run(claudeDir, [
|
|
'review',
|
|
'approve',
|
|
'1',
|
|
'--notify-owner',
|
|
'--from',
|
|
'alice',
|
|
'--note',
|
|
'Looks great!',
|
|
]);
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
expect(inbox.length).toBe(1);
|
|
const msg = inbox[0] as Record<string, unknown>;
|
|
expect(String(msg.text)).toContain('approved');
|
|
expect(String(msg.text)).toContain('Looks great!');
|
|
});
|
|
|
|
it('request-changes → clears kanban, sets in_progress, sends inbox', () => {
|
|
const { exitCode } = run(claudeDir, [
|
|
'review',
|
|
'request-changes',
|
|
'1',
|
|
'--comment',
|
|
'Fix the edge case',
|
|
'--from',
|
|
'alice',
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
|
|
// Kanban cleared
|
|
const kanban = readKanban(claudeDir);
|
|
const tasks = kanban.tasks as Record<string, Record<string, unknown>>;
|
|
expect(tasks['1']).toBeUndefined();
|
|
|
|
// Status back to in_progress
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.status).toBe('in_progress');
|
|
|
|
// Inbox notification
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
expect(inbox.length).toBe(1);
|
|
const msg = inbox[0] as Record<string, unknown>;
|
|
expect(String(msg.text)).toContain('Fix the edge case');
|
|
});
|
|
});
|
|
|
|
// ---- Message Send ----
|
|
describe('message send', () => {
|
|
it('sends a message to member inbox', () => {
|
|
const { stdout, exitCode } = run(claudeDir, [
|
|
'message',
|
|
'send',
|
|
'--to',
|
|
'bob',
|
|
'--text',
|
|
'Hello Bob!',
|
|
'--summary',
|
|
'Greeting',
|
|
'--from',
|
|
'alice',
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.deliveredToInbox).toBe(true);
|
|
expect(parsed.messageId).toBeDefined();
|
|
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
expect(inbox.length).toBe(1);
|
|
const msg = inbox[0] as Record<string, unknown>;
|
|
expect(msg.from).toBe('alice');
|
|
expect(msg.text).toBe('Hello Bob!');
|
|
expect(msg.summary).toBe('Greeting');
|
|
});
|
|
|
|
it('infers lead name from config when --from is missing', () => {
|
|
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']);
|
|
const inbox = readInbox(claudeDir, 'bob');
|
|
const msg = inbox[0] as Record<string, unknown>;
|
|
// alice is first member with "lead" role
|
|
expect(msg.from).toBe('alice');
|
|
});
|
|
|
|
it('fails without --to', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--text', 'No recipient']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --to');
|
|
});
|
|
|
|
it('fails without --text', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--to', 'bob']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --text');
|
|
});
|
|
});
|
|
|
|
// ---- Process Management ----
|
|
describe('process management', () => {
|
|
it('registers a process', () => {
|
|
const { stdout, exitCode } = run(claudeDir, [
|
|
'process',
|
|
'register',
|
|
'--pid',
|
|
String(process.pid),
|
|
'--label',
|
|
'dev-server',
|
|
'--port',
|
|
'3000',
|
|
'--from',
|
|
'bob',
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('process registered');
|
|
expect(stdout).toContain(`pid=${process.pid}`);
|
|
expect(stdout).toContain('port=3000');
|
|
});
|
|
|
|
it('lists processes with alive status', () => {
|
|
run(claudeDir, [
|
|
'process',
|
|
'register',
|
|
'--pid',
|
|
String(process.pid),
|
|
'--label',
|
|
'dev-server',
|
|
]);
|
|
const { stdout, exitCode } = run(claudeDir, ['process', 'list']);
|
|
expect(exitCode).toBe(0);
|
|
const list = JSON.parse(stdout) as Record<string, unknown>[];
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].pid).toBe(process.pid);
|
|
expect(list[0].alive).toBe(true);
|
|
});
|
|
|
|
it('unregisters a process by pid', () => {
|
|
run(claudeDir, [
|
|
'process',
|
|
'register',
|
|
'--pid',
|
|
String(process.pid),
|
|
'--label',
|
|
'dev-server',
|
|
]);
|
|
const { stdout, exitCode } = run(claudeDir, [
|
|
'process',
|
|
'unregister',
|
|
'--pid',
|
|
String(process.pid),
|
|
]);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('unregistered');
|
|
|
|
// List should be empty
|
|
const { stdout: listOut } = run(claudeDir, ['process', 'list']);
|
|
expect(JSON.parse(listOut)).toHaveLength(0);
|
|
});
|
|
|
|
it('fails register without --pid', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--label', 'test']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Invalid --pid');
|
|
});
|
|
|
|
it('fails register without --label', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--pid', '1234']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Missing --label');
|
|
});
|
|
});
|
|
|
|
// ---- Highwatermark ----
|
|
describe('highwatermark', () => {
|
|
it('respects highwatermark for task ID generation', () => {
|
|
// Create task 1
|
|
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
|
expect(readTask(claudeDir, '1')).toBeDefined();
|
|
|
|
// Create task 2
|
|
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
|
expect(readTask(claudeDir, '2')).toBeDefined();
|
|
|
|
// Delete task 2 from disk (simulating agent deletion)
|
|
fs.unlinkSync(path.join(claudeDir, 'tasks', TEAM, '2.json'));
|
|
|
|
// Highwatermark should be 2, so next task should be 3
|
|
const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 3']);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.id).toBe('3');
|
|
});
|
|
});
|
|
|
|
// ---- Error handling ----
|
|
describe('error handling', () => {
|
|
it('exits with error for unknown domain', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['foobar', 'something']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown domain');
|
|
});
|
|
|
|
it('exits with error for unknown task action', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['task', 'foobar']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown task action');
|
|
});
|
|
|
|
it('exits with error for unknown kanban action', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['kanban', 'foobar']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown kanban action');
|
|
});
|
|
|
|
it('exits with error for unknown review action', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['review', 'foobar']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown review action');
|
|
});
|
|
|
|
it('exits with error for unknown message action', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['message', 'foobar']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown message action');
|
|
});
|
|
|
|
it('exits with error for unknown process action', () => {
|
|
const { exitCode, stderr } = run(claudeDir, ['process', 'foobar']);
|
|
expect(exitCode).not.toBe(0);
|
|
expect(stderr).toContain('Unknown process action');
|
|
});
|
|
});
|
|
|
|
// ---- Edge cases ----
|
|
describe('edge cases', () => {
|
|
it('handles empty tasks directory gracefully for list', () => {
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'list']);
|
|
expect(exitCode).toBe(0);
|
|
expect(JSON.parse(stdout)).toEqual([]);
|
|
});
|
|
|
|
it('handles missing tasks directory gracefully for briefing', () => {
|
|
// Remove tasks dir
|
|
fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true });
|
|
const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'alice']);
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('no tasks assigned to you');
|
|
});
|
|
|
|
it('multiple comments accumulate', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Multi-comment',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
});
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']);
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']);
|
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']);
|
|
|
|
const task = readTask(claudeDir, '1');
|
|
const comments = task.comments as Record<string, unknown>[];
|
|
expect(comments).toHaveLength(3);
|
|
expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']);
|
|
});
|
|
|
|
it('set-clarification preserves other task fields', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Rich task',
|
|
description: 'Detailed desc',
|
|
status: 'in_progress',
|
|
owner: 'bob',
|
|
blocks: ['2'],
|
|
blockedBy: [],
|
|
comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }],
|
|
});
|
|
run(claudeDir, ['task', 'set-clarification', '1', 'lead']);
|
|
const task = readTask(claudeDir, '1');
|
|
expect(task.needsClarification).toBe('lead');
|
|
expect(task.subject).toBe('Rich task');
|
|
expect(task.description).toBe('Detailed desc');
|
|
expect(task.owner).toBe('bob');
|
|
expect(task.blocks).toEqual(['2']);
|
|
const comments = task.comments as Record<string, unknown>[];
|
|
expect(comments).toHaveLength(1);
|
|
});
|
|
|
|
it('briefing excludes approved tasks', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Approved task',
|
|
status: 'completed',
|
|
owner: 'bob',
|
|
});
|
|
run(claudeDir, ['kanban', 'set-column', '1', 'approved']);
|
|
|
|
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
|
expect(stdout).not.toContain('Approved task');
|
|
});
|
|
|
|
it('briefing excludes deleted tasks', () => {
|
|
writeTask(claudeDir, '1', {
|
|
id: '1',
|
|
subject: 'Deleted task',
|
|
status: 'deleted',
|
|
owner: 'bob',
|
|
});
|
|
|
|
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
|
expect(stdout).not.toContain('Deleted task');
|
|
});
|
|
});
|
|
});
|