Skip to content

Commit 96e4d19

Browse files
committed
test(api): add render API integration tests
- Refactor startServer into createServer returning a testable handle - Test healthz, templates list, local HTML/PDF render, archive upload PDF render, ImageMagick pixel validation, and error cases - Install root deps in CI for FACET_PACKAGE_PATH resolution
1 parent 959f13d commit 96e4d19

3 files changed

Lines changed: 252 additions & 9 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ jobs:
3939
magick -version || convert -version
4040
4141
- name: Install dependencies
42-
run: cd cli && npm install
42+
run: |
43+
npm install
44+
cd cli && npm install
4345
4446
- name: Run tests
4547
run: cd cli && bun test

cli/src/server/preview.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import { resolve } from 'path';
22
import { Logger } from '../utils/logger.js';
3-
import { loadConfig, type ServerCLIFlags } from './config.js';
3+
import { loadConfig, type ServerCLIFlags, type ServerConfig } from './config.js';
44
import { checkAuth } from './auth.js';
55
import { errorResponse } from './errors.js';
66
import { WorkerPool } from './worker-pool.js';
7-
import { discoverTemplates } from './templates.js';
7+
import { discoverTemplates, type TemplateInfo } from './templates.js';
88
import { S3Uploader } from './s3.js';
99
import { handleHealthz, handleTemplates, handleRender } from './routes.js';
1010

