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: [], sharedPlansDir: null, }); }); 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 }]); expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]); 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); expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]); 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'] }); expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null }); 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(''); }); }); 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]); }); }); 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: '' }); }); });