mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(web): queue chat sends * feat(web): render code comment directives * feat(web): add preview comments and manual edits * fix(web): polish shared chrome controls * fix(web): align queued send loading state * feat(web): open primary project artifacts * fix(web): keep queued sends and tests aligned * fix(web): restore docked comment tools layout * fix(web): align preview comment toolbar * fix(web): place local cli beside handoff * fix(web): move agent menu beside handoff * fix(web): make project instructions a direct header action * fix(web): compact handoff and toolbar labels * fix(web): clarify handoff menu and annotation label * fix(web): restore compact cursor handoff trigger * fix(web): align agent menu trigger with handoff * fix(web): add draw toolbar close action * fix(web): move inspect editing into edit mode * fix(web): avoid reserving comment sidebar in annotation mode * fix(web): float preview comments panel * fix(web): keep edit canvas full width * fix(web): polish preview annotation tools * fix(web): highlight active preview comments * fix(web): open comments panel after annotation save * fix(web): polish comment handoff controls * fix(web): remove palette preview tool * fix(web): simplify draw annotation toolbar * fix(web): restore queued tasks into composer * fix(web): restore queued send strip styling * fix(web): hide internal comment target ids * fix(web): align manual edit panel header * test(web): cover visual interaction contracts * fix(web): address PR feedback regressions * fix(web): preserve artifact chrome state * fix(daemon): restore project raw file routes --------- Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> Co-authored-by: mrcfps <mrc@powerformer.com>
240 lines
9.1 KiB
TypeScript
240 lines
9.1 KiB
TypeScript
/**
|
|
* Coverage for `GET /api/projects/:id`. The route was extended (#451) to
|
|
* include a derived `resolvedDir` field so the web client can address the
|
|
* on-disk working directory directly without reconstructing it from the
|
|
* daemon's internal projects root. Two cases:
|
|
* 1. Folder-imported project — `resolvedDir === metadata.baseDir`.
|
|
* 2. Native project — `resolvedDir === path.join(<projects root>, id)`.
|
|
*
|
|
* Pre-existing daemon test files cover specific subdomains
|
|
* (folder-import-projects, project-status, project-watchers, ...);
|
|
* none own this route, so a dedicated `projects-routes` file is cleaner
|
|
* than expanding any of them.
|
|
*/
|
|
import type http from 'node:http';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
describe('GET /api/projects/:id resolvedDir', () => {
|
|
let server: http.Server;
|
|
let baseUrl: 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;
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
function makeFolder(): string {
|
|
const d = mkdtempSync(path.join(tmpdir(), 'od-projects-routes-'));
|
|
tempDirs.push(d);
|
|
return d;
|
|
}
|
|
|
|
it('returns resolvedDir === metadata.baseDir for an imported-folder project', async () => {
|
|
const folder = makeFolder();
|
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
|
|
|
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ baseDir: folder }),
|
|
});
|
|
expect(importResp.status).toBe(200);
|
|
const importBody = (await importResp.json()) as {
|
|
project: { id: string; metadata?: { baseDir?: string } };
|
|
};
|
|
const projectId = importBody.project.id;
|
|
const baseDir = importBody.project.metadata?.baseDir;
|
|
expect(baseDir).toBeTruthy();
|
|
|
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
|
expect(detailResp.status).toBe(200);
|
|
const detail = (await detailResp.json()) as {
|
|
project: { id: string };
|
|
resolvedDir: string;
|
|
};
|
|
expect(detail.project.id).toBe(projectId);
|
|
expect(detail.resolvedDir).toBe(baseDir);
|
|
});
|
|
|
|
it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
|
|
const projectId = `proj-routes-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Native fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
|
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
|
expect(detailResp.status).toBe(200);
|
|
const detail = (await detailResp.json()) as {
|
|
project: { id: string; metadata?: { baseDir?: string } };
|
|
resolvedDir: string;
|
|
};
|
|
expect(detail.project.metadata?.baseDir).toBeUndefined();
|
|
|
|
const dataDir = process.env.OD_DATA_DIR;
|
|
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
|
|
const expected = path.join(dataDir, 'projects', projectId);
|
|
expect(detail.resolvedDir).toBe(expected);
|
|
expect(path.isAbsolute(detail.resolvedDir)).toBe(true);
|
|
});
|
|
|
|
it('persists skipDiscoveryBrief for batch-created projects', async () => {
|
|
const projectId = `proj-skip-discovery-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Batch fixture',
|
|
skillId: null,
|
|
designSystemId: 'default',
|
|
metadata: { kind: 'prototype', platform: 'responsive' },
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
const body = (await createResp.json()) as {
|
|
project: { designSystemId?: string | null; metadata?: { skipDiscoveryBrief?: boolean } };
|
|
};
|
|
expect(body.project.designSystemId).toBe('default');
|
|
expect(body.project.metadata?.skipDiscoveryBrief).toBe(true);
|
|
});
|
|
|
|
it('serves project files through raw and files path routes', async () => {
|
|
const projectId = `proj-raw-route-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Raw route fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
|
|
const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'index.html', content: '<!doctype html><h1>ok</h1>' }),
|
|
});
|
|
expect(writeResp.status).toBe(200);
|
|
|
|
const rawResp = await fetch(`${baseUrl}/api/projects/${projectId}/raw/index.html`);
|
|
expect(rawResp.status).toBe(200);
|
|
expect(rawResp.headers.get('content-type')).toContain('text/html');
|
|
expect(await rawResp.text()).toContain('<h1>ok</h1>');
|
|
|
|
const fileResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html`);
|
|
expect(fileResp.status).toBe(200);
|
|
expect(await fileResp.text()).toContain('<h1>ok</h1>');
|
|
});
|
|
|
|
it('rejects non-boolean skipDiscoveryBrief on POST /api/projects', async () => {
|
|
const projectId = `proj-skip-discovery-bad-${Date.now()}`;
|
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Bad batch fixture',
|
|
skipDiscoveryBrief: 'yes',
|
|
}),
|
|
});
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
|
expect(body.error?.message).toMatch(/skipDiscoveryBrief/i);
|
|
});
|
|
|
|
it('returns 404 with PROJECT_NOT_FOUND for unknown ids', async () => {
|
|
const resp = await fetch(`${baseUrl}/api/projects/does-not-exist-${Date.now()}`);
|
|
expect(resp.status).toBe(404);
|
|
const body = (await resp.json()) as { error?: { code?: string } };
|
|
expect(body.error?.code).toBe('PROJECT_NOT_FOUND');
|
|
});
|
|
|
|
// PR #974: `fromTrustedPicker` is privileged the same way `baseDir`
|
|
// is — only the HMAC-gated POST /api/import/folder may set it. POST
|
|
// /api/projects (the generic create endpoint) and PATCH
|
|
// /api/projects/:id must reject any client-supplied attempt to
|
|
// acquire or flip the marker, otherwise a compromised renderer could
|
|
// mark a previously-untrusted folder-imported project as trusted and
|
|
// re-open the openPath bypass.
|
|
it('rejects fromTrustedPicker on POST /api/projects', async () => {
|
|
const projectId = `proj-trusted-${Date.now()}`;
|
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Smuggled trust',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
metadata: { kind: 'prototype', fromTrustedPicker: true },
|
|
}),
|
|
});
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
|
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
|
|
});
|
|
|
|
it('rejects fromTrustedPicker on PATCH /api/projects/:id', async () => {
|
|
// Create a vanilla native project, then try to PATCH the
|
|
// trusted-picker marker onto it. The handler must refuse —
|
|
// PATCHing privileged metadata fields is the same threat surface
|
|
// as setting them on creation.
|
|
const projectId = `proj-trusted-patch-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Native fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
|
|
const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ metadata: { kind: 'prototype', fromTrustedPicker: true } }),
|
|
});
|
|
expect(patchResp.status).toBe(400);
|
|
const body = (await patchResp.json()) as { error?: { code?: string; message?: string } };
|
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
|
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
|
|
});
|
|
});
|