Files
openchamber/packages/web/server/lib/projects/project-config.test.js
T
Bohdan Triapitsyn 4901cf60b8 fix(projects): deterministic project identity + safe per-project persistence
Derive project.id from project.path (path_<base64url(path)>), shared helper on
both server (lib/projects/project-id.js) and client (lib/projectId.ts). Replace
random UUIDs so icons, notes, todos, actions, setup-worktree, plans and
scheduledTasks share one id across restarts and reinstalls.

Fix read-then-overwrite clobber in project-config.js: scheduled-task writes now
merge with the existing project json instead of replacing it, preserving
client-written fields that live in the same file.

On settings load migrate legacy UUID ids to canonical path ids, moving config
json, storage dir contents and icon files, and remap activeProjectId. Scan for
orphan non-path_* project configs and merge them into the canonical project
when a \$ROOT_PROJECT_PATH/<file> reference resolves on disk, logging any that
can't be matched.

Client openchamberConfig.writeOpenChamberConfig re-asserts server-owned keys
(version, scheduledTasks) on write to defeat the symmetric race. Stores and
persistence derive ids from path consistently.
2026-04-18 13:46:38 +03:00

145 lines
4.7 KiB
JavaScript

import { describe, expect, it } from 'bun:test';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
import { createProjectConfigRuntime } from './project-config.js';
const createRuntime = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
const runtime = createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: tempRoot,
createTaskID: () => 'task-fixed-id',
});
return {
runtime,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
describe('project-config runtime', () => {
it('creates and persists a scheduled task', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const result = await runtime.upsertScheduledTask('project-test', {
name: 'Nightly digest',
enabled: true,
schedule: {
kind: 'daily',
time: '09:30',
timezone: 'UTC',
},
execution: {
prompt: 'Summarize repository changes',
providerID: 'openai',
modelID: 'gpt-4.1',
},
});
expect(result.created).toBe(true);
expect(result.task.id).toBe('task-fixed-id');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(1);
expect(reloaded[0].name).toBe('Nightly digest');
expect(reloaded[0].schedule.timezone).toBe('UTC');
expect(reloaded[0].schedule.times).toEqual(['09:30']);
} finally {
await cleanup();
}
});
it('rejects invalid cron expressions', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await expect(runtime.upsertScheduledTask('project-test', {
name: 'Invalid cron task',
enabled: true,
schedule: {
kind: 'cron',
cron: 'invalid cron',
timezone: 'UTC',
},
execution: {
prompt: 'Run checks',
providerID: 'openai',
modelID: 'gpt-4.1',
},
})).rejects.toThrow('schedule.cron is invalid');
} finally {
await cleanup();
}
});
it('preserves unknown project config keys when writing scheduled tasks', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const projectID = 'path_preserve';
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
await writeFile(
filePath,
JSON.stringify({
projectNotes: 'hello notes',
projectTodos: [{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }],
projectActions: [{ id: 'a1', name: 'Run', command: 'bun run dev' }],
projectActionsPrimaryId: 'a1',
'setup-worktree': ['bun install'],
projectPlanFiles: [{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }],
projectPath: '/tmp/demo',
}, null, 2),
'utf8',
);
await runtime.upsertScheduledTask(projectID, {
name: 'nightly',
enabled: true,
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
});
const raw = JSON.parse(await readFile(filePath, 'utf8'));
expect(raw.projectNotes).toBe('hello notes');
expect(raw.projectTodos).toEqual([{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }]);
expect(raw.projectActions).toHaveLength(1);
expect(raw.projectActionsPrimaryId).toBe('a1');
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw.projectPlanFiles).toEqual([{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }]);
expect(raw.projectPath).toBe('/tmp/demo');
expect(raw.scheduledTasks).toHaveLength(1);
expect(raw.version).toBe(1);
} finally {
await cleanup();
}
});
it('accepts one-time schedule with date and time', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const result = await runtime.upsertScheduledTask('project-test', {
name: 'One-time review',
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-20',
time: '13:45',
timezone: 'Europe/Kyiv',
},
execution: {
prompt: 'Create a release summary',
providerID: 'openai',
modelID: 'gpt-4.1',
},
});
expect(result.task.schedule.kind).toBe('once');
expect(result.task.schedule.date).toBe('2026-04-20');
expect(result.task.schedule.time).toBe('13:45');
expect(result.task.schedule.timezone).toBe('Europe/Kyiv');
} finally {
await cleanup();
}
});
});