@jxsuite/import 0.39.8 → 0.40.1
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 +9 -5
- package/src/ai-componentize.ts +12 -2
- package/src/asset-collect.ts +11 -5
- package/src/asset-download.ts +18 -14
- package/src/asset-rewrite.ts +7 -1
- package/src/breakpoint-plan.ts +297 -0
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/cli.ts +106 -2
- package/src/componentize.ts +13 -0
- package/src/crawl.ts +50 -12
- package/src/emit.ts +136 -37
- package/src/index.ts +32 -8
- package/src/io.ts +52 -0
- package/src/media-extract.ts +53 -77
- package/src/pipeline.ts +605 -0
- package/src/run.ts +208 -403
- package/src/srcset.ts +32 -0
- package/src/strip-classes.ts +73 -0
- package/src/style-capture.ts +6 -3
- package/src/verify.ts +166 -14
package/src/srcset.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one `srcset` entry ends and the next begins.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because two passes have to agree about it: `asset-collect.ts` decides from it what
|
|
5
|
+
* gets DOWNLOADED, and `asset-rewrite.ts` reassembles the attribute from it. If they disagreed the
|
|
6
|
+
* importer would fetch one set of URLs and rewrite another.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `srcset` separator is a comma that starts a NEW URL. A comma inside a URL never is.
|
|
11
|
+
*
|
|
12
|
+
* Splitting on every comma shreds any URL carrying commas in its own path, and Wix, Cloudinary and
|
|
13
|
+
* imgix all encode image transforms exactly that way — `.../fill/w_375,h_127,al_c,q_85/logo.png`
|
|
14
|
+
* became a dozen unfetchable fragments. On one Wix site that was 109 failed downloads, and because
|
|
15
|
+
* a failed download leaves the reference untouched, 46 `static.wixstatic.com` URLs stayed in the
|
|
16
|
+
* emitted page: the "clone" served its images from the host it had cloned (issue #231).
|
|
17
|
+
*
|
|
18
|
+
* The lookahead lists the ways a URL can begin — absolute, `data:`, protocol-relative, root- or
|
|
19
|
+
* dot-relative, or a bare `name.ext` — which is what distinguishes a separator from a transform
|
|
20
|
+
* parameter. No `g` flag: `String.split` needs none, and a sticky `lastIndex` shared across calls
|
|
21
|
+
* is a bug waiting to happen.
|
|
22
|
+
*/
|
|
23
|
+
export const SRCSET_SEPARATOR =
|
|
24
|
+
/,(?=\s*(?:https?:\/\/|data:|\/\/|\/|\.{1,2}\/|[A-Za-z0-9_-]+\.[A-Za-z]{2,5}[/?#\s]))/;
|
|
25
|
+
|
|
26
|
+
/** The URL of each entry in a `srcset`, descriptors (`2x`, `800w`) dropped. */
|
|
27
|
+
export function parseSrcset(srcset: string): string[] {
|
|
28
|
+
return srcset
|
|
29
|
+
.split(SRCSET_SEPARATOR)
|
|
30
|
+
.map((entry) => entry.trim().split(/\s+/)[0] ?? "")
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip the source site's CSS classes out of an imported tree.
|
|
3
|
+
*
|
|
4
|
+
* The importer never resolves a class to a style. Every visual fact it emits comes from
|
|
5
|
+
* `getComputedStyle` (`style-capture.ts` → `style-diff.ts` → `apply-styles.ts`), and the only CSS
|
|
6
|
+
* it writes is `public/assets/fonts.css`, built from `@font-face` rules alone — the original
|
|
7
|
+
* stylesheets are collected for their fonts and then discarded. So an imported `class="hero
|
|
8
|
+
* grid-cols-3 lg:pt-12"` names rules that do not exist in the emitted project: it is dead weight
|
|
9
|
+
* that survives into every page, layout and component a reader then has to edit around.
|
|
10
|
+
*
|
|
11
|
+
* It is worse than inert in one place. `componentize.ts` treats every attribute as a prop
|
|
12
|
+
* candidate, so a class that happens to differ between two instances of the same block becomes a
|
|
13
|
+
* `${state.class}` prop with a name nobody chose. That is handled at the source there; this module
|
|
14
|
+
* is what guarantees nothing reaches disk regardless.
|
|
15
|
+
*
|
|
16
|
+
* **This is not a claim that Jx is classless.** `className` is a first-class schema property
|
|
17
|
+
* (`specs/spec.md` §8.1) and the compiler emits generated classes for nested style rules (§9.2);
|
|
18
|
+
* neither changes. What is removed here is one specific thing: class names imported from a site
|
|
19
|
+
* whose stylesheets were not imported with them.
|
|
20
|
+
*
|
|
21
|
+
* `id`, `role` and `aria-*` are deliberately left alone — anchors and accessibility survive the
|
|
22
|
+
* round trip, and unlike a class they mean the same thing without the original CSS.
|
|
23
|
+
*
|
|
24
|
+
* @docs studio/projects/create
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
28
|
+
|
|
29
|
+
/** The two spellings a class can arrive in — `attributes.class` from HTML, `className` from Jx. */
|
|
30
|
+
const CLASS_KEYS = ["class", "className"] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove class names from `node` and every descendant, in place.
|
|
34
|
+
*
|
|
35
|
+
* @param {JxElement | string} node - A tree node; strings (text) pass through untouched.
|
|
36
|
+
* @returns {number} How many class names were removed, for the progress line.
|
|
37
|
+
*/
|
|
38
|
+
export function stripClasses(node: JxElement | string): number {
|
|
39
|
+
if (typeof node !== "object" || node === null) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let removed = 0;
|
|
44
|
+
|
|
45
|
+
if ("className" in node && node.className !== undefined) {
|
|
46
|
+
delete node.className;
|
|
47
|
+
removed += 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { attributes } = node;
|
|
51
|
+
if (attributes && typeof attributes === "object") {
|
|
52
|
+
for (const key of CLASS_KEYS) {
|
|
53
|
+
if (key in attributes) {
|
|
54
|
+
delete (attributes as Record<string, unknown>)[key];
|
|
55
|
+
removed += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/* An `attributes` map emptied by the strip is deleted rather than left as `{}`: the emitted JSON
|
|
59
|
+
is read by a person, and a bare `"attributes": {}` on every node is the same noise the classes
|
|
60
|
+
were. `htmlToJx` never writes an empty one either, so this restores its own invariant. */
|
|
61
|
+
if (Object.keys(attributes).length === 0) {
|
|
62
|
+
delete node.attributes;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (Array.isArray(node.children)) {
|
|
67
|
+
for (const child of node.children) {
|
|
68
|
+
removed += stripClasses(child as JxElement | string);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
package/src/style-capture.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* UA-default baselines per unique tagName by rendering throwaway elements.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type { ImportPage } from "./capture.ts";
|
|
10
10
|
|
|
11
11
|
export interface CapturedStyle {
|
|
12
12
|
/** Depth-first element-index path from <body>, e.g. [0, 2, 1]. */
|
|
@@ -163,7 +163,7 @@ const STYLE_ALLOWLIST: string[] = [
|
|
|
163
163
|
* Run the style capture in a puppeteer page. Executes entirely in-browser via page.evaluate() — no
|
|
164
164
|
* round-trips per element.
|
|
165
165
|
*/
|
|
166
|
-
export async function captureStyles(page:
|
|
166
|
+
export async function captureStyles(page: ImportPage): Promise<StyleCaptureResult> {
|
|
167
167
|
return page.evaluate((allowlist: string[]) => {
|
|
168
168
|
const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
|
|
169
169
|
const tagsSeen = new Set<string>();
|
|
@@ -337,7 +337,10 @@ export async function captureStyles(page: Page): Promise<StyleCaptureResult> {
|
|
|
337
337
|
* Re-capture computed styles at a different viewport width (for $media extraction). Returns only
|
|
338
338
|
* the per-element styles — no UA defaults or media queries (reuse from base).
|
|
339
339
|
*/
|
|
340
|
-
export async function captureStylesAtWidth(
|
|
340
|
+
export async function captureStylesAtWidth(
|
|
341
|
+
page: ImportPage,
|
|
342
|
+
width: number,
|
|
343
|
+
): Promise<CapturedStyle[]> {
|
|
341
344
|
await page.setViewport({ width, height: 900 });
|
|
342
345
|
// Let layout reflow settle
|
|
343
346
|
await page.evaluate(
|
package/src/verify.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
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
|
-
import { launchBrowser, closeBrowser } from "./
|
|
13
|
+
import { launchBrowser, closeBrowser } from "./browser-local.ts";
|
|
14
14
|
import { diffScreenshots } from "./screenshot-diff.ts";
|
|
15
15
|
|
|
16
16
|
export interface PageRef {
|
|
@@ -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) {
|