2026-08-18 02:59:04 +03:00
|
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
|
|
import fsPromises from 'node:fs/promises';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
|
|
|
|
import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
|
|
|
|
|
|
|
|
|
|
const PROJECT_ID = 'path_dGVzdA';
|
|
|
|
|
|
|
|
|
|
let projectsDirPath;
|
|
|
|
|
let runtime;
|
|
|
|
|
let idCounter;
|
|
|
|
|
|
|
|
|
|
const legacyConfigPath = () => path.join(projectsDirPath, `${PROJECT_ID}.json`);
|
|
|
|
|
const contextPath = () => path.join(projectsDirPath, PROJECT_ID, 'context.json');
|
|
|
|
|
const plansDir = () => path.join(projectsDirPath, PROJECT_ID, 'plans');
|
|
|
|
|
|
|
|
|
|
const writeJson = async (filePath, value) => {
|
|
|
|
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
projectsDirPath = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-context-'));
|
|
|
|
|
idCounter = 0;
|
|
|
|
|
runtime = createProjectContextRuntime({
|
|
|
|
|
fsPromises,
|
|
|
|
|
path,
|
|
|
|
|
projectsDirPath,
|
|
|
|
|
createId: () => `plan-${++idCounter}`,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await fsPromises.rm(projectsDirPath, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('projectId validation', () => {
|
|
|
|
|
test('rejects traversal and empty ids', async () => {
|
|
|
|
|
await expect(runtime.readContext('../escape')).rejects.toThrow('unsupported characters');
|
|
|
|
|
await expect(runtime.readContext('a/b')).rejects.toThrow('unsupported characters');
|
|
|
|
|
await expect(runtime.readContext('')).rejects.toThrow('projectId is required');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('readContext', () => {
|
|
|
|
|
test('missing file is authoritative empty', async () => {
|
|
|
|
|
expect(await runtime.readContext(PROJECT_ID)).toEqual({
|
|
|
|
|
version: 2,
|
|
|
|
|
notes: [],
|
|
|
|
|
todos: [],
|
|
|
|
|
plans: [],
|
2026-09-07 17:50:55 +03:00
|
|
|
sharedPlansDir: null,
|
2026-08-18 02:59:04 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('malformed stored context fails instead of reading as empty', async () => {
|
|
|
|
|
await fsPromises.mkdir(path.dirname(contextPath()), { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(contextPath(), '{ not json', 'utf8');
|
|
|
|
|
await expect(runtime.readContext(PROJECT_ID)).rejects.toThrow('malformed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('drops malformed todo and plan entries without failing the read', async () => {
|
|
|
|
|
await writeJson(contextPath(), {
|
|
|
|
|
version: 2,
|
|
|
|
|
notes: [{ id: 'n1', body: 'kept', createdAt: 1, updatedAt: 1, source: 'manual' }],
|
|
|
|
|
todos: [{ id: 'a', text: 'ok', completed: false, createdAt: 1 }, { id: '', text: 'no id' }, { text: 'no id' }],
|
|
|
|
|
plans: [
|
|
|
|
|
{ id: 'p1', file: 'a.md', title: 'A', createdAt: 2 },
|
|
|
|
|
{ id: 'p2', file: '../escape.md', title: 'Bad', createdAt: 3 },
|
|
|
|
|
{ id: 'p3', file: 'no-extension', createdAt: 4 },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
|
|
|
|
|
expect(context.todos.map((todo) => todo.id)).toEqual(['a']);
|
|
|
|
|
expect(context.plans.map((plan) => plan.id)).toEqual(['p1']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clamps a note body to the maximum length', async () => {
|
|
|
|
|
await writeJson(contextPath(), {
|
|
|
|
|
version: 2,
|
|
|
|
|
notes: [{ id: 'n1', body: 'x'.repeat(5000), createdAt: 1, updatedAt: 1 }],
|
|
|
|
|
todos: [],
|
|
|
|
|
plans: [],
|
|
|
|
|
});
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).notes[0].body).toHaveLength(3000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('converts a version 1 string note into a single entry', async () => {
|
|
|
|
|
await writeJson(contextPath(), { version: 1, notes: 'legacy blob', todos: [], plans: [] });
|
|
|
|
|
|
|
|
|
|
const notes = (await runtime.readContext(PROJECT_ID)).notes;
|
|
|
|
|
expect(notes).toHaveLength(1);
|
|
|
|
|
expect(notes[0].body).toBe('legacy blob');
|
|
|
|
|
expect(notes[0].source).toBe('manual');
|
|
|
|
|
expect(notes[0].pinned).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an empty version 1 string converts to no notes at all', async () => {
|
|
|
|
|
await writeJson(contextPath(), { version: 1, notes: ' ', todos: [], plans: [] });
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).notes).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('newest note is listed first', async () => {
|
|
|
|
|
await writeJson(contextPath(), {
|
|
|
|
|
version: 2,
|
|
|
|
|
notes: [
|
|
|
|
|
{ id: 'old', body: 'old', createdAt: 1, updatedAt: 1 },
|
|
|
|
|
{ id: 'new', body: 'new', createdAt: 9, updatedAt: 9 },
|
|
|
|
|
],
|
|
|
|
|
todos: [],
|
|
|
|
|
plans: [],
|
|
|
|
|
});
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).notes.map((note) => note.id)).toEqual(['new', 'old']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('legacy migration', () => {
|
|
|
|
|
test('moves the three keys out of the client-owned config and preserves the rest', async () => {
|
|
|
|
|
await fsPromises.mkdir(plansDir(), { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(path.join(plansDir(), '10-old.md'), '# Old plan\n\nbody here', 'utf8');
|
|
|
|
|
await writeJson(legacyConfigPath(), {
|
|
|
|
|
projectPath: '/tmp/test',
|
|
|
|
|
'setup-worktree': ['bun install'],
|
|
|
|
|
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
|
|
|
|
|
projectNotes: 'legacy notes',
|
|
|
|
|
projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
|
|
|
|
|
projectPlanFiles: [{ id: 'p1', path: path.join(plansDir(), '10-old.md'), createdAt: 10 }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
|
|
|
|
|
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
|
2026-09-07 17:50:55 +03:00
|
|
|
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]);
|
2026-08-18 02:59:04 +03:00
|
|
|
|
|
|
|
|
const remaining = await readJson(legacyConfigPath());
|
|
|
|
|
expect(remaining).toEqual({
|
|
|
|
|
projectPath: '/tmp/test',
|
|
|
|
|
'setup-worktree': ['bun install'],
|
|
|
|
|
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('recovers a plan whose recorded path points outside the plans directory', async () => {
|
|
|
|
|
const strayPath = path.join(projectsDirPath, 'stray.md');
|
|
|
|
|
await fsPromises.writeFile(strayPath, '# Stray\n\nrecovered', 'utf8');
|
|
|
|
|
await writeJson(legacyConfigPath(), {
|
|
|
|
|
projectPlanFiles: [{ id: 'p1', path: strayPath, createdAt: 10 }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
2026-09-07 17:50:55 +03:00
|
|
|
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]);
|
2026-08-18 02:59:04 +03:00
|
|
|
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('drops a link whose markdown no longer exists anywhere', async () => {
|
|
|
|
|
await writeJson(legacyConfigPath(), {
|
|
|
|
|
projectNotes: 'kept',
|
|
|
|
|
projectPlanFiles: [{ id: 'gone', path: path.join(plansDir(), 'missing.md'), createdAt: 10 }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
|
|
|
|
|
expect(context.plans).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does not run when the legacy config holds no context keys', async () => {
|
|
|
|
|
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
|
2026-08-18 02:59:04 +03:00
|
|
|
await expect(fsPromises.access(contextPath())).rejects.toThrow();
|
|
|
|
|
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is idempotent across repeated reads', async () => {
|
|
|
|
|
await writeJson(legacyConfigPath(), { projectNotes: 'once', projectTodos: [] });
|
|
|
|
|
|
|
|
|
|
const first = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
const second = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(second).toEqual(first);
|
|
|
|
|
expect(await readJson(legacyConfigPath())).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('concurrent reads converge on the same migrated content', async () => {
|
|
|
|
|
await writeJson(legacyConfigPath(), { projectNotes: 'concurrent', projectTodos: [] });
|
|
|
|
|
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
runtime.readContext(PROJECT_ID),
|
|
|
|
|
runtime.readContext(PROJECT_ID),
|
|
|
|
|
runtime.readContext(PROJECT_ID),
|
|
|
|
|
]);
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
expect(result.notes.map((note) => note.body)).toEqual(['concurrent']);
|
|
|
|
|
}
|
|
|
|
|
expect((await readJson(contextPath())).notes[0].body).toBe('concurrent');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('todos', () => {
|
|
|
|
|
test('round-trips through disk', async () => {
|
|
|
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
|
|
|
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).todos).toEqual([
|
|
|
|
|
{ id: 't1', text: 'do it', completed: false, createdAt: 1 },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('preserves notes and plans it does not write', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'keep me' });
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Keep me', body: 'x' });
|
|
|
|
|
|
|
|
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'todo', createdAt: 1 }]);
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
|
|
|
|
|
expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('serializes concurrent writes without losing one', async () => {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
runtime.saveTodos(PROJECT_ID, [{ id: '1', text: 'one', createdAt: 1 }]),
|
|
|
|
|
runtime.saveTodos(PROJECT_ID, [{ id: '2', text: 'two', createdAt: 2 }]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).todos).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clamps oversized todo text', async () => {
|
|
|
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'z'.repeat(300), createdAt: 1 }]);
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).todos[0].text).toHaveLength(120);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('notes', () => {
|
|
|
|
|
test('create returns the stored note and prepends it', async () => {
|
|
|
|
|
const first = await runtime.createNote(PROJECT_ID, { body: 'first' });
|
|
|
|
|
const second = await runtime.createNote(PROJECT_ID, { body: 'second' });
|
|
|
|
|
|
|
|
|
|
expect(first.note.source).toBe('manual');
|
|
|
|
|
expect(first.note.pinned).toBe(false);
|
|
|
|
|
expect(second.context.notes.map((note) => note.body)).toEqual(['second', 'first']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create records provenance for a note distilled from a chat selection', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, {
|
|
|
|
|
body: 'insight',
|
|
|
|
|
source: 'selection',
|
|
|
|
|
origin: { sessionId: 'ses_1', messageId: 'msg_1' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(note.source).toBe('selection');
|
|
|
|
|
expect(note.origin).toEqual({ sessionId: 'ses_1', messageId: 'msg_1' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create drops an origin with no session', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'x', origin: { messageId: 'msg_1' } });
|
|
|
|
|
expect(note.origin).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create rejects an empty body', async () => {
|
|
|
|
|
await expect(runtime.createNote(PROJECT_ID, { body: ' ' })).rejects.toThrow('body is required');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create clamps an oversized body', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'y'.repeat(4000) });
|
|
|
|
|
expect(note.body).toHaveLength(3000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update patches the body and bumps updatedAt without touching createdAt', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'before' });
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
|
|
|
|
|
|
|
|
const result = await runtime.updateNote(PROJECT_ID, note.id, { body: 'after' });
|
|
|
|
|
expect(result.note.body).toBe('after');
|
|
|
|
|
expect(result.note.createdAt).toBe(note.createdAt);
|
|
|
|
|
expect(result.note.updatedAt).toBeGreaterThan(note.updatedAt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinning alone leaves the body and updatedAt untouched', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.updateNote(PROJECT_ID, note.id, { pinned: true });
|
|
|
|
|
expect(result.note.pinned).toBe(true);
|
|
|
|
|
expect(result.note.body).toBe('body');
|
|
|
|
|
expect(result.note.updatedAt).toBe(note.updatedAt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update rejects an empty patch', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
|
|
|
await expect(runtime.updateNote(PROJECT_ID, note.id, {})).rejects.toThrow('body or pinned is required');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update rejects blanking the body', async () => {
|
|
|
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
|
|
|
await expect(runtime.updateNote(PROJECT_ID, note.id, { body: ' ' })).rejects.toThrow('body is required');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update returns null for an unknown note', async () => {
|
|
|
|
|
expect(await runtime.updateNote(PROJECT_ID, 'missing', { body: 'x' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('delete removes only the requested note', async () => {
|
|
|
|
|
const keep = await runtime.createNote(PROJECT_ID, { body: 'keep' });
|
|
|
|
|
const drop = await runtime.createNote(PROJECT_ID, { body: 'drop' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.deleteNote(PROJECT_ID, drop.note.id);
|
|
|
|
|
expect(result.deleted).toBe(true);
|
|
|
|
|
expect(result.context.notes.map((note) => note.id)).toEqual([keep.note.id]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('deleting an unknown note reports no deletion', async () => {
|
|
|
|
|
const result = await runtime.deleteNote(PROJECT_ID, 'missing');
|
|
|
|
|
expect(result.deleted).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('refuses to grow past the note limit', async () => {
|
|
|
|
|
const notes = Array.from({ length: 200 }, (_unused, index) => ({
|
|
|
|
|
id: `n${index}`,
|
|
|
|
|
body: `note ${index}`,
|
|
|
|
|
createdAt: index,
|
|
|
|
|
updatedAt: index,
|
|
|
|
|
}));
|
|
|
|
|
await writeJson(contextPath(), { version: 2, notes, todos: [], plans: [] });
|
|
|
|
|
|
|
|
|
|
await expect(runtime.createNote(PROJECT_ID, { body: 'one too many' })).rejects.toThrow('at most 200 notes');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('concurrent creates all survive', async () => {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
runtime.createNote(PROJECT_ID, { body: 'a' }),
|
|
|
|
|
runtime.createNote(PROJECT_ID, { body: 'b' }),
|
|
|
|
|
runtime.createNote(PROJECT_ID, { body: 'c' }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const bodies = (await runtime.readContext(PROJECT_ID)).notes.map((note) => note.body);
|
|
|
|
|
expect(bodies.sort()).toEqual(['a', 'b', 'c']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('plans', () => {
|
|
|
|
|
test('create writes markdown and returns a readable plan', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'My Plan', body: 'step one' });
|
|
|
|
|
expect(plan.file).toMatch(/^\d+-my-plan\.md$/);
|
|
|
|
|
|
|
|
|
|
const read = await runtime.readPlan(PROJECT_ID, plan.id);
|
|
|
|
|
expect(read.title).toBe('My Plan');
|
|
|
|
|
expect(read.body).toBe('step one');
|
|
|
|
|
expect(read.raw).toBe('# My Plan\n\nstep one');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('newest plan is listed first', async () => {
|
|
|
|
|
const first = await runtime.createPlan(PROJECT_ID, { title: 'First', body: 'a' });
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
|
|
|
const second = await runtime.createPlan(PROJECT_ID, { title: 'Second', body: 'b' });
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.plans.map((entry) => entry.id)).toEqual([second.plan.id, first.plan.id]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reading an unknown plan returns null rather than throwing', async () => {
|
|
|
|
|
expect(await runtime.readPlan(PROJECT_ID, 'nope')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reading a plan whose markdown was deleted returns null', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Doomed', body: 'x' });
|
|
|
|
|
await fsPromises.rm(path.join(plansDir(), plan.file));
|
|
|
|
|
|
|
|
|
|
expect(await runtime.readPlan(PROJECT_ID, plan.id)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('delete removes both the entry and the markdown', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Bye', body: 'x' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.deletePlan(PROJECT_ID, plan.id);
|
|
|
|
|
expect(result.deleted).toBe(true);
|
|
|
|
|
expect(result.context.plans).toEqual([]);
|
|
|
|
|
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('deleting an unknown plan reports no deletion and keeps state', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Stay', body: 'x' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.deletePlan(PROJECT_ID, 'missing');
|
|
|
|
|
expect(result.deleted).toBe(false);
|
|
|
|
|
expect(result.context.plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('plans created in the same millisecond do not collide on a file name', async () => {
|
|
|
|
|
const created = await Promise.all([
|
|
|
|
|
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'a' }),
|
|
|
|
|
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'b' }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const files = new Set(created.map((entry) => entry.plan.file));
|
|
|
|
|
expect(files.size).toBe(2);
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.plans).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update rewrites the markdown verbatim and re-derives the manifest title', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Old', body: 'first' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# New title\n\n- step\n- step two\n' });
|
|
|
|
|
expect(result.plan.title).toBe('New title');
|
|
|
|
|
expect(result.plan.file).toBe(plan.file);
|
|
|
|
|
|
|
|
|
|
expect(await fsPromises.readFile(path.join(plansDir(), plan.file), 'utf8')).toBe('# New title\n\n- step\n- step two\n');
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).plans[0].title).toBe('New title');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update keeps the file name when the title changes', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Original', body: 'x' });
|
|
|
|
|
|
|
|
|
|
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Totally different\n\nx' });
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.plans[0].file).toBe(plan.file);
|
|
|
|
|
expect(context.plans).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update returns null for an unknown plan without writing anything', async () => {
|
|
|
|
|
expect(await runtime.updatePlan(PROJECT_ID, 'missing', { raw: '# X' })).toBeNull();
|
|
|
|
|
await expect(fsPromises.readdir(plansDir())).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update refuses to recreate markdown deleted underneath it', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Gone', body: 'x' });
|
|
|
|
|
await fsPromises.rm(path.join(plansDir(), plan.file));
|
|
|
|
|
|
|
|
|
|
expect(await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Resurrected' })).toBeNull();
|
|
|
|
|
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update rejects a non-string payload', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
|
|
|
await expect(runtime.updatePlan(PROJECT_ID, plan.id, {})).rejects.toThrow('raw is required');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update does not disturb notes or todos', async () => {
|
|
|
|
|
await runtime.createNote(PROJECT_ID, { body: 'keep me' });
|
|
|
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'keep', completed: false, createdAt: 1 }]);
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
|
|
|
|
|
|
|
|
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
|
|
|
|
|
|
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.notes.map((note) => note.body)).toEqual(['keep me']);
|
|
|
|
|
expect(context.todos).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinning a plan leaves its title and file alone', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Pin me', body: 'x' });
|
|
|
|
|
|
|
|
|
|
const result = await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
|
|
|
|
|
expect(result.plan).toEqual({ ...plan, pinned: true });
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).plans[0].pinned).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinning an unknown plan returns null', async () => {
|
|
|
|
|
expect(await runtime.setPlanPinned(PROJECT_ID, 'missing', true)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('editing a plan preserves its pin state', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
|
|
|
await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
|
|
|
|
|
|
|
|
|
|
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
|
|
|
|
|
expect(result.plan.pinned).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an untitled body still produces a titled markdown file', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: '', body: '' });
|
|
|
|
|
const read = await runtime.readPlan(PROJECT_ID, plan.id);
|
|
|
|
|
expect(read.title).toBe('Plan');
|
|
|
|
|
expect(read.body).toBe('');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-07 17:50:55 +03:00
|
|
|
describe('shared plans', () => {
|
|
|
|
|
let sharedDir;
|
|
|
|
|
let sharedRuntime;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
sharedDir = path.join(projectsDirPath, 'repo', 'docs', 'plans');
|
|
|
|
|
sharedRuntime = createProjectContextRuntime({
|
|
|
|
|
fsPromises,
|
|
|
|
|
path,
|
|
|
|
|
projectsDirPath,
|
|
|
|
|
createId: () => `plan-${++idCounter}`,
|
|
|
|
|
resolveSharedPlansDir: async () => sharedDir,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('lists the shared folder\'s markdown files after the personal plans, marked shared, and reads them by file', async () => {
|
|
|
|
|
await fsPromises.mkdir(sharedDir, { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(path.join(sharedDir, 'roadmap.md'), '# Roadmap\n\n- ship it\n');
|
|
|
|
|
await fsPromises.writeFile(path.join(sharedDir, 'notes.txt'), 'not a plan');
|
|
|
|
|
await fsPromises.writeFile(path.join(sharedDir, 'untitled.md'), 'first line only');
|
|
|
|
|
const mine = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
|
|
|
|
|
|
|
|
|
const context = await sharedRuntime.readContext(PROJECT_ID);
|
|
|
|
|
expect(context.sharedPlansDir).toBe(sharedDir);
|
|
|
|
|
expect(context.plans.map((plan) => `${plan.id}:${plan.source}`)).toEqual(expect.arrayContaining([
|
|
|
|
|
`${mine.plan.id}:personal`, 'shared:roadmap.md:shared', 'shared:untitled.md:shared',
|
|
|
|
|
]));
|
|
|
|
|
expect(context.plans[0].id).toBe(mine.plan.id);
|
|
|
|
|
expect(context.plans.find((plan) => plan.id === 'shared:untitled.md').title).toBe('first line only');
|
|
|
|
|
|
|
|
|
|
const read = await sharedRuntime.readPlan(PROJECT_ID, 'shared:roadmap.md');
|
|
|
|
|
expect(read.title).toBe('Roadmap');
|
|
|
|
|
expect(read.raw).toBe('# Roadmap\n\n- ship it\n');
|
|
|
|
|
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:missing.md')).toBeNull();
|
|
|
|
|
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:../escape.md')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a missing shared folder lists nothing; no folder configured lists nothing', async () => {
|
|
|
|
|
expect((await sharedRuntime.readContext(PROJECT_ID)).plans).toEqual([]);
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).sharedPlansDir).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updates and deletes a shared plan in place, verbatim', async () => {
|
|
|
|
|
await fsPromises.mkdir(sharedDir, { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(path.join(sharedDir, 'a.md'), '# A\n');
|
|
|
|
|
const updated = await sharedRuntime.updatePlan(PROJECT_ID, 'shared:a.md', { raw: '# Renamed\n\nbody\n' });
|
|
|
|
|
expect(updated.plan.title).toBe('Renamed');
|
|
|
|
|
expect(updated.plan.source).toBe('shared');
|
|
|
|
|
expect(await fsPromises.readFile(path.join(sharedDir, 'a.md'), 'utf8')).toBe('# Renamed\n\nbody\n');
|
|
|
|
|
expect(await sharedRuntime.updatePlan(PROJECT_ID, 'shared:gone.md', { raw: 'x' })).toBeNull();
|
|
|
|
|
expect(await sharedRuntime.setPlanPinned(PROJECT_ID, 'shared:a.md', true)).toBeNull();
|
|
|
|
|
|
|
|
|
|
const deleted = await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md');
|
|
|
|
|
expect(deleted.deleted).toBe(true);
|
|
|
|
|
await expect(fsPromises.access(path.join(sharedDir, 'a.md'))).rejects.toThrow();
|
|
|
|
|
expect((await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md')).deleted).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('share moves a personal plan into the shared folder and unshare brings it back, avoiding name collisions', async () => {
|
|
|
|
|
const { plan } = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'body' });
|
|
|
|
|
const shared = await sharedRuntime.sharePlan(PROJECT_ID, plan.id);
|
|
|
|
|
// The id survives the move: a session that attached the plan still finds it.
|
|
|
|
|
expect(shared.plan.id).toBe(plan.id);
|
|
|
|
|
expect(shared.plan.source).toBe('shared');
|
|
|
|
|
expect(shared.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:shared`]);
|
|
|
|
|
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
|
|
|
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine\n\nbody');
|
|
|
|
|
expect((await readJson(path.join(projectsDirPath, PROJECT_ID, 'context.json'))).plans[0]).toMatchObject({ id: plan.id, shared: true });
|
|
|
|
|
const readMoved = await sharedRuntime.readPlan(PROJECT_ID, plan.id);
|
|
|
|
|
expect(readMoved.source).toBe('shared');
|
|
|
|
|
expect(readMoved.raw).toBe('# Mine\n\nbody');
|
|
|
|
|
expect((await sharedRuntime.updatePlan(PROJECT_ID, plan.id, { raw: '# Mine v2\n' })).plan).toMatchObject({ id: plan.id, title: 'Mine v2', source: 'shared' });
|
|
|
|
|
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine v2\n');
|
|
|
|
|
|
|
|
|
|
// A personal file with the same name already exists: the returning plan gets a suffix, same id.
|
|
|
|
|
await fsPromises.mkdir(plansDir(), { recursive: true });
|
|
|
|
|
await fsPromises.writeFile(path.join(plansDir(), plan.file), 'squatter');
|
|
|
|
|
const back = await sharedRuntime.unsharePlan(PROJECT_ID, plan.id);
|
|
|
|
|
expect(back.plan.id).toBe(plan.id);
|
|
|
|
|
expect(back.plan.source).toBe('personal');
|
|
|
|
|
expect(back.plan.file).toBe(plan.file.replace(/\.md$/, '-1.md'));
|
|
|
|
|
expect(back.plan.title).toBe('Mine v2');
|
|
|
|
|
expect(back.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:personal`]);
|
|
|
|
|
await expect(fsPromises.access(path.join(sharedDir, plan.file))).rejects.toThrow();
|
|
|
|
|
expect(await sharedRuntime.unsharePlan(PROJECT_ID, plan.id)).toBeNull();
|
|
|
|
|
expect(await sharedRuntime.sharePlan(PROJECT_ID, 'nope')).toBeNull();
|
|
|
|
|
|
|
|
|
|
// A plan that only ever lived in the team's folder gets an id of its own on the way in.
|
|
|
|
|
await fsPromises.writeFile(path.join(sharedDir, 'foreign.md'), '# Foreign\n');
|
|
|
|
|
const adopted = await sharedRuntime.unsharePlan(PROJECT_ID, 'shared:foreign.md');
|
|
|
|
|
expect(adopted.plan.id).not.toMatch(/^shared:/);
|
|
|
|
|
expect(adopted.plan.title).toBe('Foreign');
|
|
|
|
|
expect(adopted.plan.source).toBe('personal');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('share is refused without a shared plans folder', async () => {
|
|
|
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
|
|
|
|
await expect(runtime.sharePlan(PROJECT_ID, plan.id)).rejects.toThrow('shared plans folder is required');
|
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
describe('parsePlanMarkdown', () => {
|
|
|
|
|
test('reads the leading heading as the title', () => {
|
|
|
|
|
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to the first non-empty line', () => {
|
|
|
|
|
expect(parsePlanMarkdown('\n\njust text\nmore')).toEqual({ title: 'just text', body: 'just text\nmore' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('normalizes CRLF input', () => {
|
|
|
|
|
expect(parsePlanMarkdown('# Title\r\n\r\nbody')).toEqual({ title: 'Title', body: 'body' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty input yields the default title', () => {
|
|
|
|
|
expect(parsePlanMarkdown('')).toEqual({ title: 'Plan', body: '' });
|
|
|
|
|
});
|
|
|
|
|
});
|