- Updated notification configuration to separate settings for lead and user inbox messages, improving user control over notifications. - Enhanced the handling of inbox message notifications to respect individual inbox settings. - Introduced new IPC methods for managing watched directories, allowing for more granular file monitoring. - Improved task management by implementing work intervals for task status transitions, ensuring accurate tracking of task progress. - Added validation for task creation and configuration, ensuring proper input handling for project paths and task properties.
2484 lines
90 KiB
TypeScript
2484 lines
90 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 { execFile, 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';
|
||
|
||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/;
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
|
||
/** 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 });
|
||
|
||
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 [];
|
||
}
|
||
}
|
||
|
||
/** Read processes.json. */
|
||
function readProcesses(claudeDir: string): unknown[] {
|
||
const filePath = path.join(claudeDir, 'teams', TEAM, 'processes.json');
|
||
try {
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
interface RunResult {
|
||
stdout: string;
|
||
stderr: string;
|
||
exitCode: number;
|
||
}
|
||
|
||
/** Run teamctl.js synchronously and return stdout, stderr, exitCode. */
|
||
function run(claudeDir: string, args: string[]): RunResult {
|
||
try {
|
||
const stdout = execFileSync(
|
||
process.execPath,
|
||
[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,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** Run teamctl.js asynchronously (for concurrency tests). */
|
||
function runAsync(claudeDir: string, args: string[]): Promise<RunResult> {
|
||
return new Promise((resolve) => {
|
||
execFile(
|
||
process.execPath,
|
||
[scriptPath, '--claude-dir', claudeDir, '--team', TEAM, ...args],
|
||
{ encoding: 'utf8', timeout: 10_000 },
|
||
(error, stdout, stderr) => {
|
||
if (error) {
|
||
const e = error as { code?: number; status?: number };
|
||
resolve({
|
||
stdout: stdout ?? '',
|
||
stderr: stderr ?? '',
|
||
exitCode: e.status ?? e.code ?? 1,
|
||
});
|
||
} else {
|
||
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode: 0 });
|
||
}
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Setup / Teardown
|
||
// ---------------------------------------------------------------------------
|
||
|
||
beforeAll(async () => {
|
||
vi.restoreAllMocks();
|
||
|
||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamctl-test-'));
|
||
|
||
// Mock getToolsBasePath so ensureInstalled() writes to our temp dir
|
||
// (setup.ts stubs HOME to /home/testuser which doesn't exist)
|
||
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 };
|
||
});
|
||
|
||
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(() => {
|
||
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-owner');
|
||
expect(stdout).toContain('task set-clarification');
|
||
expect(stdout).toContain('task briefing');
|
||
expect(stdout).toContain('kanban set-column');
|
||
expect(stdout).toContain('review approve');
|
||
expect(stdout).toContain('review request-changes');
|
||
expect(stdout).toContain('message send');
|
||
expect(stdout).toContain('process register');
|
||
});
|
||
|
||
it('prints help with -h short flag', () => {
|
||
const { stdout, exitCode } = run(claudeDir, ['-h']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('Usage:');
|
||
});
|
||
|
||
it('prints help with no arguments', () => {
|
||
const { stdout, exitCode } = run(claudeDir, []);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('Usage:');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Arg parsing
|
||
// =========================================================================
|
||
describe('arg parsing', () => {
|
||
it('supports --key=value syntax', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject=Equals syntax task',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
const parsed = JSON.parse(stdout);
|
||
expect(parsed.subject).toBe('Equals syntax task');
|
||
});
|
||
|
||
it('supports -- separator to stop flag parsing', () => {
|
||
const { exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Task with separator',
|
||
'--',
|
||
'--not-a-flag',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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();
|
||
expect(parsed.blocks).toEqual([]);
|
||
expect(parsed.blockedBy).toEqual([]);
|
||
|
||
// Verify file on disk matches stdout
|
||
const onDisk = readTask(claudeDir, '1');
|
||
expect(onDisk.subject).toBe('My first task');
|
||
expect(onDisk.description).toBe('My first task'); // defaults to subject
|
||
});
|
||
|
||
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']);
|
||
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
||
const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 3']);
|
||
expect(JSON.parse(stdout).id).toBe('3');
|
||
});
|
||
|
||
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('accepts --desc as alias for --description', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'With desc alias',
|
||
'--desc',
|
||
'Alias description',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect(JSON.parse(stdout).description).toBe('Alias description');
|
||
});
|
||
|
||
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');
|
||
expect(String(msg.text)).toContain('#1');
|
||
});
|
||
|
||
it('sends inbox notification with --notify including prompt and tool instructions', () => {
|
||
run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Task with prompt',
|
||
'--description',
|
||
'Detailed work',
|
||
'--prompt',
|
||
'Please implement authentication using JWT',
|
||
'--owner',
|
||
'bob',
|
||
'--notify',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
const inbox = readInbox(claudeDir, 'bob');
|
||
expect(inbox.length).toBe(1);
|
||
const text = String((inbox[0] as Record<string, unknown>).text);
|
||
expect(text).toContain('New task assigned');
|
||
expect(text).toContain('Description:');
|
||
expect(text).toContain('Detailed work');
|
||
expect(text).toContain('Instructions:');
|
||
expect(text).toContain('Please implement authentication using JWT');
|
||
expect(text).toContain('task start');
|
||
expect(text).toContain('task complete');
|
||
});
|
||
|
||
it('does NOT send notification with --notify but without --owner', () => {
|
||
run(claudeDir, ['task', 'create', '--subject', 'Unowned with notify', '--notify']);
|
||
const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes');
|
||
try {
|
||
const files = fs.readdirSync(inboxDir);
|
||
expect(files).toHaveLength(0);
|
||
} catch {
|
||
// inboxes dir doesn't exist -> correct, no notification sent
|
||
}
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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');
|
||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||
});
|
||
|
||
it('changes status to completed', () => {
|
||
run(claudeDir, ['task', 'set-status', '1', '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('preserves other task fields when changing status', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Rich',
|
||
description: 'Desc',
|
||
owner: 'bob',
|
||
status: 'pending',
|
||
blocks: ['3'],
|
||
blockedBy: ['1'],
|
||
comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }],
|
||
});
|
||
run(claudeDir, ['task', 'set-status', '2', 'in_progress']);
|
||
const task = readTask(claudeDir, '2');
|
||
expect(task.status).toBe('in_progress');
|
||
expect(task.subject).toBe('Rich');
|
||
expect(task.description).toBe('Desc');
|
||
expect(task.owner).toBe('bob');
|
||
expect(task.blocks).toEqual(['3']);
|
||
expect((task.comments as unknown[]).length).toBe(1);
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
it('fails without arguments', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'set-status']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Task Start / Complete
|
||
// =========================================================================
|
||
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('"done" is alias for "complete"', () => {
|
||
expect(run(claudeDir, ['task', 'done', '1']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '1').status).toBe('completed');
|
||
});
|
||
|
||
it('start fails without task ID', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'start']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
|
||
it('complete fails without task ID', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'complete']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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 sorted by ID', () => {
|
||
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('list ignores non-JSON files, dotfiles, and non-numeric names', () => {
|
||
const tasksDir = path.join(claudeDir, 'tasks', TEAM);
|
||
fs.writeFileSync(path.join(tasksDir, '.highwatermark'), '5');
|
||
fs.writeFileSync(path.join(tasksDir, '.hidden.json'), '{}');
|
||
fs.writeFileSync(path.join(tasksDir, 'readme.txt'), 'not a task');
|
||
fs.writeFileSync(path.join(tasksDir, 'abc.json'), '{"id":"abc"}');
|
||
fs.writeFileSync(path.join(tasksDir, '_internal_1.json'), '{"id":"_internal_1"}');
|
||
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'list']);
|
||
expect(exitCode).toBe(0);
|
||
const parsed = JSON.parse(stdout) as Record<string, unknown>[];
|
||
expect(parsed).toHaveLength(2); // only 1.json and 2.json
|
||
});
|
||
|
||
it('fails on task get with missing ID', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'get']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
|
||
it('task get on non-existent task fails', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'get', '999']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Task not found');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Task Set-Owner / Assign
|
||
// =========================================================================
|
||
describe('task set-owner / assign', () => {
|
||
beforeEach(() => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Unowned task',
|
||
status: 'pending',
|
||
blocks: [],
|
||
blockedBy: [],
|
||
});
|
||
});
|
||
|
||
it('sets owner on an existing task', () => {
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'set-owner', '1', 'bob']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('owner=bob');
|
||
expect(readTask(claudeDir, '1').owner).toBe('bob');
|
||
});
|
||
|
||
it('"assign" is alias for "set-owner"', () => {
|
||
const { exitCode } = run(claudeDir, ['task', 'assign', '1', 'bob']);
|
||
expect(exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '1').owner).toBe('bob');
|
||
});
|
||
|
||
it('clears owner with "clear"', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Owned task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'set-owner', '2', 'clear']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('owner=cleared');
|
||
expect(readTask(claudeDir, '2').owner).toBeUndefined();
|
||
});
|
||
|
||
it('clears owner with "none"', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Owned task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
expect(run(claudeDir, ['task', 'set-owner', '2', 'none']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '2').owner).toBeUndefined();
|
||
});
|
||
|
||
it('reassigns owner from one member to another', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Bob task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
expect(run(claudeDir, ['task', 'set-owner', '2', 'alice']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '2').owner).toBe('alice');
|
||
});
|
||
|
||
it('preserves other task fields when changing owner', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Rich',
|
||
description: 'Desc',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
blocks: ['3'],
|
||
blockedBy: ['1'],
|
||
comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }],
|
||
});
|
||
run(claudeDir, ['task', 'set-owner', '2', 'alice']);
|
||
const task = readTask(claudeDir, '2');
|
||
expect(task.owner).toBe('alice');
|
||
expect(task.subject).toBe('Rich');
|
||
expect(task.description).toBe('Desc');
|
||
expect(task.status).toBe('in_progress');
|
||
expect(task.blocks).toEqual(['3']);
|
||
expect((task.comments as unknown[]).length).toBe(1);
|
||
});
|
||
|
||
it('sends inbox notification with --notify', () => {
|
||
run(claudeDir, ['task', 'set-owner', '1', '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('Task assigned to you');
|
||
expect(String(msg.text)).toContain('#1');
|
||
});
|
||
|
||
it('does NOT send notification without --notify', () => {
|
||
run(claudeDir, ['task', 'set-owner', '1', 'bob']);
|
||
const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes');
|
||
try {
|
||
const files = fs.readdirSync(inboxDir);
|
||
expect(files).toHaveLength(0);
|
||
} catch {
|
||
// inboxes dir doesn't exist -> correct, no notification sent
|
||
}
|
||
});
|
||
|
||
it('does NOT send notification when clearing owner', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Owned',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['task', 'set-owner', '2', 'clear', '--notify']);
|
||
const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes');
|
||
try {
|
||
const files = fs.readdirSync(inboxDir);
|
||
expect(files).toHaveLength(0);
|
||
} catch {
|
||
// no inboxes dir -> correct
|
||
}
|
||
});
|
||
|
||
it('fails without task ID', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
|
||
it('fails without owner argument', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner', '1']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
|
||
it('fails on non-existent task', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner', '999', 'bob']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Task not found');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Task Comment
|
||
// =========================================================================
|
||
describe('task comment', () => {
|
||
beforeEach(() => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Commentable task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
comments: [],
|
||
});
|
||
});
|
||
|
||
it('adds a comment with valid ID, timestamp, and type=regular', () => {
|
||
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');
|
||
expect(comments[0].type).toBe('regular');
|
||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||
});
|
||
|
||
it('defaults author to "agent" when --from is not specified', () => {
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'No author']);
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments[0].author).toBe('agent');
|
||
});
|
||
|
||
it('sends inbox notification to owner (skip self-notification)', () => {
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']);
|
||
expect(readInbox(claudeDir, 'bob').length).toBe(1);
|
||
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']);
|
||
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
|
||
});
|
||
|
||
it('multiple comments accumulate with unique IDs and type=regular', () => {
|
||
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 comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(3);
|
||
expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']);
|
||
expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']);
|
||
expect(new Set(comments.map((c) => c.id)).size).toBe(3);
|
||
expect(comments.every((c) => c.type === 'regular')).toBe(true);
|
||
});
|
||
|
||
it('comment on task without comments array initializes it', () => {
|
||
writeTask(claudeDir, '2', { id: '2', subject: 'No comments field', status: 'pending' });
|
||
expect(
|
||
run(claudeDir, ['task', 'comment', '2', '--text', 'First', '--from', 'alice']).exitCode
|
||
).toBe(0);
|
||
expect((readTask(claudeDir, '2').comments as unknown[]).length).toBe(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',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
needsClarification: 'lead',
|
||
comments: [],
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Answer', '--from', 'alice']);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBeUndefined();
|
||
});
|
||
|
||
it('does NOT clear "lead" when owner comments (still waiting for answer)', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Blocked',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
needsClarification: 'lead',
|
||
comments: [],
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Still waiting', '--from', 'bob']);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBe('lead');
|
||
});
|
||
|
||
it('does NOT clear "user" via CLI comment (only UI clears "user")', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Escalated',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
needsClarification: 'user',
|
||
comments: [],
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Anything', '--from', 'alice']);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBe('user');
|
||
});
|
||
|
||
it('clears "lead" with default author "agent" (agent != owner)', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Blocked',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
needsClarification: 'lead',
|
||
comments: [],
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Reply']);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBeUndefined();
|
||
});
|
||
|
||
it('auto-clear and comment are a single atomic write', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Blocked',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
needsClarification: 'lead',
|
||
comments: [],
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Answer', '--from', 'alice']);
|
||
const task = readTask(claudeDir, '1');
|
||
expect(task.needsClarification).toBeUndefined();
|
||
expect((task.comments as unknown[]).length).toBe(1);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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');
|
||
expect(readTask(claudeDir, '1').needsClarification).toBe('lead');
|
||
});
|
||
|
||
it('sets needsClarification to "user"', () => {
|
||
expect(run(claudeDir, ['task', 'set-clarification', '1', 'user']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBe('user');
|
||
});
|
||
|
||
it('clears needsClarification with "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');
|
||
expect(readTask(claudeDir, '1').needsClarification).toBeUndefined();
|
||
});
|
||
|
||
it('can transition from lead to user (escalation)', () => {
|
||
run(claudeDir, ['task', 'set-clarification', '1', 'lead']);
|
||
run(claudeDir, ['task', 'set-clarification', '1', 'user']);
|
||
expect(readTask(claudeDir, '1').needsClarification).toBe('user');
|
||
});
|
||
|
||
it('preserves all other task fields', () => {
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Rich task',
|
||
description: 'Detailed desc',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
blocks: ['3'],
|
||
blockedBy: [],
|
||
comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }],
|
||
});
|
||
run(claudeDir, ['task', 'set-clarification', '2', 'lead']);
|
||
const task = readTask(claudeDir, '2');
|
||
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(['3']);
|
||
expect((task.comments as unknown[]).length).toBe(1);
|
||
});
|
||
|
||
it('fails on invalid value (shows allowed values)', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification', '1', 'invalid']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Invalid value');
|
||
expect(stderr).toContain('lead, user, clear');
|
||
});
|
||
|
||
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 with correct section placement', () => {
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('Task Briefing for bob');
|
||
|
||
const yourTasksIdx = stdout.indexOf('YOUR TASKS');
|
||
const teamBoardIdx = stdout.indexOf('TEAM BOARD');
|
||
expect(yourTasksIdx).toBeGreaterThan(-1);
|
||
expect(teamBoardIdx).toBeGreaterThan(yourTasksIdx);
|
||
|
||
// Bob's tasks in YOUR TASKS section
|
||
const yourSection = stdout.slice(yourTasksIdx, teamBoardIdx);
|
||
expect(yourSection).toContain('Bob todo');
|
||
expect(yourSection).toContain('Blocked task');
|
||
|
||
// Alice's task in TEAM BOARD section
|
||
const teamSection = stdout.slice(teamBoardIdx);
|
||
expect(teamSection).toContain('Alice in-progress');
|
||
});
|
||
|
||
it('shows needsClarification indicator', () => {
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']);
|
||
expect(stdout).toContain('NEEDS CLARIFICATION');
|
||
expect(stdout).toContain('LEAD');
|
||
});
|
||
|
||
it('shows tasks in review kanban column', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Under review task',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '5', 'review']);
|
||
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('Under review task');
|
||
expect(stdout).toContain('REVIEW');
|
||
});
|
||
|
||
it('excludes approved tasks', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Approved task',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '5', 'approved']);
|
||
expect(run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout).not.toContain(
|
||
'Approved task'
|
||
);
|
||
});
|
||
|
||
it('excludes deleted tasks', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Deleted task',
|
||
status: 'deleted',
|
||
owner: 'bob',
|
||
});
|
||
expect(run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout).not.toContain(
|
||
'Deleted task'
|
||
);
|
||
});
|
||
|
||
it('filters out _internal tasks', () => {
|
||
writeTask(claudeDir, '_internal_1', {
|
||
id: '_internal_1',
|
||
subject: 'CLI bookkeeping',
|
||
status: 'pending',
|
||
metadata: { _internal: true },
|
||
});
|
||
expect(run(claudeDir, ['task', 'briefing', '--for', 'alice']).stdout).not.toContain(
|
||
'CLI bookkeeping'
|
||
);
|
||
});
|
||
|
||
it('truncates description to 500 chars', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Long desc',
|
||
description: 'X'.repeat(600),
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('X'.repeat(500));
|
||
expect(stdout).not.toContain('X'.repeat(501));
|
||
});
|
||
|
||
it('caps DONE section to 15 tasks', () => {
|
||
for (let i = 10; i < 30; i++) {
|
||
writeTask(claudeDir, String(i), {
|
||
id: String(i),
|
||
subject: `Done task ${i}`,
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
}
|
||
const matches =
|
||
run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout.match(/Done task \d+/g) ?? [];
|
||
expect(matches.length).toBeLessThanOrEqual(15);
|
||
});
|
||
|
||
it('shows blockedBy and related info', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Blocked by others',
|
||
status: 'pending',
|
||
owner: 'bob',
|
||
blockedBy: ['1', '2'],
|
||
related: ['3'],
|
||
});
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('Blocked by: #1, #2');
|
||
expect(stdout).toContain('Related: #3');
|
||
});
|
||
|
||
it('shows comment count and content', () => {
|
||
writeTask(claudeDir, '5', {
|
||
id: '5',
|
||
subject: 'Task with comments',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
comments: [
|
||
{ id: 'c1', author: 'alice', text: 'Please fix this', createdAt: '2025-06-01T12:00:00Z' },
|
||
{ id: 'c2', author: 'bob', text: 'Working on it', createdAt: '2025-06-01T13:00:00Z' },
|
||
],
|
||
});
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('Comments (2)');
|
||
expect(stdout).toContain('[alice');
|
||
expect(stdout).toContain('Please fix this');
|
||
});
|
||
|
||
it('shows "no tasks assigned" when member has no tasks', () => {
|
||
expect(run(claudeDir, ['task', 'briefing', '--for', 'charlie']).stdout).toContain(
|
||
'no tasks assigned to you'
|
||
);
|
||
});
|
||
|
||
it('fails without --for', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'briefing']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Missing --for');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Kanban
|
||
// =========================================================================
|
||
describe('kanban', () => {
|
||
beforeEach(() => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Review me', status: 'completed' });
|
||
});
|
||
|
||
it('sets kanban column to review with movedAt timestamp', () => {
|
||
const { stdout, exitCode } = run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('column=review');
|
||
const tasks = readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>;
|
||
expect(tasks['1'].column).toBe('review');
|
||
expect(tasks['1'].reviewer).toBeNull();
|
||
expect(String(tasks['1'].movedAt)).toMatch(ISO_RE);
|
||
});
|
||
|
||
it('sets kanban column to approved', () => {
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'approved']);
|
||
const tasks = readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>;
|
||
expect(tasks['1'].column).toBe('approved');
|
||
expect(String(tasks['1'].movedAt)).toMatch(ISO_RE);
|
||
});
|
||
|
||
it('clears kanban entry with "clear"', () => {
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
expect(run(claudeDir, ['kanban', 'clear', '1']).exitCode).toBe(0);
|
||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||
});
|
||
|
||
it('"remove" is alias for "clear"', () => {
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
expect(run(claudeDir, ['kanban', 'remove', '1']).exitCode).toBe(0);
|
||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['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');
|
||
});
|
||
|
||
it('maintains state for multiple tasks', () => {
|
||
writeTask(claudeDir, '2', { id: '2', subject: 'Another', status: 'completed' });
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
run(claudeDir, ['kanban', 'set-column', '2', 'approved']);
|
||
const tasks = readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>;
|
||
expect(tasks['1'].column).toBe('review');
|
||
expect(tasks['2'].column).toBe('approved');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Kanban Reviewers
|
||
// =========================================================================
|
||
describe('kanban reviewers', () => {
|
||
it('lists empty reviewers', () => {
|
||
expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual([]);
|
||
});
|
||
|
||
it('adds and removes reviewers', () => {
|
||
run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']);
|
||
run(claudeDir, ['kanban', 'reviewers', 'add', 'bob']);
|
||
expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual([
|
||
'alice',
|
||
'bob',
|
||
]);
|
||
|
||
run(claudeDir, ['kanban', 'reviewers', 'remove', 'alice']);
|
||
expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['bob']);
|
||
});
|
||
|
||
it('add is idempotent (Set-based, no duplicates)', () => {
|
||
run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']);
|
||
run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']);
|
||
expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['alice']);
|
||
});
|
||
|
||
it('remove non-existent reviewer is a no-op', () => {
|
||
run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']);
|
||
run(claudeDir, ['kanban', 'reviewers', 'remove', 'nonexistent']);
|
||
expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['alice']);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Review
|
||
// =========================================================================
|
||
describe('review', () => {
|
||
beforeEach(() => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Feature X',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
});
|
||
|
||
it('approves a task -> moves to approved column', () => {
|
||
expect(run(claudeDir, ['review', 'approve', '1']).exitCode).toBe(0);
|
||
expect(
|
||
(readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'].column
|
||
).toBe('approved');
|
||
});
|
||
|
||
it('approve with --notify-owner sends inbox message with note', () => {
|
||
run(claudeDir, [
|
||
'review',
|
||
'approve',
|
||
'1',
|
||
'--notify-owner',
|
||
'--from',
|
||
'alice',
|
||
'--note',
|
||
'Looks great!',
|
||
]);
|
||
const inbox = readInbox(claudeDir, 'bob');
|
||
expect(inbox.length).toBe(1);
|
||
const text = String((inbox[0] as Record<string, unknown>).text);
|
||
expect(text).toContain('approved');
|
||
expect(text).toContain('Looks great!');
|
||
expect((inbox[0] as Record<string, unknown>).from).toBe('alice');
|
||
});
|
||
|
||
it('approve with --notify-owner but no --note sends plain message', () => {
|
||
run(claudeDir, ['review', 'approve', '1', '--notify-owner', '--from', 'alice']);
|
||
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
||
expect(text).toContain('#1 approved');
|
||
});
|
||
|
||
it('request-changes -> clears kanban, sets in_progress, sends inbox with comment', () => {
|
||
expect(
|
||
run(claudeDir, [
|
||
'review',
|
||
'request-changes',
|
||
'1',
|
||
'--comment',
|
||
'Fix the edge case',
|
||
'--from',
|
||
'alice',
|
||
]).exitCode
|
||
).toBe(0);
|
||
|
||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
||
expect(text).toContain('Fix the edge case');
|
||
expect(text).toContain('Please fix');
|
||
});
|
||
|
||
it('request-changes without --comment uses default text', () => {
|
||
run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']);
|
||
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
||
expect(text).toContain('Reviewer requested changes');
|
||
});
|
||
|
||
it('request-changes on task without owner fails', () => {
|
||
writeTask(claudeDir, '2', { id: '2', subject: 'No owner', status: 'completed' });
|
||
const { exitCode, stderr } = run(claudeDir, [
|
||
'review',
|
||
'request-changes',
|
||
'2',
|
||
'--comment',
|
||
'Fix',
|
||
]);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('No owner found');
|
||
});
|
||
|
||
it('approve fails without task ID', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['review', 'approve']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Usage');
|
||
});
|
||
|
||
it('approve records review_approved comment in task.comments', () => {
|
||
run(claudeDir, ['review', 'approve', '1', '--from', 'alice']);
|
||
const task = readTask(claudeDir, '1');
|
||
const comments = task.comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_approved');
|
||
expect(comments[0].author).toBe('alice');
|
||
expect(comments[0].text).toBe('Approved');
|
||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||
});
|
||
|
||
it('approve records review_approved comment with --note text', () => {
|
||
run(claudeDir, [
|
||
'review',
|
||
'approve',
|
||
'1',
|
||
'--notify-owner',
|
||
'--from',
|
||
'alice',
|
||
'--note',
|
||
'Looks great!',
|
||
]);
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_approved');
|
||
expect(comments[0].text).toBe('Looks great!');
|
||
});
|
||
|
||
it('request-changes records review_request comment in task.comments', () => {
|
||
run(claudeDir, [
|
||
'review',
|
||
'request-changes',
|
||
'1',
|
||
'--comment',
|
||
'Fix the edge case',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_request');
|
||
expect(comments[0].author).toBe('alice');
|
||
expect(comments[0].text).toBe('Fix the edge case');
|
||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||
});
|
||
|
||
it('request-changes without --comment records default text as review_request', () => {
|
||
run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']);
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_request');
|
||
expect(comments[0].text).toBe('Reviewer requested changes.');
|
||
});
|
||
|
||
it('review comments preserve existing task comments', () => {
|
||
// Add a regular comment first
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Working on it', '--from', 'bob']);
|
||
// Then request changes
|
||
run(claudeDir, [
|
||
'review',
|
||
'request-changes',
|
||
'1',
|
||
'--comment',
|
||
'Needs tests',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(2);
|
||
expect(comments[0].type).toBe('regular');
|
||
expect(comments[0].text).toBe('Working on it');
|
||
expect(comments[1].type).toBe('review_request');
|
||
expect(comments[1].text).toBe('Needs tests');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Message Send
|
||
// =========================================================================
|
||
describe('message send', () => {
|
||
it('sends a message with all fields validated', () => {
|
||
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(String(parsed.messageId)).toMatch(UUID_RE);
|
||
|
||
const msg = readInbox(claudeDir, 'bob')[0] as Record<string, unknown>;
|
||
expect(msg.from).toBe('alice');
|
||
expect(msg.text).toBe('Hello Bob!');
|
||
expect(msg.summary).toBe('Greeting');
|
||
expect(msg.read).toBe(false);
|
||
expect(String(msg.timestamp)).toMatch(ISO_RE);
|
||
expect(String(msg.messageId)).toMatch(UUID_RE);
|
||
});
|
||
|
||
it('infers lead name from config when --from is missing', () => {
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']);
|
||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).from).toBe('alice');
|
||
});
|
||
|
||
it('multiple messages accumulate with unique IDs', () => {
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 1', '--from', 'alice']);
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 2', '--from', 'alice']);
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 3', '--from', 'alice']);
|
||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
expect(inbox.length).toBe(3);
|
||
expect(inbox.map((m) => m.text)).toEqual(['Msg 1', 'Msg 2', 'Msg 3']);
|
||
expect(new Set(inbox.map((m) => m.messageId)).size).toBe(3);
|
||
});
|
||
|
||
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 with all optional fields', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'dev-server',
|
||
'--port',
|
||
'3000',
|
||
'--url',
|
||
'http://localhost:3000',
|
||
'--claude-process-id',
|
||
'cp-123',
|
||
'--from',
|
||
'bob',
|
||
'--command',
|
||
'npm run dev',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('process registered');
|
||
expect(stdout).toContain(`pid=${process.pid}`);
|
||
expect(stdout).toContain('port=3000');
|
||
|
||
const procs = readProcesses(claudeDir) as Record<string, unknown>[];
|
||
expect(procs).toHaveLength(1);
|
||
expect(procs[0].pid).toBe(process.pid);
|
||
expect(procs[0].label).toBe('dev-server');
|
||
expect(procs[0].port).toBe(3000);
|
||
expect(procs[0].url).toBe('http://localhost:3000');
|
||
expect(procs[0].claudeProcessId).toBe('cp-123');
|
||
expect(procs[0].registeredBy).toBe('bob');
|
||
expect(procs[0].command).toBe('npm run dev');
|
||
expect(String(procs[0].registeredAt)).toMatch(ISO_RE);
|
||
expect(String(procs[0].id)).toMatch(UUID_RE);
|
||
});
|
||
|
||
it('registers without port -> no port in stdout', () => {
|
||
const { stdout } = run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'worker',
|
||
]);
|
||
expect(stdout).toContain('process registered');
|
||
expect(stdout).not.toContain('port=');
|
||
});
|
||
|
||
it('re-registration with same PID preserves id and registeredAt', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'v1',
|
||
'--port',
|
||
'3000',
|
||
]);
|
||
const procs1 = readProcesses(claudeDir) as Record<string, unknown>[];
|
||
const originalId = procs1[0].id;
|
||
const originalRegisteredAt = procs1[0].registeredAt;
|
||
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'v2',
|
||
'--port',
|
||
'4000',
|
||
]);
|
||
const procs2 = readProcesses(claudeDir) as Record<string, unknown>[];
|
||
expect(procs2).toHaveLength(1);
|
||
expect(procs2[0].id).toBe(originalId);
|
||
expect(procs2[0].registeredAt).toBe(originalRegisteredAt);
|
||
expect(procs2[0].label).toBe('v2');
|
||
expect(procs2[0].port).toBe(4000);
|
||
});
|
||
|
||
it('lists processes with alive=true for current PID', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'dev-server',
|
||
]);
|
||
const list = JSON.parse(run(claudeDir, ['process', 'list']).stdout) as Record<
|
||
string,
|
||
unknown
|
||
>[];
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].alive).toBe(true);
|
||
});
|
||
|
||
it('lists dead process with alive=false', () => {
|
||
const deadPid = 2_147_483_647;
|
||
run(claudeDir, ['process', 'register', '--pid', String(deadPid), '--label', 'dead-proc']);
|
||
const list = JSON.parse(run(claudeDir, ['process', 'list']).stdout) as Record<
|
||
string,
|
||
unknown
|
||
>[];
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].pid).toBe(deadPid);
|
||
expect(list[0].alive).toBe(false);
|
||
});
|
||
|
||
it('unregisters by --pid', () => {
|
||
run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']);
|
||
expect(run(claudeDir, ['process', 'unregister', '--pid', String(process.pid)]).exitCode).toBe(
|
||
0
|
||
);
|
||
expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0);
|
||
});
|
||
|
||
it('unregisters by --id (UUID)', () => {
|
||
run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']);
|
||
const procId = String((readProcesses(claudeDir) as Record<string, unknown>[])[0].id);
|
||
expect(run(claudeDir, ['process', 'unregister', '--id', procId]).exitCode).toBe(0);
|
||
expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0);
|
||
});
|
||
|
||
it('"remove" is alias for "unregister"', () => {
|
||
run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']);
|
||
expect(run(claudeDir, ['process', 'remove', '--pid', String(process.pid)]).exitCode).toBe(0);
|
||
expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0);
|
||
});
|
||
|
||
it('unregister non-existent process fails', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['process', 'unregister', '--pid', '999999']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Process not found');
|
||
});
|
||
|
||
it('unregister without --pid or --id fails', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['process', 'unregister']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Missing --pid or --id');
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
it('ignores invalid port (out of range)', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'test',
|
||
'--port',
|
||
'0',
|
||
]);
|
||
expect((readProcesses(claudeDir) as Record<string, unknown>[])[0].port).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Highwatermark
|
||
// =========================================================================
|
||
describe('highwatermark', () => {
|
||
it('respects highwatermark when task file is deleted', () => {
|
||
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
||
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
||
fs.unlinkSync(path.join(claudeDir, 'tasks', TEAM, '2.json'));
|
||
expect(JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'Task 3']).stdout).id).toBe(
|
||
'3'
|
||
);
|
||
});
|
||
|
||
it('handles manually set highwatermark higher than existing files', () => {
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), '100');
|
||
expect(
|
||
JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'After HWM']).stdout).id
|
||
).toBe('101');
|
||
});
|
||
|
||
it('handles missing highwatermark (uses max file ID)', () => {
|
||
writeTask(claudeDir, '5', { id: '5', subject: 'Task 5', status: 'pending' });
|
||
expect(JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'Next']).stdout).id).toBe(
|
||
'6'
|
||
);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 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.each([
|
||
['task', 'foobar', 'Unknown task action'],
|
||
['kanban', 'foobar', 'Unknown kanban action'],
|
||
['review', 'foobar', 'Unknown review action'],
|
||
['message', 'foobar', 'Unknown message action'],
|
||
['process', 'foobar', 'Unknown process action'],
|
||
])('exits with error for unknown %s action "%s"', (domain, action, expected) => {
|
||
const { exitCode, stderr } = run(claudeDir, [domain, action]);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain(expected);
|
||
});
|
||
|
||
it('missing --team flag fails', () => {
|
||
try {
|
||
execFileSync(process.execPath, [scriptPath, '--claude-dir', claudeDir, 'task', 'list'], {
|
||
encoding: 'utf8',
|
||
timeout: 10_000,
|
||
});
|
||
expect.fail('Expected error');
|
||
} catch (err: unknown) {
|
||
const e = err as { stderr?: string; status?: number };
|
||
expect(e.status).not.toBe(0);
|
||
expect(e.stderr).toContain('Missing --team');
|
||
}
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Special characters in arguments
|
||
// =========================================================================
|
||
describe('special characters', () => {
|
||
it('handles unicode in subject and description', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Задача с юникодом',
|
||
'--description',
|
||
'Описание',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
const parsed = JSON.parse(stdout);
|
||
expect(parsed.subject).toBe('Задача с юникодом');
|
||
expect(parsed.description).toBe('Описание');
|
||
expect(readTask(claudeDir, '1').subject).toBe('Задача с юникодом');
|
||
});
|
||
|
||
it('handles quotes and special shell chars in text', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'pending', owner: 'bob' });
|
||
const specialText = 'He said "hello" & she said \'goodbye\' <tag> $HOME `backticks`';
|
||
run(claudeDir, ['task', 'comment', '1', '--text', specialText, '--from', 'alice']);
|
||
expect((readTask(claudeDir, '1').comments as Record<string, unknown>[])[0].text).toBe(
|
||
specialText
|
||
);
|
||
});
|
||
|
||
it('handles multi-word subject with spaces', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'This is a long task subject with many words',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect(JSON.parse(stdout).subject).toBe('This is a long task subject with many words');
|
||
});
|
||
|
||
it('handles newlines in message text', () => {
|
||
const textWithNewlines = 'Line 1\nLine 2\nLine 3';
|
||
run(claudeDir, [
|
||
'message',
|
||
'send',
|
||
'--to',
|
||
'bob',
|
||
'--text',
|
||
textWithNewlines,
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text).toBe(
|
||
textWithNewlines
|
||
);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Corrupted/malformed data
|
||
// =========================================================================
|
||
describe('corrupted data', () => {
|
||
it('empty task JSON file causes error on get', () => {
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), '');
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'get', '1']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('invalid JSON in task file causes error on get', () => {
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), '{invalid!!!}');
|
||
expect(run(claudeDir, ['task', 'get', '1']).exitCode).not.toBe(0);
|
||
});
|
||
|
||
it('truncated JSON causes error (partial write scenario)', () => {
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), '{"id":"1","subj');
|
||
expect(run(claudeDir, ['task', 'get', '1']).exitCode).not.toBe(0);
|
||
});
|
||
|
||
it('task list handles corrupted file without crashing', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Good', status: 'pending' });
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '2.json'), 'CORRUPTED');
|
||
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'list']);
|
||
expect(exitCode).toBe(0);
|
||
expect((JSON.parse(stdout) as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it('briefing handles corrupted task files', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Good task', status: 'pending', owner: 'bob' });
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '2.json'), 'NOT_JSON');
|
||
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(exitCode).toBe(0);
|
||
expect(stdout).toContain('Good task');
|
||
});
|
||
|
||
it('missing kanban-state.json -> creates on first write', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'completed' });
|
||
expect(run(claudeDir, ['kanban', 'set-column', '1', 'review']).exitCode).toBe(0);
|
||
expect(readKanban(claudeDir)).toBeDefined();
|
||
});
|
||
|
||
it('missing processes.json -> empty list', () => {
|
||
expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Concurrency
|
||
// =========================================================================
|
||
describe('concurrency', () => {
|
||
it('parallel task creates all succeed without crashing', async () => {
|
||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||
runAsync(claudeDir, ['task', 'create', '--subject', `Parallel task ${i}`])
|
||
);
|
||
const results = await Promise.all(promises);
|
||
|
||
for (const r of results) {
|
||
expect(r.exitCode).toBe(0);
|
||
const parsed = JSON.parse(r.stdout) as { id: string };
|
||
expect(Number(parsed.id)).toBeGreaterThan(0);
|
||
}
|
||
|
||
// Note: without inter-process file locking, parallel creates may produce
|
||
// duplicate IDs (known pre-existing limitation). We verify that all calls
|
||
// succeed and produce valid output — not uniqueness.
|
||
const allTasks = JSON.parse(run(claudeDir, ['task', 'list']).stdout) as unknown[];
|
||
expect(allTasks.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it('parallel comments on same task — no crash, valid structure', async () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Shared task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
comments: [],
|
||
});
|
||
|
||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||
runAsync(claudeDir, [
|
||
'task',
|
||
'comment',
|
||
'1',
|
||
'--text',
|
||
`Comment ${i}`,
|
||
'--from',
|
||
`agent-${i}`,
|
||
])
|
||
);
|
||
const results = await Promise.all(promises);
|
||
|
||
for (const r of results) {
|
||
expect(r.exitCode).toBe(0);
|
||
}
|
||
|
||
// Due to read-modify-write race, not all 5 may persist.
|
||
// The important thing: no crash, no data corruption, valid JSON.
|
||
const task = readTask(claudeDir, '1');
|
||
const comments = task.comments as Record<string, unknown>[];
|
||
expect(comments.length).toBeGreaterThanOrEqual(1);
|
||
for (const c of comments) {
|
||
expect(c.text).toBeDefined();
|
||
expect(c.author).toBeDefined();
|
||
expect(c.id).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('parallel messages to same inbox — no crash', async () => {
|
||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||
runAsync(claudeDir, [
|
||
'message',
|
||
'send',
|
||
'--to',
|
||
'bob',
|
||
'--text',
|
||
`Msg ${i}`,
|
||
'--from',
|
||
`agent-${i}`,
|
||
])
|
||
);
|
||
const results = await Promise.all(promises);
|
||
|
||
for (const r of results) {
|
||
expect(r.exitCode).toBe(0);
|
||
}
|
||
|
||
expect(readInbox(claudeDir, 'bob').length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Critical nuances
|
||
// =========================================================================
|
||
describe('critical nuances', () => {
|
||
// --- Numeric sort (not lexicographic) ---
|
||
it('task list sorts numerically: 1, 2, 10 (not 1, 10, 2)', () => {
|
||
writeTask(claudeDir, '10', { id: '10', subject: 'Ten', status: 'pending' });
|
||
writeTask(claudeDir, '2', { id: '2', subject: 'Two', status: 'pending' });
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'One', status: 'pending' });
|
||
const tasks = JSON.parse(run(claudeDir, ['task', 'list']).stdout) as { id: string }[];
|
||
expect(tasks.map((t) => t.id)).toEqual(['1', '2', '10']);
|
||
});
|
||
|
||
// --- createTask rejects duplicate ID ---
|
||
it('task create dies if task file already exists at next ID', () => {
|
||
// Pre-create task #1 so getNextTaskId returns 1, but file exists
|
||
// Actually: getNextTaskId reads highwatermark + max file ID.
|
||
// So we need to trick it: set highwatermark to 0, have 1.json exist
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Existing', status: 'pending' });
|
||
// Highwatermark not set → getNextTaskId uses max file ID (1) + 1 = 2
|
||
// So this can't naturally trigger. Let's force: set HWM to 0, file 1 exists.
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), '0');
|
||
// Now getNextTaskId: max(files)=1, max(hwm)=0, next=2. Still won't collide.
|
||
// This scenario is actually protected by the HWM logic. Good — confirms no dup.
|
||
const { stdout, exitCode } = run(claudeDir, ['task', 'create', '--subject', 'New']);
|
||
expect(exitCode).toBe(0);
|
||
expect(JSON.parse(stdout).id).toBe('2');
|
||
});
|
||
|
||
// --- parseArgs: flag followed by another flag ---
|
||
it('parseArgs: --from followed by --text sets from=true, not "--text"', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Task',
|
||
status: 'pending',
|
||
owner: 'bob',
|
||
});
|
||
// --from is immediately followed by --text (which starts with -)
|
||
// So parseArgs should set from=true (boolean), text='Hello'
|
||
const { exitCode } = run(claudeDir, ['task', 'comment', '1', '--from', '--text', 'Hello']);
|
||
// from=true → not a string → defaults to 'agent'
|
||
expect(exitCode).toBe(0);
|
||
const comments = readTask(claudeDir, '1').comments as { author: string; text: string }[];
|
||
expect(comments[0].author).toBe('agent'); // not "--text"
|
||
expect(comments[0].text).toBe('Hello');
|
||
});
|
||
|
||
// --- reviewApprove without --notify-owner creates NO inbox but DOES record comment ---
|
||
it('review approve without --notify-owner does NOT create inbox but records comment', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Feature',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
run(claudeDir, ['review', 'approve', '1']); // no --notify-owner
|
||
expect(readInbox(claudeDir, 'bob')).toEqual([]);
|
||
// Comment is still recorded
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_approved');
|
||
});
|
||
|
||
// --- request-changes: verify ALL four side effects ---
|
||
it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'PR',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
run(claudeDir, [
|
||
'review',
|
||
'request-changes',
|
||
'1',
|
||
'--comment',
|
||
'Missing tests',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
// 1) Kanban cleared
|
||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||
// 2) Status changed to in_progress
|
||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||
// 3) Review comment recorded
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_request');
|
||
expect(comments[0].author).toBe('alice');
|
||
expect(comments[0].text).toBe('Missing tests');
|
||
// 4) Inbox message sent
|
||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
expect(inbox).toHaveLength(1);
|
||
expect(inbox[0].from).toBe('alice');
|
||
expect(String(inbox[0].text)).toContain('Missing tests');
|
||
expect(String(inbox[0].text)).toContain('Please fix');
|
||
});
|
||
|
||
// --- Alternative flag names: --teamName ---
|
||
it('accepts --teamName as alternative to --team', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Alt flag', status: 'pending' });
|
||
try {
|
||
const stdout = execFileSync(
|
||
process.execPath,
|
||
[scriptPath, '--claude-dir', claudeDir, '--teamName', TEAM, 'task', 'get', '1'],
|
||
{ encoding: 'utf8', timeout: 10_000 }
|
||
);
|
||
expect(JSON.parse(stdout).subject).toBe('Alt flag');
|
||
} catch {
|
||
expect.fail('--teamName flag should be accepted');
|
||
}
|
||
});
|
||
|
||
// --- Alternative flag: --claudeDir ---
|
||
it('accepts --claudeDir as alternative to --claude-dir', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'claudeDir alt', status: 'pending' });
|
||
try {
|
||
const stdout = execFileSync(
|
||
process.execPath,
|
||
[scriptPath, '--claudeDir', claudeDir, '--team', TEAM, 'task', 'get', '1'],
|
||
{ encoding: 'utf8', timeout: 10_000 }
|
||
);
|
||
expect(JSON.parse(stdout).subject).toBe('claudeDir alt');
|
||
} catch {
|
||
expect.fail('--claudeDir flag should be accepted');
|
||
}
|
||
});
|
||
|
||
// --- Inbox isolation between members ---
|
||
it('messages to different members are isolated', () => {
|
||
run(claudeDir, ['message', 'send', '--to', 'alice', '--text', 'For Alice', '--from', 'bob']);
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'For Bob', '--from', 'alice']);
|
||
const aliceInbox = readInbox(claudeDir, 'alice') as Record<string, unknown>[];
|
||
const bobInbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
expect(aliceInbox).toHaveLength(1);
|
||
expect(bobInbox).toHaveLength(1);
|
||
expect(aliceInbox[0].text).toBe('For Alice');
|
||
expect(bobInbox[0].text).toBe('For Bob');
|
||
});
|
||
|
||
// --- Empty string arguments rejected ---
|
||
it('task create with empty --subject fails', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'create', '--subject', '']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Missing --subject');
|
||
});
|
||
|
||
it('task comment with empty --text fails', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'pending' });
|
||
const { exitCode, stderr } = run(claudeDir, ['task', 'comment', '1', '--text', '']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Missing --text');
|
||
});
|
||
|
||
it('message send with empty --text fails', () => {
|
||
const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--to', 'bob', '--text', '']);
|
||
expect(exitCode).not.toBe(0);
|
||
expect(stderr).toContain('Missing --text');
|
||
});
|
||
|
||
// --- Invalid explicit status + owner → falls back to in_progress ---
|
||
it('task create with invalid --status and --owner defaults to in_progress', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Fallback status',
|
||
'--owner',
|
||
'bob',
|
||
'--status',
|
||
'bogus',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect(JSON.parse(stdout).status).toBe('in_progress');
|
||
});
|
||
|
||
it('task create with invalid --status and NO owner defaults to pending', () => {
|
||
const { stdout, exitCode } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Fallback no owner',
|
||
'--status',
|
||
'bogus',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect(JSON.parse(stdout).status).toBe('pending');
|
||
});
|
||
|
||
// --- writeTask verification: stdout matches disk ---
|
||
it('task create stdout is byte-identical to file on disk', () => {
|
||
const { stdout } = run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Verify sync',
|
||
'--description',
|
||
'Detailed desc',
|
||
'--owner',
|
||
'bob',
|
||
'--from',
|
||
'alice',
|
||
'--active-form',
|
||
'Verifying sync',
|
||
]);
|
||
const fromStdout = JSON.parse(stdout);
|
||
const fromDisk = readTask(claudeDir, fromStdout.id);
|
||
expect(fromDisk.subject).toBe(fromStdout.subject);
|
||
expect(fromDisk.description).toBe(fromStdout.description);
|
||
expect(fromDisk.owner).toBe(fromStdout.owner);
|
||
expect(fromDisk.createdBy).toBe(fromStdout.createdBy);
|
||
expect(fromDisk.activeForm).toBe(fromStdout.activeForm);
|
||
expect(fromDisk.status).toBe(fromStdout.status);
|
||
expect(fromDisk.blocks).toEqual(fromStdout.blocks);
|
||
expect(fromDisk.blockedBy).toEqual(fromStdout.blockedBy);
|
||
});
|
||
|
||
// --- Comment inbox notification: exact format verification ---
|
||
it('comment inbox notification has correct format', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Auth module',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, [
|
||
'task',
|
||
'comment',
|
||
'1',
|
||
'--text',
|
||
'Please add error handling',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
expect(inbox).toHaveLength(1);
|
||
expect(inbox[0].from).toBe('alice');
|
||
expect(String(inbox[0].text)).toContain('Comment on task #1');
|
||
expect(String(inbox[0].text)).toContain('Auth module');
|
||
expect(String(inbox[0].text)).toContain('Please add error handling');
|
||
expect(String(inbox[0].summary)).toContain('#1');
|
||
expect(inbox[0].read).toBe(false);
|
||
expect(String(inbox[0].timestamp)).toMatch(ISO_RE);
|
||
expect(String(inbox[0].messageId)).toMatch(UUID_RE);
|
||
});
|
||
|
||
// --- Comment on task without owner: no crash, no inbox ---
|
||
it('comment on task without owner does not crash and sends no inbox', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Orphan', status: 'pending' });
|
||
const { exitCode } = run(claudeDir, [
|
||
'task',
|
||
'comment',
|
||
'1',
|
||
'--text',
|
||
'Note to self',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
expect((readTask(claudeDir, '1').comments as unknown[]).length).toBe(1);
|
||
// No inboxes dir should exist
|
||
const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes');
|
||
try {
|
||
expect(fs.readdirSync(inboxDir)).toHaveLength(0);
|
||
} catch {
|
||
// dir doesn't exist — correct
|
||
}
|
||
});
|
||
|
||
// --- Briefing kanban override: completed task in review column ---
|
||
it('briefing shows kanban column override, not raw status', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'In review task',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
// Kanban says review, status says completed — briefing should say REVIEW
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
const yourSection = stdout.slice(stdout.indexOf('YOUR TASKS'));
|
||
expect(yourSection).toContain('[REVIEW]');
|
||
expect(yourSection).not.toContain('[DONE]');
|
||
});
|
||
|
||
// --- Port boundaries ---
|
||
it('process register with port=1 (min valid)', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'min-port',
|
||
'--port',
|
||
'1',
|
||
]);
|
||
expect((readProcesses(claudeDir) as Record<string, unknown>[])[0].port).toBe(1);
|
||
});
|
||
|
||
it('process register with port=65535 (max valid)', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'max-port',
|
||
'--port',
|
||
'65535',
|
||
]);
|
||
expect((readProcesses(claudeDir) as Record<string, unknown>[])[0].port).toBe(65535);
|
||
});
|
||
|
||
it('process register with port=65536 (over max) -> ignored', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'over-port',
|
||
'--port',
|
||
'65536',
|
||
]);
|
||
expect((readProcesses(claudeDir) as Record<string, unknown>[])[0].port).toBeUndefined();
|
||
});
|
||
|
||
// --- set-status idempotent ---
|
||
it('set-status to same value is idempotent', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'in_progress' });
|
||
expect(run(claudeDir, ['task', 'set-status', '1', 'in_progress']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||
});
|
||
|
||
// --- Corrupted highwatermark ---
|
||
it('corrupted highwatermark (non-numeric) falls back to max file ID', () => {
|
||
writeTask(claudeDir, '3', { id: '3', subject: 'Three', status: 'pending' });
|
||
fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), 'garbage');
|
||
// readJson parses "garbage" → JSON.parse throws. readJson only catches ENOENT.
|
||
// This SHOULD crash the script on task create. Let's verify behavior.
|
||
const result = run(claudeDir, ['task', 'create', '--subject', 'After garbage HWM']);
|
||
// If script handles it gracefully, ID should be > 3. If it crashes, exitCode != 0.
|
||
if (result.exitCode === 0) {
|
||
expect(Number(JSON.parse(result.stdout).id)).toBeGreaterThan(3);
|
||
} else {
|
||
// Script crashes on invalid JSON in .highwatermark — this IS a bug.
|
||
// Document the behavior: corrupted HWM causes task create to fail.
|
||
expect(result.stderr.length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
// --- Briefing: effective column logic per status ---
|
||
it('briefing: pending→TODO, in_progress→IN PROGRESS, completed→DONE columns', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Pending task',
|
||
status: 'pending',
|
||
owner: 'bob',
|
||
});
|
||
writeTask(claudeDir, '2', {
|
||
id: '2',
|
||
subject: 'Active task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
writeTask(claudeDir, '3', {
|
||
id: '3',
|
||
subject: 'Done task',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('#1 [TODO]');
|
||
expect(stdout).toContain('#2 [IN_PROGRESS]');
|
||
expect(stdout).toContain('#3 [DONE]');
|
||
});
|
||
|
||
// --- Comment self-notification: owner comments on own task ---
|
||
it('comment by task owner does NOT self-notify', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'My task',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['task', 'comment', '1', '--text', 'Progress note', '--from', 'bob']);
|
||
expect(readInbox(claudeDir, 'bob')).toEqual([]);
|
||
});
|
||
|
||
// --- Kanban set-column updates movedAt on re-set ---
|
||
it('kanban set-column updates movedAt timestamp on re-assignment', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'completed' });
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
const movedAt1 = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1']
|
||
.movedAt;
|
||
|
||
// Small delay to ensure different timestamp
|
||
const start = Date.now();
|
||
while (Date.now() - start < 5);
|
||
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'approved']);
|
||
const movedAt2 = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1']
|
||
.movedAt;
|
||
|
||
expect(movedAt1).not.toBe(movedAt2);
|
||
expect(String(movedAt2)).toMatch(ISO_RE);
|
||
});
|
||
|
||
// --- Task create notification: AGENT_BLOCK markers ---
|
||
it('task create notification contains AGENT_BLOCK markers and tool instructions', () => {
|
||
run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Build feature',
|
||
'--owner',
|
||
'bob',
|
||
'--notify',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
const text = String(inbox[0].text);
|
||
// Must contain agent block markers (```info_for_agent ... ```)
|
||
expect(text).toContain('info_for_agent');
|
||
expect(text).toContain('task start');
|
||
expect(text).toContain('task complete');
|
||
});
|
||
|
||
// --- readKanbanState fallback with corrupted kanban ---
|
||
it('kanban set-column works even with corrupted kanban-state.json', () => {
|
||
writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'completed' });
|
||
const kanbanPath = path.join(claudeDir, 'teams', TEAM, 'kanban-state.json');
|
||
fs.writeFileSync(kanbanPath, '{corrupted!!!}');
|
||
// readKanbanState: readJson will throw (not ENOENT) → script crashes
|
||
const result = run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
if (result.exitCode === 0) {
|
||
expect(
|
||
(readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'].column
|
||
).toBe('review');
|
||
} else {
|
||
// Documents that corrupted kanban-state.json crashes kanban operations
|
||
expect(result.stderr.length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
// --- Multiple processes in same team ---
|
||
it('multiple processes coexist independently', () => {
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
String(process.pid),
|
||
'--label',
|
||
'server-1',
|
||
'--port',
|
||
'3000',
|
||
]);
|
||
run(claudeDir, [
|
||
'process',
|
||
'register',
|
||
'--pid',
|
||
'99998',
|
||
'--label',
|
||
'server-2',
|
||
'--port',
|
||
'3001',
|
||
]);
|
||
const procs = readProcesses(claudeDir) as Record<string, unknown>[];
|
||
expect(procs).toHaveLength(2);
|
||
expect(procs.map((p) => p.label)).toEqual(['server-1', 'server-2']);
|
||
// Unregister one, other stays
|
||
run(claudeDir, ['process', 'unregister', '--pid', '99998']);
|
||
const after = readProcesses(claudeDir) as Record<string, unknown>[];
|
||
expect(after).toHaveLength(1);
|
||
expect(after[0].label).toBe('server-1');
|
||
});
|
||
|
||
// --- review approve also writes to kanban (column=approved) + comment ---
|
||
it('review approve sets kanban column to approved with movedAt and records comment', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'PR task',
|
||
status: 'completed',
|
||
owner: 'bob',
|
||
});
|
||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||
run(claudeDir, ['review', 'approve', '1']);
|
||
const entry = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'];
|
||
expect(entry.column).toBe('approved');
|
||
expect(String(entry.movedAt)).toMatch(ISO_RE);
|
||
// Review comment recorded
|
||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||
expect(comments).toHaveLength(1);
|
||
expect(comments[0].type).toBe('review_approved');
|
||
});
|
||
|
||
// --- Task create without --description defaults to subject ---
|
||
it('task description defaults to subject when omitted', () => {
|
||
run(claudeDir, ['task', 'create', '--subject', 'Self-describing task']);
|
||
expect(readTask(claudeDir, '1').description).toBe('Self-describing task');
|
||
});
|
||
|
||
// --- Task create with --description different from subject ---
|
||
it('task description stored separately from subject when provided', () => {
|
||
run(claudeDir, [
|
||
'task',
|
||
'create',
|
||
'--subject',
|
||
'Short title',
|
||
'--description',
|
||
'A much longer and more detailed description of the work',
|
||
]);
|
||
const task = readTask(claudeDir, '1');
|
||
expect(task.subject).toBe('Short title');
|
||
expect(task.description).toBe('A much longer and more detailed description of the work');
|
||
});
|
||
|
||
// --- Briefing: description != subject shown, description == subject hidden ---
|
||
it('briefing hides description when identical to subject', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Same as desc',
|
||
description: 'Same as desc',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('Same as desc');
|
||
expect(stdout).not.toContain('Description:');
|
||
});
|
||
|
||
it('briefing shows description when different from subject', () => {
|
||
writeTask(claudeDir, '1', {
|
||
id: '1',
|
||
subject: 'Title',
|
||
description: 'A detailed description that differs from the title',
|
||
status: 'in_progress',
|
||
owner: 'bob',
|
||
});
|
||
const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']);
|
||
expect(stdout).toContain('Description:');
|
||
expect(stdout).toContain('A detailed description that differs from the title');
|
||
});
|
||
|
||
// --- Inbox corrupted: sendInboxMessage with corrupted existing inbox ---
|
||
it('message send to inbox with non-array content recovers', () => {
|
||
// Pre-write corrupted inbox (object instead of array)
|
||
const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes');
|
||
fs.mkdirSync(inboxDir, { recursive: true });
|
||
fs.writeFileSync(path.join(inboxDir, 'bob.json'), '{"not": "an array"}');
|
||
|
||
const { exitCode } = run(claudeDir, [
|
||
'message',
|
||
'send',
|
||
'--to',
|
||
'bob',
|
||
'--text',
|
||
'Recovery msg',
|
||
'--from',
|
||
'alice',
|
||
]);
|
||
expect(exitCode).toBe(0);
|
||
// readJson returns the object, `Array.isArray` fails → uses empty list.
|
||
// So message is the only item.
|
||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||
expect(inbox.length).toBe(1);
|
||
expect(inbox[0].text).toBe('Recovery msg');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// Edge cases
|
||
// =========================================================================
|
||
describe('edge cases', () => {
|
||
it('empty tasks dir -> empty list', () => {
|
||
expect(JSON.parse(run(claudeDir, ['task', 'list']).stdout)).toEqual([]);
|
||
});
|
||
|
||
it('missing tasks dir -> empty list', () => {
|
||
fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true });
|
||
expect(JSON.parse(run(claudeDir, ['task', 'list']).stdout)).toEqual([]);
|
||
});
|
||
|
||
it('missing tasks dir -> briefing still works', () => {
|
||
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('task create auto-creates tasks directory', () => {
|
||
fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true });
|
||
expect(run(claudeDir, ['task', 'create', '--subject', 'Auto-dir']).exitCode).toBe(0);
|
||
expect(readTask(claudeDir, '1').subject).toBe('Auto-dir');
|
||
});
|
||
|
||
it('lead name inference falls back to first member when no lead role', () => {
|
||
fs.writeFileSync(
|
||
path.join(claudeDir, 'teams', TEAM, 'config.json'),
|
||
JSON.stringify({ name: TEAM, members: [{ name: 'charlie' }, { name: 'diana' }] })
|
||
);
|
||
run(claudeDir, ['message', 'send', '--to', 'diana', '--text', 'Hi']);
|
||
expect((readInbox(claudeDir, 'diana')[0] as Record<string, unknown>).from).toBe('charlie');
|
||
});
|
||
|
||
it('lead name inference falls back to "team-lead" with empty members', () => {
|
||
fs.writeFileSync(
|
||
path.join(claudeDir, 'teams', TEAM, 'config.json'),
|
||
JSON.stringify({ name: TEAM, members: [] })
|
||
);
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']);
|
||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).from).toBe('team-lead');
|
||
});
|
||
|
||
it('lead name inference falls back to "team-lead" with missing config', () => {
|
||
fs.unlinkSync(path.join(claudeDir, 'teams', TEAM, 'config.json'));
|
||
run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']);
|
||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).from).toBe('team-lead');
|
||
});
|
||
});
|
||
});
|