@jxsuite/import 0.39.7 → 0.40.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 CHANGED
@@ -7,7 +7,7 @@
7
7
  * during import.
8
8
  */
9
9
 
10
- import { join } from "node:path";
10
+ import { extname, join } from "node:path";
11
11
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
12
  import type { Browser, Page } from "puppeteer-core";
13
13
  import { launchBrowser, closeBrowser } from "./capture.ts";
@@ -30,8 +30,27 @@ export interface VerifyOptions {
30
30
  viewportHeight?: number;
31
31
  /** Where to write diff images and the report (default: <projectDir>/verify/). */
32
32
  reportDir?: string;
33
- /** Pixelmatch threshold (default: 0.15). */
33
+ /**
34
+ * Pixelmatch's per-pixel COLOUR tolerance, 0..1 (default 0.15).
35
+ *
36
+ * It decides when two pixels count as the same colour, so it moves the score; it is not a
37
+ * pass/fail bar. `minFidelity` is the bar. Calling this one a "threshold" is what let a run
38
+ * scoring 8% exit 0 (issue #232).
39
+ */
34
40
  threshold?: number;
41
+ /**
42
+ * The average fidelity, 0..100, below which the run is a failure (default 0 — report only).
43
+ *
44
+ * Reported as `passed`; the caller decides what to do with it. `jx-import` exits non-zero.
45
+ */
46
+ minFidelity?: number;
47
+ /**
48
+ * Compare the whole scrollable page rather than the viewport (default true).
49
+ *
50
+ * It used to be the first 900px, which on a long marketing page is a small fraction of what was
51
+ * imported — most of the clone was never looked at.
52
+ */
53
+ fullPage?: boolean;
35
54
  /** Progress callback. */
36
55
  onProgress?: (msg: string) => void;
37
56
  /** Existing browser instance to reuse. */
@@ -47,6 +66,15 @@ export interface PageVerifyResult {
47
66
  diffImagePath: string;
48
67
  originalScreenshotPath: string;
49
68
  renderedScreenshotPath: string;
69
+ /**
70
+ * Console errors and uncaught exceptions the rendered page produced.
71
+ *
72
+ * A fidelity number says the clone looks wrong; these say why. The first import measured 404'd on
73
+ * 15 asset references and the verify pass reported a percentage and nothing else.
74
+ */
75
+ consoleErrors: string[];
76
+ /** Requests the rendered page made that failed or answered 4xx/5xx, as "<status> <url>". */
77
+ failedRequests: string[];
50
78
  error?: string;
51
79
  }
52
80
 
@@ -55,6 +83,18 @@ export interface VerifyResult {
55
83
  /** Average fidelity across all pages. */
56
84
  averageFidelity: number;
57
85
  reportDir: string;
86
+ /**
87
+ * Errors the compiler reported while building the project.
88
+ *
89
+ * Previously logged, written into report.json, and then dropped — `verifyProject` carried on and
90
+ * `importSite` never saw them, so a project that did not build still exited 0.
91
+ */
92
+ buildErrors: string[];
93
+ /**
94
+ * Whether this run met its bar: it built cleanly, every page rendered, and the average fidelity
95
+ * reached `minFidelity`. The single fact a caller needs to gate on.
96
+ */
97
+ passed: boolean;
58
98
  }
59
99
 
60
100
  /**
@@ -76,18 +116,59 @@ export function routeToUrlPath(route: string): string {
76
116
  return path;
77
117
  }
78
118
 
119
+ /** What the rendered page said about itself while it loaded. */
120
+ interface PageDiagnostics {
121
+ consoleErrors: string[];
122
+ failedRequests: string[];
123
+ }
124
+
125
+ /**
126
+ * Listen for the two things a fidelity percentage cannot tell you: what the page logged, and what
127
+ * it asked for and did not get.
128
+ *
129
+ * Attached before `goto`, because a 404 on an asset referenced in the HTML happens during the
130
+ * navigation. Registration is optional — a stand-in page that only screenshots is still usable, and
131
+ * diagnostics are then simply empty rather than a crash.
132
+ */
133
+ function watchPage(page: Page): PageDiagnostics {
134
+ const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [] };
135
+ if (typeof (page as Partial<Page>).on !== "function") {
136
+ return diagnostics;
137
+ }
138
+ page.on("console", (msg) => {
139
+ if (msg.type() === "error") {
140
+ diagnostics.consoleErrors.push(msg.text());
141
+ }
142
+ });
143
+ // `pageerror` carries `Error | unknown`: a page can reject with anything at all, and a thrown
144
+ // String is still a page that broke.
145
+ page.on("pageerror", (error) => {
146
+ diagnostics.consoleErrors.push(error instanceof Error ? error.message : String(error));
147
+ });
148
+ page.on("requestfailed", (request) => {
149
+ diagnostics.failedRequests.push(`failed ${request.url()}`);
150
+ });
151
+ page.on("response", (response) => {
152
+ if (response.status() >= 400) {
153
+ diagnostics.failedRequests.push(`${response.status()} ${response.url()}`);
154
+ }
155
+ });
156
+ return diagnostics;
157
+ }
158
+
79
159
  async function screenshotPage(
80
160
  page: Page,
81
161
  url: string,
82
162
  width: number,
83
163
  height: number,
164
+ fullPage: boolean,
84
165
  ): Promise<Buffer> {
85
166
  await page.setViewport({ width, height });
86
167
  await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
87
168
  await new Promise<void>((r) => {
88
169
  setTimeout(r, 500);
89
170
  });
90
- const screenshot = await page.screenshot({ type: "png", fullPage: false });
171
+ const screenshot = await page.screenshot({ fullPage, type: "png" });
91
172
  return Buffer.from(screenshot);
92
173
  }
