Files
makelore/tests/unit/coding-project-identity.test.ts
2026-09-07 10:06:37 +08:00

367 lines
15 KiB
TypeScript

// @vitest-environment node
import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { HostApiContext } from '../../electron/api/context';
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
import {
createCodingProjectConfigV2,
isCanonicalCodingProjectId,
normalizeCodingProjectConfigV2,
readCodingProjectConfigV2,
writeCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
normalizeCodingProjectPath,
} from '../../electron/coding-projects/project-store';
const roots: string[] = [];
const CREATED = '2026-08-26T00:00:00.000Z';
const UPDATED = '2026-08-26T00:01:00.000Z';
const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const NEXT_PROJECT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function makeRoot(prefix = 'makelore-project-identity-'): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
function makeStore(localId = 'local-project-id') {
return createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => localId,
now: () => CREATED,
});
}
describe('coding project durable identity', () => {
it.each(['interactive_ai_app', 'custom'] as const)(
'creates only project-owned metadata for %s projects',
async (projectType) => {
const projectPath = await makeRoot();
const projects = new CodingProjectService(makeStore(), {
createProjectId: () => PROJECT_ID,
});
const created = await projects.createProject({
projectPath,
projectType,
identity: { kind: 'create' },
});
expect(created.config.projectType).toBe(projectType);
expect((await readdir(projectPath)).sort()).toEqual(['.makelore', 'knowledge']);
},
);
it.each(['mini_game', 'mini_program'] as const)(
'reads legacy %s metadata as the canonical interactive type without rewriting it',
async (legacyProjectType) => {
const projectPath = await makeRoot();
await mkdir(path.join(projectPath, '.makelore'), { recursive: true });
const configPath = path.join(projectPath, '.makelore', 'project.json');
const rawConfig = `${JSON.stringify({
...createCodingProjectConfigV2(CREATED, 'interactive_ai_app'),
projectType: legacyProjectType,
}, null, 2)}\n`;
await writeFile(configPath, rawConfig, 'utf8');
await expect(readCodingProjectConfigV2(projectPath)).resolves.toMatchObject({
status: 'valid',
config: { projectType: 'interactive_ai_app' },
});
await expect(readFile(configPath, 'utf8')).resolves.toBe(rawConfig);
},
);
it('preserves canonical identity and rejects noncanonical values without backfill', () => {
const initialized = createCodingProjectConfigV2(CREATED, 'custom', PROJECT_ID);
expect(initialized.projectId).toBe(PROJECT_ID);
expect(isCanonicalCodingProjectId(PROJECT_ID)).toBe(true);
const legacy = createCodingProjectConfigV2(CREATED, 'custom');
expect(legacy).not.toHaveProperty('projectId');
expect(normalizeCodingProjectConfigV2(legacy)).not.toHaveProperty('projectId');
for (const projectId of [
PROJECT_ID.toUpperCase(),
`{${PROJECT_ID}}`,
PROJECT_ID.replaceAll('-', ''),
` ${PROJECT_ID}`,
'not-a-project-id',
null,
]) {
expect(() => normalizeCodingProjectConfigV2({ ...legacy, projectId }))
.toThrow('Project identity is invalid');
}
});
it('requires an explicit create/bind choice and writes no config on invalid bind', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const createProjectId = vi.fn(() => PROJECT_ID);
const identityChanging = vi.fn();
const projects = new CodingProjectService(store, {
createProjectId,
onProjectIdentityChanging: identityChanging,
});
const created = await projects.createProject({
projectPath,
identity: { kind: 'create' },
});
expect(created.config.projectId).toBe(PROJECT_ID);
expect(createProjectId).toHaveBeenCalledTimes(1);
expect(identityChanging).not.toHaveBeenCalled();
const invalidPath = path.join(await makeRoot('makelore-project-identity-parent-'), 'invalid');
await expect(projects.createProject({
projectPath: invalidPath,
identity: { kind: 'bind', projectId: 'not-canonical' },
})).rejects.toMatchObject({
code: 'CODING_PROJECT_REQUEST_INVALID',
status: 400,
});
await expect(readCodingProjectConfigV2(invalidPath)).resolves.toEqual({ status: 'missing' });
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' });
const boundPath = await makeRoot();
const bound = await projects.createProject({
projectPath: boundPath,
identity: { kind: 'bind', projectId: NEXT_PROJECT_ID },
});
expect(bound.config.projectId).toBe(NEXT_PROJECT_ID);
expect(createProjectId).toHaveBeenCalledTimes(1);
});
it('resolves a legacy identity once and invalidates before writing resources', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, now: CREATED }, store);
const events: string[] = [];
const projects = new CodingProjectService(store, {
createProjectId: () => PROJECT_ID,
now: () => UPDATED,
onProjectIdentityChanging: () => { events.push('invalidate'); },
writeConfig: async (filePath, value) => {
events.push('write');
await writeCodingProjectConfigV2(filePath, value);
},
onResourcesChanged: () => { events.push('resources'); },
});
expect(local.config).not.toHaveProperty('projectId');
const resolved = await projects.resolveProjectIdentity(local.project.id, { kind: 'create' });
expect(resolved.config.projectId).toBe(PROJECT_ID);
expect(events).toEqual(['invalidate', 'write', 'resources']);
await expect(projects.resolveProjectIdentity(local.project.id, {
kind: 'bind',
projectId: NEXT_PROJECT_ID,
})).rejects.toMatchObject({
code: 'CODING_PROJECT_IDENTITY_IMMUTABLE',
status: 409,
});
expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID);
});
it('automatically assigns one identity when a legacy project is read concurrently', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, now: CREATED }, store);
const createProjectId = vi.fn(() => PROJECT_ID);
const events: string[] = [];
const projects = new CodingProjectService(store, {
createProjectId,
now: () => UPDATED,
onProjectIdentityChanging: () => { events.push('invalidate'); },
writeConfig: async (filePath, value) => {
events.push('write');
await writeCodingProjectConfigV2(filePath, value);
},
onResourcesChanged: () => { events.push('resources'); },
});
const [first, second] = await Promise.all([
projects.getConfig(local.project.id),
projects.getConfig(local.project.id),
]);
expect(first.config.projectId).toBe(PROJECT_ID);
expect(second.config.projectId).toBe(PROJECT_ID);
expect(createProjectId).toHaveBeenCalledTimes(1);
expect(events).toEqual(['invalidate', 'write', 'resources']);
await expect(readCodingProjectConfigV2(projectPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID, updatedAt: UPDATED } });
});
it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({
projectPath,
projectId: PROJECT_ID,
now: CREATED,
}, store);
const invalidating = vi.fn();
const projects = new CodingProjectService(store, {
createProjectId: () => NEXT_PROJECT_ID,
now: () => UPDATED,
onProjectIdentityChanging: invalidating,
});
const current = await projects.getConfig(local.project.id);
await expect(projects.saveConfig(local.project.id, {
...current.config,
projectId: undefined,
})).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 });
await expect(projects.saveConfig(local.project.id, {
...current.config,
projectId: NEXT_PROJECT_ID,
})).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 });
await expect(projects.makeProjectIndependentCopy(
local.project.id,
false as unknown as true,
)).rejects.toMatchObject({
code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED',
status: 400,
});
const independent = await projects.makeProjectIndependentCopy(local.project.id, true);
expect(independent.config.projectId).toBe(NEXT_PROJECT_ID);
expect(independent.config.updatedAt).toBe(UPDATED);
expect(invalidating).toHaveBeenCalledWith(local.project, PROJECT_ID, NEXT_PROJECT_ID);
});
it('does not write a legacy identity when preview invalidation fails', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath }, store);
const projects = new CodingProjectService(store, {
createProjectId: () => PROJECT_ID,
onProjectIdentityChanging: () => {
throw new Error('preview close failed');
},
});
await expect(projects.resolveProjectIdentity(local.project.id, { kind: 'create' }))
.rejects.toThrow('preview close failed');
const current = await readCodingProjectConfigV2(projectPath);
expect(current).toMatchObject({ status: 'valid', config: { projectType: 'custom' } });
if (current.status === 'valid') expect(current.config).not.toHaveProperty('projectId');
});
it('retains identity across a move and raw folder copy', async () => {
const parent = await makeRoot('makelore-project-identity-move-parent-');
const originalPath = path.join(parent, 'original');
await mkdir(originalPath);
const store = makeStore();
const original = await createLocalCodingProject({
projectPath: originalPath,
projectId: PROJECT_ID,
now: CREATED,
}, store);
const movedPath = path.join(parent, 'moved');
await rename(originalPath, movedPath);
const movedStore = makeStore('moved-local-project-id');
const moved = await movedStore.openFolder(movedPath);
expect((await readCodingProjectConfigV2(moved.path)).status).toBe('valid');
const movedConfig = await readCodingProjectConfigV2(moved.path);
expect(movedConfig).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } });
const copiedPath = path.join(parent, 'copied');
await mkdir(path.join(copiedPath, '.makelore'), { recursive: true });
await copyFile(
path.join(movedPath, '.makelore', 'project.json'),
path.join(copiedPath, '.makelore', 'project.json'),
);
const copied = await readCodingProjectConfigV2(copiedPath);
expect(copied).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } });
expect(original.project.id).not.toBe(moved.id);
});
it('centralizes the active real path and durable identity checks', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, projectId: PROJECT_ID }, store);
const projects = new CodingProjectService(store);
const active = await projects.requireActiveRealProjectWithIdentity(projectPath);
expect(active).toMatchObject({ project: local.project, projectId: PROJECT_ID });
expect(normalizeCodingProjectPath(active.path)).toBe(
normalizeCodingProjectPath(await realpath(local.project.path)),
);
const otherPath = await makeRoot('makelore-project-identity-other-');
await expect(projects.requireActiveRealProjectWithIdentity(otherPath)).rejects.toMatchObject({
code: 'CODING_ACTIVE_PROJECT_PATH_MISMATCH',
status: 409,
});
const legacyPath = await makeRoot('makelore-project-identity-legacy-');
const legacyStore = makeStore();
await createLocalCodingProject({ projectPath: legacyPath }, legacyStore);
const legacyProjects = new CodingProjectService(legacyStore, {
createProjectId: () => NEXT_PROJECT_ID,
});
await expect(legacyProjects.requireActiveRealProjectWithIdentity())
.resolves.toMatchObject({ projectId: NEXT_PROJECT_ID });
await expect(readCodingProjectConfigV2(legacyPath))
.resolves.toMatchObject({ status: 'valid', config: { projectId: NEXT_PROJECT_ID } });
});
it('exposes identity resolution and independent-copy through the Host routes', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath }, store);
const generatedIds = [PROJECT_ID, NEXT_PROJECT_ID];
const projects = new CodingProjectService(store, {
createProjectId: () => generatedIds.shift() ?? 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
});
const context = {
codingProducts: { projects, conversations: {} },
} as unknown as HostApiContext;
const resolved = await dispatchHostApiRequest(context, {
path: '/api/coding/projects/identity',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ localProjectId: local.project.id, identity: { kind: 'create' } }),
});
expect(resolved).toMatchObject({
status: 200,
json: { snapshot: { config: { projectId: PROJECT_ID } } },
});
const independent = await dispatchHostApiRequest(context, {
path: '/api/coding/projects/identity/independent-copy',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ localProjectId: local.project.id, confirmed: true }),
});
expect(independent.status).toBe(200);
expect((independent.json as { snapshot: { config: { projectId: string } } }).snapshot.config.projectId)
.not.toBe(PROJECT_ID);
const rejected = await dispatchHostApiRequest(context, {
path: '/api/coding/projects/identity/independent-copy',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ localProjectId: local.project.id, confirmed: false }),
});
expect(rejected).toMatchObject({
status: 400,
json: { code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED' },
});
});
});