This commit is contained in:
Denis Redozubov 2026-05-31 09:06:40 +04:00 committed by GitHub
commit c302d57bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 851 additions and 8 deletions

View file

@ -201,10 +201,15 @@ export function validateArtifactManifestInput(
}
}
const safeEntry = typeof entry === 'string' ? entry : '';
if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) {
return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` };
const manifestEntry =
typeof manifest.entry === 'string' && manifest.entry.trim()
? manifest.entry.trim()
: entry;
const entryErr = validateSupportingPath(manifestEntry);
if (entryErr) {
return { ok: false, error: `artifactManifest.entry ${entryErr}` };
}
const safeEntry = (manifestEntry as string).replace(/\\/g, '/');
return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) };
}

View file

@ -1,4 +1,5 @@
import type { Express } from 'express';
import nodePath from 'node:path';
import type { RouteDeps } from './server-context.js';
import {
InlineAssetsLimitError,
@ -358,7 +359,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
const { sendApiError } = ctx.http;
const { PROJECTS_DIR } = ctx.paths;
const { getProject } = ctx.projectStore;
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
const { listFiles, readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
const { isSafeId } = ctx.validation;
const {
buildProjectArchive,
@ -447,6 +448,30 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
}
});
app.get('/api/projects/:id/export/manifest', async (req, res) => {
try {
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const files = await listFiles(PROJECTS_DIR, req.params.id, {
metadata: project.metadata,
});
/** @type {import('@open-design/contracts').ProjectExportManifestResponse} */
const body = buildProjectExportManifestResponse({
project,
projectId: req.params.id,
files,
});
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
app.post('/api/projects/:id/export/pdf', async (req, res) => {
if (typeof desktopPdfExporter !== 'function') {
return sendApiError(
@ -656,6 +681,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
}
function buildProjectExportManifestResponse({
project,
projectId,
files,
}: {
project: any;
projectId: string;
files: any[];
}) {
const sortedFiles = [...files].sort((a, b) => String(a.name).localeCompare(String(b.name)));
const filesByName = new Map(sortedFiles.map((file) => [file.name, file]));
const reasons = new Map<string, Set<string>>();
const supportingNames = new Set<string>();
const artifactNames = new Set<string>();
const artifacts = [];
const note = (name: unknown, reason: string) => {
if (typeof name !== 'string' || !filesByName.has(name)) return;
if (!reasons.has(name)) reasons.set(name, new Set());
reasons.get(name)?.add(reason);
};
for (const file of sortedFiles) {
const manifest = file.artifactManifest && typeof file.artifactManifest === 'object'
? file.artifactManifest
: null;
if (!manifest) continue;
if (isInferredArtifactManifest(manifest)) continue;
artifactNames.add(file.name);
note(file.name, 'artifact-manifest');
const artifactSupporting = new Set<string>();
const addManifestRef = (
ref: unknown,
reason: string,
options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {},
) => {
const ownerRelative = normalizeManifestProjectRef(ref, file.name);
const projectRoot = normalizeManifestProjectRootRef(ref);
const candidates = options.preferProjectRoot
? [projectRoot, ownerRelative]
: [
ownerRelative,
...(options.allowProjectRootFallback ? [projectRoot] : []),
];
const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate));
if (!normalized) return;
if (normalized === file.name) return;
supportingNames.add(normalized);
artifactSupporting.add(normalized);
note(normalized, reason);
};
addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true });
if (typeof manifest.primary === 'string') {
addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true });
}
if (Array.isArray(manifest.supportingFiles)) {
for (const ref of manifest.supportingFiles) {
addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true });
}
}
artifacts.push({
file: file.name,
title: typeof manifest.title === 'string' && manifest.title.trim()
? manifest.title
: file.name,
kind: typeof manifest.kind === 'string' ? manifest.kind : (file.artifactKind ?? null),
renderer: typeof manifest.renderer === 'string' ? manifest.renderer : null,
status: typeof manifest.status === 'string' ? manifest.status : null,
exports: Array.isArray(manifest.exports)
? manifest.exports.filter((value: unknown): value is string => typeof value === 'string')
: [],
supportingFiles: Array.from(artifactSupporting).sort((a, b) => a.localeCompare(b)),
updatedAt: typeof manifest.updatedAt === 'string' ? manifest.updatedAt : null,
});
}
const entryFile = chooseExportManifestEntryFile(project, sortedFiles, filesByName);
note(entryFile, 'project-entry-file');
return {
schema: 'open-design.project-export-manifest.v1',
projectId,
projectName: typeof project?.name === 'string' ? project.name : null,
generatedAt: new Date().toISOString(),
entryFile,
files: sortedFiles.map((file) => ({
...file,
included: true,
role: roleForExportManifestFile(file, {
entryFile,
artifactNames,
supportingNames,
}),
reasons: Array.from(reasons.get(file.name) ?? ['visible-project-file']).sort((a, b) => a.localeCompare(b)),
})),
artifacts,
};
}
function isInferredArtifactManifest(manifest: any): boolean {
return manifest?.metadata &&
typeof manifest.metadata === 'object' &&
manifest.metadata.inferred === true;
}
function chooseExportManifestEntryFile(
project: any,
files: any[],
filesByName: Map<string, any>,
): string | null {
const metadataEntry = typeof project?.metadata?.entryFile === 'string'
? project.metadata.entryFile
: null;
if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry;
for (const file of files) {
const manifest = file.artifactManifest;
if (!manifest || typeof manifest !== 'object') continue;
if (isInferredArtifactManifest(manifest)) continue;
if (manifest.primary === true) return file.name;
if (typeof manifest.primary === 'string') {
const rootPrimary = normalizeManifestProjectRootRef(manifest.primary);
if (rootPrimary && filesByName.has(rootPrimary)) return rootPrimary;
const ownerRelativePrimary = normalizeManifestProjectRef(manifest.primary, file.name);
if (ownerRelativePrimary && filesByName.has(ownerRelativePrimary)) return ownerRelativePrimary;
}
const rootEntry = normalizeManifestProjectRootRef(manifest.entry);
if (rootEntry && filesByName.has(rootEntry)) return rootEntry;
const ownerRelativeEntry = normalizeManifestProjectRef(manifest.entry, file.name);
if (ownerRelativeEntry && filesByName.has(ownerRelativeEntry)) return ownerRelativeEntry;
}
return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name
?? files.find((file) => file.kind === 'html')?.name
?? files[0]?.name
?? null;
}
function normalizeManifestProjectRootRef(ref: unknown): string | null {
return normalizeManifestProjectRef(ref, '');
}
function normalizeManifestProjectRef(ref: unknown, ownerFile: string): string | null {
if (typeof ref !== 'string' || !ref.trim()) return null;
const value = ref.trim();
if (value.includes('\0') || value.startsWith('/')) return null;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
const ownerDir = nodePath.posix.dirname(ownerFile);
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
const normalized = nodePath.posix.normalize(joined).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
return normalized;
}
function roleForExportManifestFile(
file: any,
refs: {
entryFile: string | null;
artifactNames: Set<string>;
supportingNames: Set<string>;
},
) {
if (file.name === refs.entryFile) return 'entry';
if (refs.artifactNames.has(file.name)) return 'artifact';
if (refs.supportingNames.has(file.name)) return 'supporting';
if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio') return 'asset';
if (file.kind === 'code' || file.kind === 'text') return 'source';
return 'other';
}
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) {

View file

@ -880,6 +880,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
await renameFilePath(source, targetPath, { noOverwrite: true });
await commitArtifactManifestRename(manifestRename, newName);
await updateArtifactManifestRefsForRename(dir, oldName, newName);
const st = await stat(targetPath);
const manifest = await readManifestForPath(dir, newName);
@ -974,16 +975,22 @@ async function prepareArtifactManifestRename(dir, oldName, newName) {
}
}
return { oldManifestPath, newManifestPath: targetManifestPath, raw };
return { oldManifestPath, newManifestPath: targetManifestPath, raw, oldName };
}
async function commitArtifactManifestRename(manifestRename, newName) {
if (!manifestRename) return;
const { oldManifestPath, newManifestPath, raw } = manifestRename;
const { oldManifestPath, newManifestPath, raw, oldName } = manifestRename;
await mkdir(path.dirname(newManifestPath), { recursive: true });
const parsed = parseManifest(raw);
if (parsed) {
const validated = validateArtifactManifestInput(parsed, newName);
const parsedEntry = typeof parsed.entry === 'string'
? parsed.entry.replace(/\\/g, '/')
: '';
const renamedManifest = parsedEntry === oldName
? { ...parsed, entry: newName }
: parsed;
const validated = validateArtifactManifestInput(renamedManifest, newName);
if (validated.ok && validated.value) {
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
@ -993,6 +1000,153 @@ async function commitArtifactManifestRename(manifestRename, newName) {
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
}
async function updateArtifactManifestRefsForRename(dir, oldName, newName) {
const manifests = [];
await collectArtifactManifestFiles(dir, '', manifests);
for (const manifestFile of manifests) {
const ownerName = ownerNameForArtifactManifest(manifestFile.relPath);
if (!ownerName) continue;
let raw;
try {
raw = await readFile(manifestFile.fullPath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') continue;
throw err;
}
const parsed = parseManifest(raw);
if (!parsed) continue;
const updated = rewriteArtifactManifestRenameRefs(parsed, {
ownerName,
oldName,
newName,
});
if (!updated.changed) continue;
const validated = validateArtifactManifestInput(updated.manifest, ownerName);
if (!validated.ok || !validated.value) continue;
await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2));
}
}
async function collectArtifactManifestFiles(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await collectArtifactManifestFiles(fullPath, relPath, out);
continue;
}
if (entry.isFile() && entry.name.endsWith('.artifact.json')) {
out.push({ relPath, fullPath });
}
}
}
function ownerNameForArtifactManifest(manifestName) {
const suffix = '.artifact.json';
if (!manifestName.endsWith(suffix)) return null;
return manifestName.slice(0, -suffix.length);
}
function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) {
let changed = false;
const next = { ...manifest };
const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, {
preferProjectRoot: true,
});
if (entry.changed) {
next.entry = entry.value;
changed = true;
}
if (typeof next.primary === 'string') {
const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, {
preferProjectRoot: true,
});
if (primary.changed) {
next.primary = primary.value;
changed = true;
}
}
if (Array.isArray(next.supportingFiles)) {
const supportingFiles = next.supportingFiles.map((ref) => {
const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName);
if (updated.changed) changed = true;
return updated.value;
});
if (changed) next.supportingFiles = supportingFiles;
}
return { changed, manifest: next };
}
function rewriteManifestRefForRename(
ref,
ownerName,
oldName,
newName,
options = {},
) {
if (typeof ref !== 'string') return { changed: false, value: ref };
const normalized = ref.replace(/\\/g, '/').trim();
if (!normalized) return { changed: false, value: ref };
if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) {
return { changed: true, value: newName };
}
if (normalizeManifestProjectRef(normalized, ownerName) === oldName) {
return {
changed: true,
value: relativeManifestRefForOwner(ownerName, newName),
};
}
if (normalized === oldName) {
return { changed: true, value: newName };
}
return { changed: false, value: ref };
}
function relativeManifestRefForOwner(ownerName, targetName) {
const ownerDir = path.posix.dirname(ownerName);
if (ownerDir === '.') return targetName;
const relative = path.posix.relative(ownerDir, targetName);
if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) {
return targetName;
}
return relative;
}
function normalizeManifestProjectRootRef(ref) {
return normalizeManifestProjectRef(ref, '');
}
function normalizeManifestProjectRef(ref, ownerName) {
if (typeof ref !== 'string' || !ref.trim()) return null;
const value = ref.trim().replace(/\\/g, '/');
if (value.includes('\0') || value.startsWith('/')) return null;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
const ownerDir = path.posix.dirname(ownerName);
const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`;
const normalized = path.posix.normalize(joined).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized.startsWith('../')) return null;
if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null;
return normalized;
}
export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true });

