mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
import type http from 'node:http';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { writeFile as writeFsFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
describe('project export manifest route', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
const projectsToClean: string[] = [];
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const id of projectsToClean.splice(0)) {
|
|
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
|
|
}
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
function makeFolder(): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'od-export-manifest-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
|
const previous = process.env.OD_SANDBOX_MODE;
|
|
process.env.OD_SANDBOX_MODE = '1';
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
|
else process.env.OD_SANDBOX_MODE = previous;
|
|
}
|
|
}
|
|
|
|
async function createProject(
|
|
metadata: Record<string, unknown> = { kind: 'prototype', entryFile: 'index.html' },
|
|
): Promise<string> {
|
|
const id = `export-manifest-${randomUUID()}`;
|
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id,
|
|
name: 'Export manifest project',
|
|
metadata,
|
|
}),
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
projectsToClean.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function writeFile(projectId: string, body: Record<string, unknown>): Promise<void> {
|
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
}
|
|
|
|
it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => {
|
|
const projectId = await createProject();
|
|
await writeFile(projectId, {
|
|
name: 'styles.css',
|
|
content: 'body { color: black; }',
|
|
});
|
|
await writeFile(projectId, {
|
|
name: 'assets/logo.svg',
|
|
content: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
|
});
|
|
await writeFile(projectId, {
|
|
name: 'index.html',
|
|
content: '<!doctype html><link rel="stylesheet" href="styles.css">',
|
|
artifactManifest: {
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Reviewed prototype',
|
|
entry: 'index.html',
|
|
renderer: 'html',
|
|
status: 'complete',
|
|
exports: ['html', 'zip'],
|
|
primary: true,
|
|
supportingFiles: ['styles.css', 'assets/logo.svg', 'missing.png'],
|
|
updatedAt: '2026-05-28T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
|
expect(response.ok).toBe(true);
|
|
const body = await response.json() as {
|
|
schema: string;
|
|
projectId: string;
|
|
entryFile: string;
|
|
files: Array<{ name: string; role: string; reasons: string[]; artifactManifest?: unknown }>;
|
|
artifacts: Array<{ file: string; title: string; supportingFiles: string[] }>;
|
|
};
|
|
|
|
expect(body).toMatchObject({
|
|
schema: 'open-design.project-export-manifest.v1',
|
|
projectId,
|
|
entryFile: 'index.html',
|
|
});
|
|
expect(body.files.map((file) => file.name)).toEqual([
|
|
'assets/logo.svg',
|
|
'index.html',
|
|
'styles.css',
|
|
]);
|
|
expect(body.files.find((file) => file.name === 'index.html')).toMatchObject({
|
|
role: 'entry',
|
|
reasons: expect.arrayContaining(['artifact-manifest', 'project-entry-file']),
|
|
});
|
|
expect(body.files.find((file) => file.name === 'styles.css')).toMatchObject({
|
|
role: 'supporting',
|
|
reasons: ['artifact-supporting-file'],
|
|
});
|
|
expect(body.artifacts).toMatchObject([
|
|
{
|
|
file: 'index.html',
|
|
title: 'Reviewed prototype',
|
|
supportingFiles: ['assets/logo.svg', 'styles.css'],
|
|
},
|
|
]);
|
|
expect(body.files.some((file) => file.name.endsWith('.artifact.json'))).toBe(false);
|
|
});
|
|
|
|
it('uses artifact primary strings as project-relative entry refs', async () => {
|
|
const projectId = await createProject({ kind: 'prototype' });
|
|
await writeFile(projectId, {
|
|
name: 'reviewed.html',
|
|
content: '<!doctype html><main>reviewed</main>',
|
|
});
|
|
await writeFile(projectId, {
|
|
name: 'preview/wrapper.html',
|
|
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
|
artifactManifest: {
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Review wrapper',
|
|
renderer: 'html',
|
|
status: 'complete',
|
|
exports: ['html'],
|
|
primary: 'reviewed.html',
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
|
expect(response.ok).toBe(true);
|
|
const body = await response.json() as {
|
|
entryFile: string;
|
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
|
};
|
|
|
|
expect(body.entryFile).toBe('reviewed.html');
|
|
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
|
role: 'entry',
|
|
reasons: expect.arrayContaining(['artifact-primary', 'project-entry-file']),
|
|
});
|
|
});
|
|
|
|
it('uses artifact entry strings as project-relative entry refs without primary hints', async () => {
|
|
const projectId = await createProject({ kind: 'prototype' });
|
|
await writeFile(projectId, {
|
|
name: 'index.html',
|
|
content: '<!doctype html><main>fallback</main>',
|
|
});
|
|
await writeFile(projectId, {
|
|
name: 'reviewed.html',
|
|
content: '<!doctype html><main>reviewed</main>',
|
|
});
|
|
await writeFile(projectId, {
|
|
name: 'preview/wrapper.html',
|
|
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
|
|
artifactManifest: {
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Review wrapper',
|
|
entry: 'reviewed.html',
|
|
renderer: 'html',
|
|
status: 'complete',
|
|
exports: ['html'],
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`);
|
|
expect(response.ok).toBe(true);
|
|
const body = await response.json() as {
|
|
entryFile: string;
|
|
files: Array<{ name: string; role: string; reasons: string[] }>;
|
|
};
|
|
|
|
expect(body.entryFile).toBe('reviewed.html');
|
|
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
|
|
role: 'entry',
|
|
reasons: expect.arrayContaining(['artifact-entry', 'project-entry-file']),
|
|
});
|
|
});
|
|
|
|
it('rejects invalid project ids before listing files', async () => {
|
|
const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`);
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('rejects imported-folder projects in sandbox mode instead of returning an empty manifest', async () => {
|
|
const folder = makeFolder();
|
|
await writeFsFile(path.join(folder, 'index.html'), '<!doctype html>');
|
|
|
|
const importResponse = await fetch(`${baseUrl}/api/import/folder`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ baseDir: folder }),
|
|
});
|
|
expect(importResponse.status).toBe(200);
|
|
const importBody = (await importResponse.json()) as { project: { id: string } };
|
|
projectsToClean.push(importBody.project.id);
|
|
|
|
await withSandboxMode(async () => {
|
|
const response = await fetch(`${baseUrl}/api/projects/${importBody.project.id}/export/manifest`);
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
|
});
|
|
});
|
|
});
|