mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
498 lines
22 KiB
TypeScript
498 lines
22 KiB
TypeScript
import type http from 'node:http';
|
|
import { existsSync, realpathSync } from 'node:fs';
|
|
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import type {
|
|
DesktopExportArtifactInput,
|
|
DesktopExportArtifactResult,
|
|
DesktopRenderSlidesInput,
|
|
DesktopRenderSlidesResult,
|
|
} from '@open-design/sidecar-proto';
|
|
import { createProjectFileVersion } from '../src/project-file-versions.js';
|
|
import { startServer } from '../src/server.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Screenshot export — desktop renderer file handoff.
|
|
//
|
|
// The daemon hands the desktop renderer a scratch `outputDir` under the data
|
|
// root, the renderer writes image files there (instead of pushing base64
|
|
// through IPC), and the daemon reads them back and deletes the scratch dir.
|
|
// Exercised end-to-end at the HTTP boundary with a stub renderer standing in
|
|
// for the desktop runtime.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
const JPEG = Buffer.from(
|
|
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==',
|
|
'base64',
|
|
);
|
|
const PPTX = Buffer.from('PK\x03\x04editable-pptx');
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<boolean> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (predicate()) return true;
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
}
|
|
return predicate();
|
|
}
|
|
|
|
describe('screenshot export desktop renderer file handoff', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let exportRenderRoot: string;
|
|
const projectId = 'proj-export-handoff';
|
|
const seenDirs: string[] = [];
|
|
const seenInputs: DesktopRenderSlidesInput[] = [];
|
|
let versionedVersionId = '';
|
|
|
|
// Stub desktop renderer: assert we were handed an outputDir, write the image
|
|
// files there exactly like the real renderer's emitImages, return file paths.
|
|
const stubRenderer = async (input: DesktopRenderSlidesInput): Promise<DesktopRenderSlidesResult> => {
|
|
seenInputs.push(input);
|
|
if (!input.outputDir) return { ok: false, error: 'expected an outputDir handoff' };
|
|
seenDirs.push(input.outputDir);
|
|
await mkdir(input.outputDir, { recursive: true });
|
|
if (input.editable) {
|
|
const file = path.join(input.outputDir, 'deck.pptx');
|
|
await writeFile(file, PPTX);
|
|
return { ok: true, pptxFile: file, width: 1920, height: 1080, mode: 'deck' };
|
|
}
|
|
const jpeg = input.pageImageFormat === 'jpeg';
|
|
const file = path.join(input.outputDir, jpeg ? 'slide-0.jpeg' : 'slide-0.png');
|
|
await writeFile(file, jpeg ? JPEG : PNG);
|
|
return { ok: true, slideFiles: [file], width: 1920, height: 1080, mode: input.stitch ? 'deck' : 'page' };
|
|
};
|
|
|
|
const exportImage = () =>
|
|
fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
const started = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: stubRenderer,
|
|
})) as { url: string; server: http.Server };
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
|
|
const dataDir = process.env.OD_DATA_DIR!;
|
|
// The daemon derives the scratch dir from the realpath-resolved data root
|
|
// (RUNTIME_DATA_DIR_CANONICAL); on macOS OD_DATA_DIR may contain a symlink
|
|
// (/var -> /private/var), so resolve it the same way for the prefix check.
|
|
exportRenderRoot = path.join(realpathSync(dataDir), 'export-render');
|
|
const dir = path.join(dataDir, 'projects', projectId);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, 'index.html'), '<html><body><section class="slide">A</section></body></html>');
|
|
await writeFile(path.join(dir, 'page.html'), '<html><body><main>Ordinary page</main></body></html>');
|
|
const versioned = await createProjectFileVersion(
|
|
path.join(dataDir, 'projects'),
|
|
projectId,
|
|
'versioned.html',
|
|
'<html><body><main>Historical export version</main></body></html>',
|
|
);
|
|
versionedVersionId = versioned.id;
|
|
await writeFile(path.join(dir, 'versioned.html'), '<html><body><main>Current file content</main></body></html>');
|
|
});
|
|
|
|
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
it('hands the renderer an outputDir under the data root and returns the image', async () => {
|
|
const res = await exportImage();
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('image/png');
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
expect(bytes.subarray(0, 4).toString('hex')).toBe('89504e47'); // PNG magic
|
|
|
|
const used = seenDirs.at(-1)!;
|
|
expect(used).toBeTruthy();
|
|
expect(used.startsWith(exportRenderRoot + path.sep)).toBe(true);
|
|
});
|
|
|
|
it('forwards jpeg image exports to the screenshot renderer', async () => {
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', imageFormat: 'jpeg' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('image/jpeg');
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
expect(bytes.subarray(0, 2).toString('hex')).toBe('ffd8');
|
|
});
|
|
|
|
it('rejects multi-image JPEG image exports instead of truncating to the first chunk', async () => {
|
|
const chunkingRenderer = async (input: DesktopRenderSlidesInput): Promise<DesktopRenderSlidesResult> => {
|
|
if (!input.outputDir) return { ok: false, error: 'expected an outputDir handoff' };
|
|
await mkdir(input.outputDir, { recursive: true });
|
|
const first = path.join(input.outputDir, 'slide-0.jpeg');
|
|
const second = path.join(input.outputDir, 'slide-1.jpeg');
|
|
await writeFile(first, JPEG);
|
|
await writeFile(second, JPEG);
|
|
return { ok: true, slideFiles: [first, second], width: 1440, height: 1000, mode: 'page' };
|
|
};
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: chunkingRenderer,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', imageFormat: 'jpeg' }),
|
|
});
|
|
expect(res.status).toBe(502);
|
|
expect(await res.text()).toContain('single-image export');
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('routes legacy generic image export through the screenshot renderer too', async () => {
|
|
const before = seenDirs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', format: 'image', imageFormat: 'jpeg' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('image/jpeg');
|
|
expect(seenDirs.length).toBe(before + 1);
|
|
});
|
|
|
|
it('leaves omitted generic export deck signals undefined for renderer auto-detection', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', format: 'image' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
expect(seenInputs.at(-1)?.deck).toBeUndefined();
|
|
});
|
|
|
|
it('renders historical version image exports from the selected version content', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'versioned.html', versionId: versionedVersionId }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
expect(seenInputs.at(-1)?.html).toContain('Historical export version');
|
|
expect(seenInputs.at(-1)?.html).not.toContain('Current file content');
|
|
});
|
|
|
|
it('preserves generic export width and height contract inputs', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', format: 'image', width: 1280, height: 720 }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
expect(seenInputs.at(-1)?.width).toBe(1280);
|
|
expect(seenInputs.at(-1)?.height).toBe(720);
|
|
});
|
|
|
|
it('forwards dedicated image export viewport dimensions to the renderer', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'page.html', deck: false, width: 390, height: 844 }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
expect(seenInputs.at(-1)?.deck).toBe(false);
|
|
expect(seenInputs.at(-1)?.width).toBe(390);
|
|
expect(seenInputs.at(-1)?.height).toBe(844);
|
|
});
|
|
|
|
it('routes generic POST /export pdf through the raster screenshot renderer, not the vector path', async () => {
|
|
// Regression: the generic /export route used to render `format: pdf` with the
|
|
// vector printToPDF path (desktopArtifactExporter), which drops CJK glyphs in
|
|
// the packaged runtime. The shared contract advertises pdf as screenshot-
|
|
// rendered, so this route must hand pdf to the screenshot renderer (seenDirs
|
|
// grows = the stub renderer ran) and stream back a real PDF.
|
|
const before = seenDirs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', format: 'pdf' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('application/pdf');
|
|
expect(seenDirs.length).toBe(before + 1); // the screenshot renderer was invoked
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
expect(bytes.subarray(0, 4).toString('latin1')).toBe('%PDF'); // raster PDF assembled from the screenshots
|
|
});
|
|
|
|
it('preserves non-ASCII export titles in the RFC 5987 filename', async () => {
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', title: 'Café Deck 简报' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-disposition')).toContain(
|
|
"filename*=UTF-8''Caf%C3%A9%20Deck%20%E7%AE%80%E6%8A%A5.png",
|
|
);
|
|
});
|
|
|
|
it('omits page JPEG hints for omitted-deck PDF exports so detected decks stay PNG', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', format: 'pdf' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
expect(seenInputs.at(-1)?.deck).toBeUndefined();
|
|
expect(seenInputs.at(-1)?.paginate).toBe(true);
|
|
expect(seenInputs.at(-1)?.pageImageFormat).toBeUndefined();
|
|
});
|
|
|
|
it('streams editable PPTX files written by the desktop renderer', async () => {
|
|
const before = seenInputs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/pptx`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', editable: true }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain(
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
);
|
|
expect(seenInputs.at(-1)?.editable).toBe(true);
|
|
expect(seenInputs.length).toBe(before + 1);
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
expect(bytes.equals(PPTX)).toBe(true);
|
|
});
|
|
|
|
it('returns 422 when PPTX export is requested for a non-deck page', async () => {
|
|
const noSlideRenderer = async (): Promise<DesktopRenderSlidesResult> => ({
|
|
ok: false,
|
|
error: 'no slide surfaces found in this deck',
|
|
errorCode: 'NO_SLIDES',
|
|
});
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: noSlideRenderer,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/pptx`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'page.html' }),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { error?: { code?: string; message?: string } };
|
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
|
expect(body.error?.message).toContain('not a slide deck');
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('returns 422 for semantic renderer request errors', async () => {
|
|
const indexErrorRenderer = async (): Promise<DesktopRenderSlidesResult> => ({
|
|
ok: false,
|
|
error: 'slide index 4 is out of range (deck has 2 slide(s))',
|
|
errorCode: 'SLIDE_INDEX_OUT_OF_RANGE',
|
|
});
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: indexErrorRenderer,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', index: 4 }),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { error?: { code?: string; message?: string } };
|
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
|
expect(body.error?.message).toContain('out of range');
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('rejects unsupported image export formats before rendering', async () => {
|
|
const before = seenDirs.length;
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', imageFormat: 'webp' }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(seenDirs.length).toBe(before);
|
|
});
|
|
|
|
it('falls back to the legacy artifact exporter for image export when slide rendering is unavailable', async () => {
|
|
const seenLegacyInputs: DesktopExportArtifactInput[] = [];
|
|
const legacyExporter = async (
|
|
input: DesktopExportArtifactInput,
|
|
): Promise<DesktopExportArtifactResult> => {
|
|
seenLegacyInputs.push(input);
|
|
const dir = path.join(os.tmpdir(), `od-legacy-image-export-${Date.now()}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const file = path.join(dir, 'artifact.png');
|
|
await writeFile(file, PNG);
|
|
return { ok: true, path: file, mime: 'image/png', bytes: PNG.length };
|
|
};
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: null,
|
|
desktopArtifactExporter: legacyExporter,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html', deck: false }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('image/png');
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
expect(bytes.subarray(0, 4).toString('hex')).toBe('89504e47');
|
|
expect(seenLegacyInputs.at(-1)).toMatchObject({
|
|
deck: false,
|
|
format: 'image',
|
|
title: 'index',
|
|
});
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('does not fall back to the legacy artifact exporter for raster PDF export', async () => {
|
|
const seenLegacyInputs: DesktopExportArtifactInput[] = [];
|
|
const legacyExporter = async (
|
|
input: DesktopExportArtifactInput,
|
|
): Promise<DesktopExportArtifactResult> => {
|
|
seenLegacyInputs.push(input);
|
|
const dir = path.join(os.tmpdir(), `od-legacy-pdf-export-${Date.now()}`);
|
|
await mkdir(dir, { recursive: true });
|
|
const file = path.join(dir, 'artifact.pdf');
|
|
await writeFile(file, Buffer.from('%PDF-legacy'));
|
|
return { ok: true, path: file, mime: 'application/pdf' };
|
|
};
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: null,
|
|
desktopArtifactExporter: legacyExporter,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/pdf-image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
expect(res.status).toBe(501);
|
|
expect(await res.text()).toContain('screenshot export is only available');
|
|
expect(seenLegacyInputs).toHaveLength(0);
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('deletes the scratch render dir after the export completes', async () => {
|
|
const res = await exportImage();
|
|
await res.arrayBuffer();
|
|
const used = seenDirs.at(-1)!;
|
|
// Cleanup runs in the handler's finally, after the response is flushed —
|
|
// poll for eventual removal rather than asserting synchronously.
|
|
expect(await waitFor(() => !existsSync(used))).toBe(true);
|
|
const remaining = existsSync(exportRenderRoot) ? await readdir(exportRenderRoot) : [];
|
|
expect(remaining.length).toBe(0);
|
|
});
|
|
|
|
it('gives each concurrent export its own scratch dir', async () => {
|
|
const before = seenDirs.length;
|
|
await Promise.all(Array.from({ length: 5 }, () => exportImage().then((r) => r.arrayBuffer())));
|
|
const fresh = seenDirs.slice(before);
|
|
expect(fresh.length).toBe(5);
|
|
expect(new Set(fresh).size).toBe(5); // all unique — no collision across concurrent exports
|
|
});
|
|
|
|
it('rejects a renderer slide path outside the scratch dir (no path-traversal read)', async () => {
|
|
// A malicious/buggy renderer points slideFiles at a secret outside the
|
|
// scratch dir; the daemon must refuse rather than read & stream it back.
|
|
const dataDir = process.env.OD_DATA_DIR!;
|
|
const secret = path.join(dataDir, 'SECRET-out-of-tree.txt');
|
|
await writeFile(secret, 'TOP SECRET');
|
|
const evilRenderer = async (input: DesktopRenderSlidesInput): Promise<DesktopRenderSlidesResult> => {
|
|
if (input.outputDir) await mkdir(input.outputDir, { recursive: true });
|
|
return { ok: true, slideFiles: [secret], width: 1920, height: 1080, mode: 'page' };
|
|
};
|
|
const srv = (await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
desktopSlideRenderer: evilRenderer,
|
|
})) as { url: string; server: http.Server };
|
|
try {
|
|
const res = await fetch(`${srv.url}/api/projects/${projectId}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const body = await res.text();
|
|
expect(body).not.toContain('TOP SECRET');
|
|
} finally {
|
|
await new Promise<void>((resolve) => srv.server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('resolves an imported-folder project file via metadata.baseDir (not <data>/projects/:id)', async () => {
|
|
// Imported-folder projects keep their workspace OUTSIDE <data>/projects/:id
|
|
// (at metadata.baseDir). The screenshot export must thread that metadata into
|
|
// readProjectFile, or it 404s on the default dir even though the file exists.
|
|
const folder = path.join(realpathSync(os.tmpdir()), `od-import-export-${Date.now()}`);
|
|
await mkdir(folder, { recursive: true });
|
|
await writeFile(
|
|
path.join(folder, 'index.html'),
|
|
'<html><body><section class="slide">Imported</section></body></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 { project } = (await importResp.json()) as { project: { id: string } };
|
|
|
|
const res = await fetch(`${baseUrl}/api/projects/${project.id}/export/image`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: 'index.html' }),
|
|
});
|
|
// 200 (image streamed back) proves the file was resolved via baseDir — a
|
|
// regression would surface as 404 FILE_NOT_FOUND.
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('image/png');
|
|
});
|
|
});
|