View file

@ -0,0 +1,364 @@
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);
}
async function renameFile(projectId: string, from: string, to: string): Promise<void> {
const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ from, to }),
});
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('keeps artifact entry refs current when a referenced file is renamed', 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'],
primary: 'reviewed.html',
supportingFiles: ['reviewed.html'],
},
});
await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.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-renamed.html');
expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']),
});
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
expect(filesResponse.ok).toBe(true);
const filesBody = await filesResponse.json() as {
files: Array<{
name: string;
artifactManifest?: {
entry?: string;
primary?: string | boolean;
supportingFiles?: string[];
};
}>;
};
expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest)
.toMatchObject({
entry: 'reviewed-renamed.html',
primary: 'reviewed-renamed.html',
supportingFiles: ['reviewed-renamed.html'],
});
});
it('keeps artifact entry refs current when a referenced file moves out of the wrapper directory', async () => {
const projectId = await createProject({ kind: 'prototype' });
await writeFile(projectId, {
name: 'index.html',
content: '<!doctype html><main>fallback</main>',
});
await writeFile(projectId, {
name: 'preview/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'],
primary: 'reviewed.html',
supportingFiles: ['reviewed.html'],
},
});
await renameFile(projectId, 'preview/reviewed.html', '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[] }>;
artifacts: Array<{ file: string; supportingFiles: string[] }>;
};
expect(body.entryFile).toBe('reviewed.html');
expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({
role: 'entry',
reasons: expect.arrayContaining([
'artifact-entry',
'artifact-primary',
'artifact-supporting-file',
'project-entry-file',
]),
});
expect(body.artifacts.find((artifact) => artifact.file === 'preview/wrapper.html'))
.toMatchObject({
supportingFiles: ['reviewed.html'],
});
});
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);
});
});
});

