@jxsuite/import 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verify.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Verify — render each emitted page in the Jx runtime, screenshot-diff vs the captured original,
3
+ * and report a per-page fidelity score.
4
+ *
5
+ * Uses the compiler's `buildSite` to produce static HTML, serves it locally via Bun, then
6
+ * screenshots the rendered pages with puppeteer and diffs against reference screenshots captured
7
+ * during import.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
+ import type { Browser, Page } from "puppeteer-core";
13
+ import { launchBrowser, closeBrowser } from "./capture.ts";
14
+ import { diffScreenshots } from "./screenshot-diff.ts";
15
+
16
+ export interface PageRef {
17
+ sourceUrl: string;
18
+ /** Pre-captured reference screenshot (PNG buffer or path to a .png file on disk). */
19
+ screenshot: Buffer | string;
20
+ }
21
+
22
+ export interface VerifyOptions {
23
+ /** The emitted Jx project directory (contains project.json). */
24
+ projectDir: string;
25
+ /** Map of route path (e.g. "pages/index.json") → page reference data. */
26
+ pages: Map<string, PageRef>;
27
+ /** Viewport width (default: 1440). */
28
+ viewportWidth?: number;
29
+ /** Viewport height (default: 900). */
30
+ viewportHeight?: number;
31
+ /** Where to write diff images and the report (default: <projectDir>/verify/). */
32
+ reportDir?: string;
33
+ /** Pixelmatch threshold (default: 0.15). */
34
+ threshold?: number;
35
+ /** Progress callback. */
36
+ onProgress?: (msg: string) => void;
37
+ /** Existing browser instance to reuse. */
38
+ browser?: Browser;
39
+ }
40
+
41
+ export interface PageVerifyResult {
42
+ route: string;
43
+ sourceUrl: string;
44
+ fidelity: number;
45
+ mismatchedPixels: number;
46
+ totalPixels: number;
47
+ diffImagePath: string;
48
+ originalScreenshotPath: string;
49
+ renderedScreenshotPath: string;
50
+ error?: string;
51
+ }
52
+
53
+ export interface VerifyResult {
54
+ pages: PageVerifyResult[];
55
+ /** Average fidelity across all pages. */
56
+ averageFidelity: number;
57
+ reportDir: string;
58
+ }
59
+
60
+ /**
61
+ * Map a source page route (e.g. "pages/about.json") to the URL path the built site serves.
62
+ * "pages/index.json" → "/", "pages/about.json" → "/about", "pages/blog/post-1.json" →
63
+ * "/blog/post-1"
64
+ */
65
+ export function routeToUrlPath(route: string): string {
66
+ let path = route
67
+ .replace(/^pages\//, "/")
68
+ .replace(/\.json$/, "")
69
+ .replace(/\/index$/, "/");
70
+ if (path !== "/" && path.endsWith("/")) {
71
+ path = path.slice(0, -1);
72
+ }
73
+ if (!path.startsWith("/")) {
74
+ path = `/${path}`;
75
+ }
76
+ return path;
77
+ }
78
+
79
+ async function screenshotPage(
80
+ page: Page,
81
+ url: string,
82
+ width: number,
83
+ height: number,
84
+ ): Promise<Buffer> {
85
+ await page.setViewport({ width, height });
86
+ await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
87
+ await new Promise<void>((r) => {
88
+ setTimeout(r, 500);
89
+ });
90
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
91
+ return Buffer.from(screenshot);
92
+ }
93
+
94
+ /**
95
+ * Take a reference screenshot of a live page via puppeteer. Called during import so the screenshot
96
+ * is captured at the same time as the DOM.
97
+ */
98
+ export async function captureReferenceScreenshot(
99
+ page: Page,
100
+ width = 1440,
101
+ height = 900,
102
+ ): Promise<Buffer> {
103
+ await page.setViewport({ width, height });
104
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
105
+ return Buffer.from(screenshot);
106
+ }
107
+
108
+ /** Build the Jx project to static HTML using the compiler. */
109
+ async function buildProject(projectDir: string): Promise<{ distDir: string; errors: string[] }> {
110
+ const { buildSite } = await import("@jxsuite/compiler/site");
111
+ const distDir = join(projectDir, "dist");
112
+ const result = await buildSite(projectDir, { clean: true, verbose: false });
113
+ return { distDir, errors: result.errors };
114
+ }
115
+
116
+ /**
117
+ * Serve a directory over HTTP using Bun's built-in server. Returns the server instance and the base
118
+ * URL.
119
+ */
120
+ export function serveDirectory(dir: string): {
121
+ server: ReturnType<typeof Bun.serve>;
122
+ baseUrl: string;
123
+ } {
124
+ const server = Bun.serve({
125
+ port: 0,
126
+ async fetch(req) {
127
+ let { pathname } = new URL(req.url);
128
+ if (pathname === "/") {
129
+ pathname = "/index.html";
130
+ } else if (!pathname.includes(".")) {
131
+ pathname = `${pathname}/index.html`;
132
+ }
133
+
134
+ const filePath = join(dir, pathname);
135
+ const file = Bun.file(filePath);
136
+ if (await file.exists()) {
137
+ const contentType = filePath.endsWith(".html")
138
+ ? "text/html"
139
+ : filePath.endsWith(".css")
140
+ ? "text/css"
141
+ : filePath.endsWith(".js")
142
+ ? "application/javascript"
143
+ : "application/octet-stream";
144
+ return new Response(file, { headers: { "Content-Type": contentType } });
145
+ }
146
+ return new Response("Not Found", { status: 404 });
147
+ },
148
+ });
149
+
150
+ return { server, baseUrl: `http://localhost:${server.port}` };
151
+ }
152
+
153
+ function resolveScreenshot(ref: Buffer | string): Buffer {
154
+ if (Buffer.isBuffer(ref)) {
155
+ return ref;
156
+ }
157
+ return readFileSync(ref);
158
+ }
159
+
160
+ /**
161
+ * Verify a cloned Jx project against reference screenshots captured during import.
162
+ *
163
+ * 1. Build the project to static HTML via the Jx compiler
164
+ * 2. Serve the built output locally
165
+ * 3. For each page: screenshot the rendered page, diff against the reference screenshot
166
+ * 4. Report per-page fidelity
167
+ */
168
+ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult> {
169
+ const {
170
+ projectDir,
171
+ pages,
172
+ viewportWidth = 1440,
173
+ viewportHeight = 900,
174
+ threshold = 0.15,
175
+ onProgress = () => {},
176
+ } = opts;
177
+
178
+ const reportDir = opts.reportDir ?? join(projectDir, "verify");
179
+ mkdirSync(reportDir, { recursive: true });
180
+
181
+ // Step 1: Build the project
182
+ onProgress("Building project to static HTML...");
183
+ let distDir: string;
184
+ let buildErrors: string[];
185
+ try {
186
+ const buildResult = await buildProject(projectDir);
187
+ ({ distDir } = buildResult);
188
+ buildErrors = buildResult.errors;
189
+ if (buildErrors.length > 0) {
190
+ onProgress(` Build completed with ${buildErrors.length} error(s)`);
191
+ }
192
+ } catch (error) {
193
+ const err = error as Error;
194
+ return {
195
+ pages: [...pages.entries()].map(([route, ref]) => ({
196
+ route,
197
+ sourceUrl: ref.sourceUrl,
198
+ fidelity: 0,
199
+ mismatchedPixels: 0,
200
+ totalPixels: 0,
201
+ diffImagePath: "",
202
+ originalScreenshotPath: "",
203
+ renderedScreenshotPath: "",
204
+ error: `Build failed: ${err.message}`,
205
+ })),
206
+ averageFidelity: 0,
207
+ reportDir,
208
+ };
209
+ }
210
+
211
+ // Step 2: Serve the built output
212
+ onProgress("Starting local server for rendered pages...");
213
+ const { server, baseUrl } = serveDirectory(distDir);
214
+
215
+ const browser = opts.browser ?? (await launchBrowser());
216
+ const results: PageVerifyResult[] = [];
217
+
218
+ try {
219
+ // Step 3: Screenshot rendered pages and diff against references
220
+ for (const [route, ref] of pages) {
221
+ const urlPath = routeToUrlPath(route);
222
+ const safeName = urlPath === "/" ? "index" : urlPath.slice(1).replaceAll("/", "-");
223
+
224
+ onProgress(` Verifying ${route}...`);
225
+
226
+ try {
227
+ const originalPng = resolveScreenshot(ref.screenshot);
228
+
229
+ // Screenshot rendered
230
+ const renderedUrl = `${baseUrl}${urlPath}`;
231
+ onProgress(` Screenshotting rendered: ${renderedUrl}`);
232
+ const renderedPage = await browser.newPage();
233
+ const renderedPng = await screenshotPage(
234
+ renderedPage,
235
+ renderedUrl,
236
+ viewportWidth,
237
+ viewportHeight,
238
+ );
239
+ await renderedPage.close();
240
+
241
+ // Diff
242
+ onProgress(" Computing pixel diff...");
243
+ const diff = diffScreenshots(originalPng, renderedPng, { threshold });
244
+
245
+ // Write artifacts
246
+ const origPath = join(reportDir, `${safeName}-original.png`);
247
+ const renderedPath = join(reportDir, `${safeName}-rendered.png`);
248
+ const diffPath = join(reportDir, `${safeName}-diff.png`);
249
+
250
+ writeFileSync(origPath, originalPng);
251
+ writeFileSync(renderedPath, renderedPng);
252
+ writeFileSync(diffPath, diff.diffPng);
253
+
254
+ onProgress(` Fidelity: ${diff.fidelity}% (${diff.mismatchedPixels} mismatched pixels)`);
255
+
256
+ results.push({
257
+ route,
258
+ sourceUrl: ref.sourceUrl,
259
+ fidelity: diff.fidelity,
260
+ mismatchedPixels: diff.mismatchedPixels,
261
+ totalPixels: diff.totalPixels,
262
+ diffImagePath: diffPath,
263
+ originalScreenshotPath: origPath,
264
+ renderedScreenshotPath: renderedPath,
265
+ });
266
+ } catch (error) {
267
+ const err = error as Error;
268
+ onProgress(` Error: ${err.message}`);
269
+ results.push({
270
+ route,
271
+ sourceUrl: ref.sourceUrl,
272
+ fidelity: 0,
273
+ mismatchedPixels: 0,
274
+ totalPixels: 0,
275
+ diffImagePath: "",
276
+ originalScreenshotPath: "",
277
+ renderedScreenshotPath: "",
278
+ error: err.message,
279
+ });
280
+ }
281
+ }
282
+
283
+ // Step 4: Write summary report
284
+ const successfulPages = results.filter((r) => !r.error);
285
+ const averageFidelity =
286
+ successfulPages.length > 0
287
+ ? Math.round(
288
+ (successfulPages.reduce((sum, r) => sum + r.fidelity, 0) / successfulPages.length) *
289
+ 100,
290
+ ) / 100
291
+ : 0;
292
+
293
+ const report = {
294
+ timestamp: new Date().toISOString(),
295
+ projectDir,
296
+ viewport: { width: viewportWidth, height: viewportHeight },
297
+ threshold,
298
+ averageFidelity,
299
+ pages: results,
300
+ buildErrors: buildErrors ?? [],
301
+ };
302
+ writeFileSync(join(reportDir, "report.json"), JSON.stringify(report, null, 2));
303
+
304
+ return { pages: results, averageFidelity, reportDir };
305
+ } finally {
306
+ void server.stop();
307
+ if (!opts.browser) {
308
+ await closeBrowser();
309
+ }
310
+ }
311
+ }