11-
export async function startServer(flags: ServerCLIFlags): Promise<void> {
12-
const config = loadConfig(flags);
11+
export interface ServerHandle {
12+
port: number;
13+
url: string;
14+
stop: () => Promise<void>;
15+
}
16+
17+
export async function createServer(config: ServerConfig): Promise<ServerHandle> {
1318
const logger = new Logger(config.verbose);
1419

1520
const templatesDir = resolve(process.cwd(), config.templatesDir);
1621
logger.info(`Templates directory: ${templatesDir}`);
1722

1823
const templates = await discoverTemplates(templatesDir);
19-
logger.info(`Discovered ${templates.length} templates: ${templates.map((t) => t.name).join(', ') || '(none)'}`);
24+
logger.info(`Discovered ${templates.length} templates: ${templates.map((t: TemplateInfo) => t.name).join(', ') || '(none)'}`);
2025

2126
const pool = new WorkerPool(config.workers, config.verbose);
2227
await pool.start();
@@ -50,10 +55,22 @@ export async function startServer(flags: ServerCLIFlags): Promise<void> {
5055

5156
logger.success(`Server listening on http://localhost:${server.port}`);
5257

58+
return {
59+
port: server.port,
60+
url: `http://localhost:${server.port}`,
61+
stop: async () => {
62+
server.stop();
63+
await pool.shutdown();
64+
},
65+
};
66+
}
67+
68+
export async function startServer(flags: ServerCLIFlags): Promise<void> {
69+
const config = loadConfig(flags);
70+
const handle = await createServer(config);
71+
5372
const shutdown = async () => {
54-
logger.info('Shutting down...');
55-
server.stop();
56-
await pool.shutdown();
73+
await handle.stop();
5774
process.exit(0);
5875
};
5976

cli/test/render-api.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* Integration tests for the /render API endpoint.
3+
*
4+
* Starts a real server with a browser worker pool, sends templates
5+
* via JSON (local) and multipart (archive) requests, and validates
6+
* the returned PDFs with pdf-lib and ImageMagick.
7+
*/
8+
9+
import { join } from 'path';
10+
import { execSync } from 'child_process';
11+
import { mkdtemp, writeFile } from 'fs/promises';
12+
import { tmpdir } from 'os';
13+
import { PDFDocument } from 'pdf-lib';
14+
import { createServer, type ServerHandle } from '../src/server/preview.js';
15+
16+
const EXAMPLES_DIR = join(__dirname, '../examples');
17+
const REPO_ROOT = join(__dirname, '../..');
18+
19+
// Point to the local repo so npm install doesn't need a published package
20+
process.env['FACET_PACKAGE_PATH'] = REPO_ROOT;
21+
22+
function hasMagick(): boolean {
23+
try { execSync('magick -version', { stdio: 'pipe' }); return true; } catch {}
24+
try { execSync('convert -version', { stdio: 'pipe' }); return true; } catch {}
25+
return false;
26+
}
27+
28+
describe('Render API', () => {
29+
let server: ServerHandle;
30+
31+
beforeAll(async () => {
32+
server = await createServer({
33+
port: 0,
34+
templatesDir: EXAMPLES_DIR,
35+
workers: 1,
36+
renderTimeout: 60000,
37+
maxUploadSize: 52428800,
38+
verbose: false,
39+
});
40+
}, 30000);
41+
42+
afterAll(async () => {
43+
await server?.stop();
44+
}, 15000);
45+
46+
test('GET /healthz returns ok', async () => {
47+
const res = await fetch(`${server.url}/healthz`);
48+
expect(res.status).toBe(200);
49+
const body = await res.json();
50+
expect(body.status).toBe('ok');
51+
expect(body.workers.total).toBe(1);
52+
});
53+
54+
test('GET /templates lists discovered templates', async () => {
55+
const res = await fetch(`${server.url}/templates`);
56+
expect(res.status).toBe(200);
57+
const templates = await res.json();
58+
const names = templates.map((t: any) => t.name);
59+
expect(names).toContain('SimpleReport');
60+
});
61+
62+
test('POST /render with local template returns valid HTML', async () => {
63+
// First render is slow due to vite build + npm install in .facet dir
64+
const res = await fetch(`${server.url}/render`, {
65+
method: 'POST',
66+
headers: { 'Content-Type': 'application/json' },
67+
body: JSON.stringify({
68+
template: 'SimpleReport',
69+
format: 'html',
70+
data: {
71+
title: 'Test Report',
72+
sections: [{ title: 'Section 1', content: 'Hello world' }],
73+
},
74+
}),
75+
});
76+
if (res.status !== 200) {
77+
console.error('Render error:', await res.text());
78+
}
79+
expect(res.status).toBe(200);
80+
expect(res.headers.get('content-type')).toContain('text/html');
81+
const html = await res.text();
82+
expect(html.length).toBeGreaterThan(100);
83+
expect(html).toContain('Test Report');
84+
}, 120000);
85+
86+
test('POST /render with local template returns valid PDF', async () => {
87+
const res = await fetch(`${server.url}/render`, {
88+
method: 'POST',
89+
headers: { 'Content-Type': 'application/json' },
90+
body: JSON.stringify({
91+
template: 'SimpleReport',
92+
format: 'pdf',
93+
data: {
94+
title: 'PDF Test Report',
95+
sections: [
96+
{ title: 'Overview', content: 'This is a PDF integration test.' },
97+
{ title: 'Details', content: 'Validating end-to-end render pipeline.' },
98+
],
99+
},
100+
}),
101+
});
102+
expect(res.status).toBe(200);
103+
expect(res.headers.get('content-type')).toContain('application/pdf');
104+
105+
const pdfBytes = Buffer.from(await res.arrayBuffer());
106+
expect(pdfBytes.length).toBeGreaterThan(1000);
107+
108+
const doc = await PDFDocument.load(pdfBytes);
109+
expect(doc.getPageCount()).toBeGreaterThanOrEqual(1);
110+
const { width, height } = doc.getPage(0).getSize();
111+
// A4 at 72dpi: ~595 x 842 pt
112+
expect(width).toBeGreaterThan(500);
113+
expect(height).toBeGreaterThan(700);
114+
}, 60000);
115+
116+
test('POST /render with archive upload returns valid PDF', async () => {
117+
const tmpDir = await mkdtemp(join(tmpdir(), 'facet-test-'));
118+
const templateContent = `
119+
import React from 'react';
120+
export default function InlineTemplate({ data }: { data: any }) {
121+
return (
122+
<html>
123+
<body>
124+
<h1 style={{ color: '#e11d48', fontFamily: 'sans-serif' }}>
125+
{data.heading || 'Archive Template'}
126+
</h1>
127+
<p>Rendered from an uploaded archive.</p>
128+
</body>
129+
</html>
130+
);
131+
}`;
132+
await writeFile(join(tmpDir, 'Template.tsx'), templateContent);
133+
execSync(`tar -czf "${join(tmpDir, 'template.tar.gz')}" -C "${tmpDir}" Template.tsx`);
134+
const tarball = Bun.file(join(tmpDir, 'template.tar.gz'));
135+
136+
const formData = new FormData();
137+
formData.append('archive', tarball);
138+
formData.append('data', JSON.stringify({ heading: 'Inline PDF Test' }));
139+
formData.append('options', JSON.stringify({ format: 'pdf' }));
140+
141+
const res = await fetch(`${server.url}/render`, {
142+
method: 'POST',
143+
body: formData,
144+
});
145+
expect(res.status).toBe(200);
146+
expect(res.headers.get('content-type')).toContain('application/pdf');
147+
148+
const pdfBytes = Buffer.from(await res.arrayBuffer());
149+
expect(pdfBytes.length).toBeGreaterThan(1000);
150+
151+
const doc = await PDFDocument.load(pdfBytes);
152+
expect(doc.getPageCount()).toBeGreaterThanOrEqual(1);
153+
}, 60000);
154+
155+
(hasMagick() ? test : test.skip)('PDF renders visible content (ImageMagick)', async () => {
156+
const res = await fetch(`${server.url}/render`, {
157+
method: 'POST',
158+
headers: { 'Content-Type': 'application/json' },
159+
body: JSON.stringify({
160+
template: 'SimpleReport',
161+
format: 'pdf',
162+
data: {
163+
title: 'Pixel Check',
164+
sections: [{ title: 'Content', content: 'Visible text for pixel validation.' }],
165+
},
166+
}),
167+
});
168+
expect(res.status).toBe(200);
169+
const pdfBytes = Buffer.from(await res.arrayBuffer());
170+
171+
const tmpDir = await mkdtemp(join(tmpdir(), 'facet-magick-'));
172+
const pdfPath = join(tmpDir, 'output.pdf');
173+
const pngPath = join(tmpDir, 'page.png');
174+
await writeFile(pdfPath, pdfBytes);
175+
176+
execSync(`magick -density 72 "${pdfPath}[0]" "${pngPath}"`, { timeout: 15000 });
177+
178+
// Verify the PNG was created and has reasonable size (not blank)
179+
const pngSize = Bun.file(pngPath).size;
180+
expect(pngSize).toBeGreaterThan(1000);
181+
182+
// Convert to raw PPM and verify not all-white
183+
const ppmPath = join(tmpDir, 'page.ppm');
184+
execSync(`magick "${pngPath}" -depth 8 "${ppmPath}"`, { timeout: 10000 });
185+
const ppmData = await Bun.file(ppmPath).arrayBuffer();
186+
const pixels = Buffer.from(ppmData);
187+
188+
// Skip PPM header (3 lines), then check we have non-white pixels
189+
let headerEnd = 0;
190+
let newlines = 0;
191+
for (let i = 0; i < pixels.length && newlines < 3; i++) {
192+
if (pixels[i] === 0x0a) newlines++;
193+
headerEnd = i + 1;
194+
}
195+
const raw = pixels.subarray(headerEnd);
196+
let nonWhiteCount = 0;
197+
for (let i = 0; i < raw.length; i += 3) {
198+
if (raw[i] < 250 || raw[i + 1] < 250 || raw[i + 2] < 250) {
199+
nonWhiteCount++;
200+
}
201+
}
202+
// At least 0.1% of pixels should be non-white (text, borders, etc.)
203+
const totalPixels = raw.length / 3;
204+
expect(nonWhiteCount / totalPixels).toBeGreaterThan(0.001);
205+
}, 60000);
206+
207+
test('POST /render with unknown template returns 404', async () => {
208+
const res = await fetch(`${server.url}/render`, {
209+
method: 'POST',
210+
headers: { 'Content-Type': 'application/json' },
211+
body: JSON.stringify({ template: 'NonExistent', format: 'html', data: {} }),
212+
});
213+
expect(res.status).toBe(404);
214+
});
215+
216+
test('POST /render with missing template field returns 400', async () => {
217+
const res = await fetch(`${server.url}/render`, {
218+
method: 'POST',
219+
headers: { 'Content-Type': 'application/json' },
220+
body: JSON.stringify({ format: 'html', data: {} }),
221+
});
222+
expect(res.status).toBe(400);
223+
});
224+
});

0 commit comments

Comments
 (0)