View file

@ -357,6 +357,73 @@ describe('spawn writes external MCP config for Claude Code', () => {
}
}, 30_000);
it('binds conversation-less runs to the seeded project conversation', async () => {
await withFakeClaude(async () => {
const { id, conversationId } = await createProject();
const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Recently active' }),
});
expect(recentConvRes.ok).toBe(true);
const recentConvBody = (await recentConvRes.json()) as {
conversation: { id: string };
};
const recentConversationId = recentConvBody.conversation.id;
await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Recently active',
updatedAt: Date.now() + 60_000,
}),
});
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'headless fallback prompt',
}),
});
expect(chatRes.status).toBe(202);
const { runId, conversationId: resolvedConversationId } = (await chatRes.json()) as {
runId: string;
conversationId: string;
};
expect(resolvedConversationId).toBe(conversationId);
const status = await waitForRunStatus(baseUrl, runId);
expect(status.status).toBe('succeeded');
const defaultMessagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(defaultMessagesRes.ok).toBe(true);
const defaultMessages = (await defaultMessagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(defaultMessages.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: 'headless fallback prompt',
}),
]),
);
const recentMessagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}/messages`,
);
expect(recentMessagesRes.ok).toBe(true);
const recentMessages = (await recentMessagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(recentMessages.messages.some((msg) => msg.content === 'headless fallback prompt')).toBe(false);
});
}, 30_000);
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
await withFakeClaude(async () => {
const { id, dir } = await createProject();

View file

@ -142,6 +142,8 @@ async function runToolsDevSuite(
break;
} catch (error) {
if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error;
await runtime.release().catch(() => {});
await toolsDev.stopToolsDevWeb(suite).catch(() => {});
runtime = await toolsDev.allocateToolsDevRuntime();
}
}

View file

@ -161,7 +161,8 @@ export function isToolsDevPortConflict(error: unknown): boolean {
const text = error instanceof Error
? `${error.message}\n${error.stack ?? ''}`
: String(error);
return text.includes('EADDRINUSE');
return text.includes('EADDRINUSE') ||
(text.includes('is already running in namespace') && text.includes('stop it or choose another namespace'));
}
async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T> {

View file

@ -0,0 +1,19 @@
// @vitest-environment node
import { describe, expect, test } from 'vitest';
import { isToolsDevPortConflict } from '@/vitest/tools-dev';
describe('tools-dev startup conflict detection', () => {
test('classifies port and namespace startup collisions as retryable', () => {
expect(isToolsDevPortConflict(new Error('listen EADDRINUSE: address already in use 127.0.0.1:30123'))).toBe(true);
expect(
isToolsDevPortConflict(
new Error(
'daemon is already running in namespace e2e-orbit-run-123 at http://127.0.0.1:36695; stop it or choose another namespace',
),
),
).toBe(true);
expect(isToolsDevPortConflict(new Error('daemon exited before readiness'))).toBe(false);
});
});

View file

@ -46,6 +46,41 @@ export interface ProjectFilesResponse {
files: ProjectFile[];
}
export type ProjectExportManifestFileRole =
| 'entry'
| 'artifact'
| 'supporting'
| 'asset'
| 'source'
| 'other';
export interface ProjectExportManifestFile extends ProjectFile {
included: boolean;
role: ProjectExportManifestFileRole;
reasons: string[];
}
export interface ProjectExportManifestArtifact {
file: string;
title: string;
kind: ArtifactKind | null;
renderer: string | null;
status: string | null;
exports: string[];
supportingFiles: string[];
updatedAt: string | null;
}
export interface ProjectExportManifestResponse {
schema: 'open-design.project-export-manifest.v1';
projectId: string;
projectName: string | null;
generatedAt: string;
entryFile: string | null;
files: ProjectExportManifestFile[];
artifacts: ProjectExportManifestArtifact[];
}
export interface ProjectFileResponse {
file: ProjectFile;
}