Files
Codewalkers/apps/server/agent/file-io.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

485 lines
15 KiB
TypeScript

/**
* File-Based Agent I/O Tests
*/
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'crypto';
import {
writeInputFiles,
readSummary,
readPhaseFiles,
readTaskFiles,
readDecisionFiles,
readPageFiles,
generateId,
writeErrandManifest,
} from './file-io.js';
import { buildErrandPrompt } from './prompts/index.js';
import type { Initiative, Phase, Task } from '../db/schema.js';
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `cw-file-io-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe('generateId', () => {
it('returns a non-empty string', () => {
const id = generateId();
expect(id).toBeTruthy();
expect(typeof id).toBe('string');
});
it('returns unique values', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
expect(ids.size).toBe(100);
});
});
describe('writeInputFiles', () => {
it('writes initiative.md with frontmatter', async () => {
const initiative: Initiative = {
id: 'init-1',
name: 'Test Initiative',
status: 'active',
branch: 'cw/test-initiative',
executionMode: 'review_per_phase',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
};
await writeInputFiles({ agentWorkdir: testDir, initiative });
const filePath = join(testDir, '.cw', 'input', 'initiative.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes phase.md with frontmatter', async () => {
const phase = {
id: 'phase-1',
initiativeId: 'init-1',
number: 1,
name: 'Phase One',
content: 'First phase',
status: 'pending',
mergeBase: null,
createdAt: new Date(),
updatedAt: new Date(),
} as Phase;
await writeInputFiles({ agentWorkdir: testDir, phase });
const filePath = join(testDir, '.cw', 'input', 'phase.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes task.md with frontmatter', async () => {
const task = {
id: 'task-1',
name: 'Test Task',
description: 'Do the thing',
category: 'execute',
type: 'auto',
priority: 'medium',
status: 'pending',
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as Task;
await writeInputFiles({ agentWorkdir: testDir, task });
const filePath = join(testDir, '.cw', 'input', 'task.md');
expect(existsSync(filePath)).toBe(true);
});
it('writes pages to pages/ subdirectory', async () => {
await writeInputFiles({
agentWorkdir: testDir,
pages: [
{ id: 'page-1', parentPageId: null, title: 'Root', content: null, sortOrder: 0 },
{ id: 'page-2', parentPageId: 'page-1', title: 'Child', content: null, sortOrder: 1 },
],
});
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-1.md'))).toBe(true);
expect(existsSync(join(testDir, '.cw', 'input', 'pages', 'page-2.md'))).toBe(true);
});
it('handles empty options without error', async () => {
await writeInputFiles({ agentWorkdir: testDir });
expect(existsSync(join(testDir, '.cw', 'input'))).toBe(true);
});
it('writes context/index.json grouping tasks by phaseId', async () => {
await writeInputFiles({
agentWorkdir: testDir,
tasks: [
{ id: 't1', name: 'Task A', phaseId: 'ph1', status: 'pending', category: 'execute', type: 'auto', priority: 'medium' } as Task,
{ id: 't2', name: 'Task B', phaseId: 'ph1', status: 'completed', category: 'execute', type: 'auto', priority: 'medium', summary: 'Done' } as Task,
{ id: 't3', name: 'Task C', phaseId: 'ph2', status: 'pending', category: 'research', type: 'auto', priority: 'high' } as Task,
],
});
const indexPath = join(testDir, '.cw', 'input', 'context', 'index.json');
expect(existsSync(indexPath)).toBe(true);
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
expect(index.tasksByPhase.ph1).toHaveLength(2);
expect(index.tasksByPhase.ph2).toHaveLength(1);
expect(index.tasksByPhase.ph1[0]).toEqual({
file: 'context/tasks/t1.md',
id: 't1',
name: 'Task A',
status: 'pending',
});
});
it('does not write context/index.json when no tasks', async () => {
await writeInputFiles({ agentWorkdir: testDir });
expect(existsSync(join(testDir, '.cw', 'input', 'context', 'index.json'))).toBe(false);
});
});
describe('readSummary', () => {
it('reads SUMMARY.md with frontmatter', async () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
join(outputDir, 'SUMMARY.md'),
`---
files_modified:
- src/foo.ts
- src/bar.ts
---
Task completed successfully. Refactored the module.
`,
'utf-8',
);
const summary = await readSummary(testDir);
expect(summary).not.toBeNull();
expect(summary!.body).toBe('Task completed successfully. Refactored the module.');
expect(summary!.filesModified).toEqual(['src/foo.ts', 'src/bar.ts']);
});
it('returns null when SUMMARY.md does not exist', async () => {
const summary = await readSummary(testDir);
expect(summary).toBeNull();
});
it('handles SUMMARY.md without frontmatter', async () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(join(outputDir, 'SUMMARY.md'), 'Just plain text\n', 'utf-8');
const summary = await readSummary(testDir);
expect(summary).not.toBeNull();
expect(summary!.body).toBe('Just plain text');
expect(summary!.filesModified).toBeUndefined();
});
it('handles empty files_modified', async () => {
const outputDir = join(testDir, '.cw', 'output');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
join(outputDir, 'SUMMARY.md'),
`---
files_modified: []
---
Done.
`,
'utf-8',
);
const summary = await readSummary(testDir);
expect(summary!.filesModified).toEqual([]);
});
});
describe('readPhaseFiles', () => {
it('reads phase files from phases/ directory', async () => {
const phasesDir = join(testDir, '.cw', 'output', 'phases');
mkdirSync(phasesDir, { recursive: true });
writeFileSync(
join(phasesDir, 'abc123.md'),
`---
title: Database Schema
dependencies:
- xyz789
---
Create the user tables and auth schema.
`,
'utf-8',
);
const phases = await readPhaseFiles(testDir);
expect(phases).toHaveLength(1);
expect(phases[0].id).toBe('abc123');
expect(phases[0].title).toBe('Database Schema');
expect(phases[0].dependencies).toEqual(['xyz789']);
expect(phases[0].body).toBe('Create the user tables and auth schema.');
});
it('returns empty array when directory does not exist', async () => {
const phases = await readPhaseFiles(testDir);
expect(phases).toEqual([]);
});
it('handles phases with no dependencies', async () => {
const phasesDir = join(testDir, '.cw', 'output', 'phases');
mkdirSync(phasesDir, { recursive: true });
writeFileSync(
join(phasesDir, 'p1.md'),
`---
title: Foundation
---
Set up the base.
`,
'utf-8',
);
const phases = await readPhaseFiles(testDir);
expect(phases[0].dependencies).toEqual([]);
});
});
describe('readTaskFiles', () => {
it('reads task files from tasks/ directory', async () => {
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(
join(tasksDir, 'task-1.md'),
`---
title: Implement login
category: execute
type: auto
dependencies:
- task-0
---
Build the login form and submit handler.
`,
'utf-8',
);
const tasks = await readTaskFiles(testDir);
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe('task-1');
expect(tasks[0].title).toBe('Implement login');
expect(tasks[0].category).toBe('execute');
expect(tasks[0].type).toBe('auto');
expect(tasks[0].dependencies).toEqual(['task-0']);
expect(tasks[0].body).toBe('Build the login form and submit handler.');
});
it('defaults category and type when missing', async () => {
const tasksDir = join(testDir, '.cw', 'output', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 't1.md'), `---\ntitle: Minimal\n---\nDo it.\n`, 'utf-8');
const tasks = await readTaskFiles(testDir);
expect(tasks[0].category).toBe('execute');
expect(tasks[0].type).toBe('auto');
});
it('returns empty array when directory does not exist', async () => {
expect(await readTaskFiles(testDir)).toEqual([]);
});
});
describe('readDecisionFiles', () => {
it('reads decision files from decisions/ directory', async () => {
const decisionsDir = join(testDir, '.cw', 'output', 'decisions');
mkdirSync(decisionsDir, { recursive: true });
writeFileSync(
join(decisionsDir, 'd1.md'),
`---
topic: Authentication
decision: Use JWT
reason: Stateless and scalable
---
Additional context about the decision.
`,
'utf-8',
);
const decisions = await readDecisionFiles(testDir);
expect(decisions).toHaveLength(1);
expect(decisions[0].id).toBe('d1');
expect(decisions[0].topic).toBe('Authentication');
expect(decisions[0].decision).toBe('Use JWT');
expect(decisions[0].reason).toBe('Stateless and scalable');
expect(decisions[0].body).toBe('Additional context about the decision.');
});
it('returns empty array when directory does not exist', async () => {
expect(await readDecisionFiles(testDir)).toEqual([]);
});
});
describe('readPageFiles', () => {
it('reads page files from pages/ directory', async () => {
const pagesDir = join(testDir, '.cw', 'output', 'pages');
mkdirSync(pagesDir, { recursive: true });
writeFileSync(
join(pagesDir, 'page-abc.md'),
`---
title: Architecture Overview
summary: Updated the overview section
---
# Architecture
New content for the page.
`,
'utf-8',
);
const pages = await readPageFiles(testDir);
expect(pages).toHaveLength(1);
expect(pages[0].pageId).toBe('page-abc');
expect(pages[0].title).toBe('Architecture Overview');
expect(pages[0].summary).toBe('Updated the overview section');
expect(pages[0].body).toBe('# Architecture\n\nNew content for the page.');
});
it('returns empty array when directory does not exist', async () => {
expect(await readPageFiles(testDir)).toEqual([]);
});
it('ignores non-.md files', async () => {
const pagesDir = join(testDir, '.cw', 'output', 'pages');
mkdirSync(pagesDir, { recursive: true });
writeFileSync(join(pagesDir, 'readme.txt'), 'not a page', 'utf-8');
writeFileSync(join(pagesDir, 'page1.md'), '---\ntitle: Page 1\n---\nContent.\n', 'utf-8');
const pages = await readPageFiles(testDir);
expect(pages).toHaveLength(1);
});
});
describe('writeErrandManifest', () => {
let errandTestDir: string;
beforeEach(() => {
errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`);
mkdirSync(errandTestDir, { recursive: true });
});
afterAll(() => {
// no-op: beforeEach creates dirs, afterEach in outer scope cleans up
});
it('writes manifest.json with correct shape', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json');
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
expect(manifest).toEqual({
errandId: 'errand-abc',
agentId: 'agent-1',
agentName: 'swift-owl',
mode: 'errand',
});
expect('files' in manifest).toBe(false);
expect('contextFiles' in manifest).toBe(false);
});
it('writes errand.md with correct YAML frontmatter', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md');
expect(existsSync(errandMdPath)).toBe(true);
const content = readFileSync(errandMdPath, 'utf-8');
expect(content).toContain('id: errand-abc');
expect(content).toContain('description: fix typo');
expect(content).toContain('branch: cw/errand/fix-typo-errandabc');
expect(content).toContain('project: my-project');
});
it('writes expected-pwd.txt with agentWorkdir path', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt');
expect(existsSync(pwdPath)).toBe(true);
const content = readFileSync(pwdPath, 'utf-8').trim();
expect(content).toBe(errandTestDir);
});
it('creates input directory if it does not exist', async () => {
const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`);
mkdirSync(freshDir, { recursive: true });
await writeErrandManifest({
agentWorkdir: freshDir,
errandId: 'errand-xyz',
description: 'add feature',
branch: 'cw/errand/add-feature-errandxyz',
projectName: 'other-project',
agentId: 'agent-2',
agentName: 'brave-eagle',
});
expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true);
expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true);
expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true);
rmSync(freshDir, { recursive: true, force: true });
});
});
describe('buildErrandPrompt', () => {
it('includes the description in the output', () => {
const result = buildErrandPrompt('fix typo in README');
expect(result).toContain('fix typo in README');
});
it('includes signal.json instruction', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('signal.json');
expect(result).toContain('"status": "done"');
});
it('includes error signal format', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('"status": "error"');
});
});