Files
nexu-io--open-design/apps/daemon/tests/plugins-duplicate-project.test.ts
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

175 lines
6.0 KiB
TypeScript

import express from 'express';
import type http from 'node:http';
import { access, mkdir, mkdtemp, rm, symlink, 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 { InstalledPluginRecord, Project } from '@open-design/contracts';
import { duplicatePluginExampleIntoProject } from '../src/plugins/duplicate-project.js';
import { registerPluginRoutes } from '../src/routes/plugins/index.js';
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function makeTempRoot(prefix: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), prefix));
tempRoots.push(dir);
return dir;
}
async function makePreviewPlugin(root: string, id = 'duplicate-fixture'): Promise<InstalledPluginRecord> {
const pluginRoot = path.join(root, id);
await mkdir(path.join(pluginRoot, 'preview'), { recursive: true });
await writeFile(
path.join(pluginRoot, 'preview', 'index.html'),
'<!doctype html><html><body><h1>Duplicable</h1></body></html>',
'utf8',
);
return {
id,
title: 'Duplicate Fixture',
fsPath: pluginRoot,
manifest: {
name: id,
title: 'Duplicate Fixture',
od: { preview: { entry: 'preview/index.html' } },
},
} as InstalledPluginRecord;
}
async function expectMissing(pathname: string): Promise<void> {
await expect(access(pathname)).rejects.toMatchObject({ code: 'ENOENT' });
}
describe('plugin project duplication', () => {
it.skipIf(process.platform === 'win32')(
'rejects duplicates that would skip a required symlinked file',
async () => {
const root = await makeTempRoot('od-plugin-duplicate-helper-');
const projectsRoot = path.join(root, 'projects');
const plugin = await makePreviewPlugin(root);
await writeFile(path.join(plugin.fsPath, 'preview', 'target.txt'), 'asset', 'utf8');
await symlink('target.txt', path.join(plugin.fsPath, 'preview', 'linked.txt'));
await expect(
duplicatePluginExampleIntoProject({
plugin,
projectsRoot,
projectId: 'symlink-project',
metadata: { kind: 'prototype' },
assembleExample: (templateHtml) => templateHtml,
}),
).rejects.toMatchObject({
status: 422,
code: 'DUPLICATE_COPY_INCOMPLETE',
});
},
);
it('rolls back project rows and files when conversation creation fails after copying files', async () => {
const root = await makeTempRoot('od-plugin-duplicate-route-');
const projectsRoot = path.join(root, 'projects');
const plugin = await makePreviewPlugin(root, 'route-duplicate-fixture');
const projectId = 'route-duplicate-project';
const project = {
id: projectId,
name: 'Route Duplicate Fixture',
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
createdAt: 1,
updatedAt: 1,
archivedAt: null,
lastOpenedAt: null,
order: 0,
conversationCount: 0,
hasActiveRun: false,
} as unknown as Project;
const db = {
prepare: () => ({
all: () => [],
get: () => null,
run: () => undefined,
}),
};
const dbDeleteProject = vi.fn();
const app = express();
app.use(express.json());
registerPluginRoutes(app, {
db,
paths: {
PROJECTS_DIR: projectsRoot,
PLUGIN_REGISTRY_ROOTS: [],
PLUGIN_LOCKFILE_PATH: path.join(root, 'plugins.lock'),
},
ids: {
randomId: vi.fn()
.mockReturnValueOnce(projectId)
.mockReturnValueOnce('route-duplicate-conversation'),
},
projectStore: {
insertProject: vi.fn(() => project),
getProject: vi.fn(() => project),
dbDeleteProject,
removeProjectDir: async (rootDir: string, id: string) => {
await rm(path.join(rootDir, id), { recursive: true, force: true });
},
},
conversations: {
insertConversation: vi.fn(() => {
throw new Error('conversation insert failed');
}),
},
plugins: {
getInstalledPlugin: vi.fn(() => plugin),
listInstalledPlugins: vi.fn(() => []),
},
helpers: {
requireLocalDaemonRequest: ((_req, _res, next) => next()) as express.RequestHandler,
assembleExample: (templateHtml: string) => templateHtml,
applyBakedPreviews: (records: unknown[]) => records,
},
} as unknown as Parameters<typeof registerPluginRoutes>[1]);
const server = await listen(app);
try {
const resp = await fetch(
`${server.url}/api/plugins/${encodeURIComponent(plugin.id)}/duplicate-project`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Route Duplicate Fixture' }),
},
);
expect(resp.status).toBe(500);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('plugin-duplicate-failed');
expect(body.error?.message).toContain('conversation insert failed');
expect(dbDeleteProject).toHaveBeenCalledWith(db, projectId);
await expectMissing(path.join(projectsRoot, projectId));
} finally {
await close(server.server);
}
});
});
async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> {
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port');
return {
server,
url: `http://127.0.0.1:${address.port}`,
};
}
async function close(server: http.Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}