@jxsuite/import 0.39.8 → 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/README.md +29 -2
- package/package.json +7 -5
- package/src/asset-collect.ts +9 -3
- package/src/asset-download.ts +8 -3
- package/src/asset-rewrite.ts +7 -1
- package/src/breakpoint-plan.ts +297 -0
- package/src/cli.ts +106 -2
- package/src/componentize.ts +13 -0
- package/src/crawl.ts +40 -2
- package/src/emit.ts +104 -11
- package/src/index.ts +21 -3
- package/src/media-extract.ts +51 -75
- package/src/run.ts +227 -12
- package/src/srcset.ts +32 -0
- package/src/strip-classes.ts +73 -0
- package/src/verify.ts +165 -13
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
|
-
/**
|
|
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"
|
|
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"
|
|
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 =
|
|
138
|
-
|
|
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
|
-
|
|
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 {
|
|
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) {
|