93
174
 
@@ -99,9 +180,10 @@ export async function captureReferenceScreenshot(
99
180
  page: Page,
100
181
  width = 1440,
101
182
  height = 900,
183
+ fullPage = true,
102
184
  ): Promise<Buffer> {
103
185
  await page.setViewport({ width, height });
104
- const screenshot = await page.screenshot({ type: "png", fullPage: false });
186
+ const screenshot = await page.screenshot({ fullPage, type: "png" });
105
187
  return Buffer.from(screenshot);
106
188
  }
107
189
 
@@ -113,6 +195,39 @@ async function buildProject(projectDir: string): Promise<{ distDir: string; erro
113
195
  return { distDir, errors: result.errors };
114
196
  }
115
197
 
198
+ /**
199
+ * What the verify server declares for each extension it can serve.
200
+ *
201
+ * `.html`, `.css` and `.js` were the whole map, so every SVG, PNG, WOFF2 and JSON went out as
202
+ * `application/octet-stream`. A browser will not render an `<img src>` served as a binary blob and
203
+ * will not use a font it was handed as one, which cost the clone fidelity points that were not the
204
+ * importer's fault at all (issue #232).
205
+ */
206
+ const CONTENT_TYPES: Record<string, string> = {
207
+ ".avif": "image/avif",
208
+ ".css": "text/css",
209
+ ".gif": "image/gif",
210
+ ".htm": "text/html; charset=utf-8",
211
+ ".html": "text/html; charset=utf-8",
212
+ ".ico": "image/x-icon",
213
+ ".jpeg": "image/jpeg",
214
+ ".jpg": "image/jpeg",
215
+ ".js": "text/javascript; charset=utf-8",
216
+ ".json": "application/json; charset=utf-8",
217
+ ".map": "application/json; charset=utf-8",
218
+ ".mjs": "text/javascript; charset=utf-8",
219
+ ".otf": "font/otf",
220
+ ".png": "image/png",
221
+ ".svg": "image/svg+xml",
222
+ ".ttf": "font/ttf",
223
+ ".txt": "text/plain; charset=utf-8",
224
+ ".webmanifest": "application/manifest+json",
225
+ ".webp": "image/webp",
226
+ ".woff": "font/woff",
227
+ ".woff2": "font/woff2",
228
+ ".xml": "application/xml; charset=utf-8",
229
+ };
230
+
116
231
  /**
117
232
  * Serve a directory over HTTP using Bun's built-in server. Returns the server instance and the base
118
233
  * URL.
@@ -134,13 +249,8 @@ export function serveDirectory(dir: string): {
134
249
  const filePath = join(dir, pathname);
135
250
  const file = Bun.file(filePath);
136
251
  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";
252
+ const contentType =
253
+ CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
144
254
  return new Response(file, { headers: { "Content-Type": contentType } });
145
255
  }
146
256
  return new Response("Not Found", { status: 404 });
@@ -172,6 +282,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
172
282
  viewportWidth = 1440,
173
283
  viewportHeight = 900,
174
284
  threshold = 0.15,
285
+ minFidelity = 0,
286
+ fullPage = true,
175
287
  onProgress = () => {},
176
288
  } = opts;
177
289
 
@@ -201,10 +313,14 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
201
313
  diffImagePath: "",
202
314
  originalScreenshotPath: "",
203
315
  renderedScreenshotPath: "",
316
+ consoleErrors: [],
317
+ failedRequests: [],
204
318
  error: `Build failed: ${err.message}`,
205
319
  })),
206
320
  averageFidelity: 0,
207
321
  reportDir,
322
+ buildErrors: [`Build failed: ${err.message}`],
323
+ passed: false,
208
324
  };
209
325
  }
210
326
 
@@ -230,13 +346,23 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
230
346
  const renderedUrl = `${baseUrl}${urlPath}`;
231
347
  onProgress(` Screenshotting rendered: ${renderedUrl}`);
232
348
  const renderedPage = await browser.newPage();
349
+ // Listeners go on before the navigation — a 404 on an asset named in the HTML happens
350
+ // During it, and that is exactly the class of failure the score alone cannot explain.
351
+ const diagnostics = watchPage(renderedPage);
233
352
  const renderedPng = await screenshotPage(
234
353
  renderedPage,
235
354
  renderedUrl,
236
355
  viewportWidth,
237
356
  viewportHeight,
357
+ fullPage,
238
358
  );
239
359
  await renderedPage.close();
360
+ if (diagnostics.failedRequests.length > 0) {
361
+ onProgress(` ${diagnostics.failedRequests.length} request(s) failed or 404'd`);
362
+ }
363
+ if (diagnostics.consoleErrors.length > 0) {
364
+ onProgress(` ${diagnostics.consoleErrors.length} console error(s)`);
365
+ }
240
366
 
241
367
  // Diff
242
368
  onProgress(" Computing pixel diff...");
@@ -262,6 +388,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
262
388
  diffImagePath: diffPath,
263
389
  originalScreenshotPath: origPath,
264
390
  renderedScreenshotPath: renderedPath,
391
+ consoleErrors: diagnostics.consoleErrors,
392
+ failedRequests: diagnostics.failedRequests,
265
393
  });
266
394
  } catch (error) {
267
395
  const err = error as Error;
@@ -275,6 +403,8 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
275
403
  diffImagePath: "",
276
404
  originalScreenshotPath: "",
277
405
  renderedScreenshotPath: "",
406
+ consoleErrors: [],
407
+ failedRequests: [],
278
408
  error: err.message,
279
409
  });
280
410
  }
@@ -290,18 +420,40 @@ export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult>
290
420
  ) / 100
291
421
  : 0;
292
422
 
423
+ /*
424
+ * A run passes only if all three hold. Any one of them alone can be silently wrong: a project
425
+ * that did not build still renders SOMETHING, a page that failed to screenshot is excluded
426
+ * from the average rather than dragging it down, and a page that rendered can still be a
427
+ * different site. Previously none of them could fail the run — an 8%-fidelity clone printed
428
+ * `Done!` and exited 0, which is worse than failing because it is only visible to somebody who
429
+ * opens the result and looks at it (issue #232).
430
+ */
431
+ const errored = results.filter((r) => r.error);
432
+ const passed =
433
+ (buildErrors ?? []).length === 0 && errored.length === 0 && averageFidelity >= minFidelity;
434
+
293
435
  const report = {
294
436
  timestamp: new Date().toISOString(),
295
437
  projectDir,
296
438
  viewport: { width: viewportWidth, height: viewportHeight },
297
- threshold,
439
+ fullPage,
440
+ /** Pixelmatch's per-pixel colour tolerance — see `VerifyOptions.threshold`. */
441
+ colorTolerance: threshold,
442
+ minFidelity,
298
443
  averageFidelity,
444
+ passed,
299
445
  pages: results,
300
446
  buildErrors: buildErrors ?? [],
301
447
  };
302
448
  writeFileSync(join(reportDir, "report.json"), JSON.stringify(report, null, 2));
303
449
 
304
- return { pages: results, averageFidelity, reportDir };
450
+ return {
451
+ pages: results,
452
+ averageFidelity,
453
+ reportDir,
454
+ buildErrors: buildErrors ?? [],
455
+ passed,
456
+ };
305
457
  } finally {
306
458
  void server.stop();
307
459
  if (!opts.browser) {