@coralreefventures/site-tools 0.3.0 → 0.4.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 +59 -7
- package/dist/bin/reef-door-bundle.js +19 -1
- package/dist/bin/reef-door-bundle.js.map +1 -1
- package/dist/bin/reef-lighthouse.js +8 -4
- package/dist/bin/reef-lighthouse.js.map +1 -1
- package/dist/door/config.d.ts +5 -1
- package/dist/door/config.d.ts.map +1 -1
- package/dist/door/config.js +3 -0
- package/dist/door/config.js.map +1 -1
- package/dist/door/package.d.ts +16 -0
- package/dist/door/package.d.ts.map +1 -1
- package/dist/door/package.js +47 -2
- package/dist/door/package.js.map +1 -1
- package/dist/door/page.d.ts +70 -0
- package/dist/door/page.d.ts.map +1 -0
- package/dist/door/page.js +171 -0
- package/dist/door/page.js.map +1 -0
- package/dist/door/server.d.ts +24 -3
- package/dist/door/server.d.ts.map +1 -1
- package/dist/door/server.js +85 -13
- package/dist/door/server.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lighthouse.d.ts +16 -1
- package/dist/lighthouse.d.ts.map +1 -1
- package/dist/lighthouse.js +39 -8
- package/dist/lighthouse.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/reef-door-bundle.ts +19 -1
- package/src/bin/reef-lighthouse.ts +8 -4
- package/src/door/config.ts +6 -1
- package/src/door/package.ts +57 -2
- package/src/door/page.ts +218 -0
- package/src/door/server.ts +92 -12
- package/src/index.ts +10 -0
- package/src/lighthouse.ts +69 -11
package/src/door/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { sendFile, staticTarget } from "../serve.ts";
|
|
6
6
|
import { type GateConfig, readGateConfig } from "./config.ts";
|
|
7
7
|
import { canonicalNext, unsafe } from "./next.ts";
|
|
8
|
+
import { comingSoon } from "./page.ts";
|
|
8
9
|
import { verifyTicket } from "./verify.ts";
|
|
9
10
|
|
|
10
11
|
// The gate is the one thing in a locked site's compute bundle, and the bundle carries no node_modules, so this file
|
|
@@ -15,6 +16,13 @@ export const sessionCookie = "__Host-crv_door";
|
|
|
15
16
|
/** The nonce the gate sets before it sends a visitor to the door, which the ticket must name as `st`. */
|
|
16
17
|
export const stateCookie = "__Host-crv_door_state";
|
|
17
18
|
export const stateLifetimeSeconds = 600;
|
|
19
|
+
/**
|
|
20
|
+
* A marker that this browser has held a session here, so that once the hour is up a page load bounces silently through
|
|
21
|
+
* the door (which issues a fresh ticket while its own sign-in lives) instead of meeting the coming-soon page. It holds
|
|
22
|
+
* no identity and opens nothing: without a valid session it changes only which way a page load is sent to sign in.
|
|
23
|
+
*/
|
|
24
|
+
export const seenCookie = "__Host-crv_door_seen";
|
|
25
|
+
export const seenLifetimeSeconds = 30 * 24 * 60 * 60;
|
|
18
26
|
|
|
19
27
|
const maxBodyBytes = 16 * 1024;
|
|
20
28
|
const encodedUnsafe = /%(5c|[01][0-9a-f]|7f)/i;
|
|
@@ -32,6 +40,8 @@ const cookie = (name: string, value: string, maxAge: number, sameSite: "Lax" | "
|
|
|
32
40
|
`${name}=${value}; Max-Age=${maxAge}; Path=/; Secure; HttpOnly; SameSite=${sameSite}`;
|
|
33
41
|
const clearSession = cookie(sessionCookie, "", 0, "Lax");
|
|
34
42
|
const clearState = cookie(stateCookie, "", 0, "None");
|
|
43
|
+
const markSeen = cookie(seenCookie, "1", seenLifetimeSeconds, "Lax");
|
|
44
|
+
const clearSeen = cookie(seenCookie, "", 0, "Lax");
|
|
35
45
|
|
|
36
46
|
/** One value of a cookie, or undefined when it is absent or sent twice (which a `__Host-` cookie never is). */
|
|
37
47
|
export const readCookie = (header: string | undefined, name: string): string | undefined => {
|
|
@@ -71,6 +81,15 @@ export const splitTarget = (target: string): { rawPath: string; rawQuery: string
|
|
|
71
81
|
/** GET, HEAD and POST; the gate answers anything else with 405. */
|
|
72
82
|
const isGatedMethod = (method: string | undefined) => method === "GET" || method === "HEAD" || method === "POST";
|
|
73
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The challenge every 401 carries, as RFC 9110 requires of one. No registered scheme describes a session cookie from a
|
|
86
|
+
* sign-in page, so this is the shape of the Cookie scheme drafted for exactly that (draft-broyer-http-cookie-auth):
|
|
87
|
+
* where the sign-in starts, and the cookie it ends in. Browsers prompt only for the schemes they implement (Basic,
|
|
88
|
+
* Digest, Negotiate, NTLM), so for this one they render the body, which for a page load is the coming-soon page.
|
|
89
|
+
*/
|
|
90
|
+
export const challenge = (site: string) =>
|
|
91
|
+
`Cookie realm="${site}", form-action="/_door/signin", cookie-name="${sessionCookie}"`;
|
|
92
|
+
|
|
74
93
|
/** The paths the gate answers itself, with or without a session: robots.txt and its own /_door/ endpoints. */
|
|
75
94
|
const isOwnPath = (path: string) => path === "/robots.txt" || path.startsWith("/_door/");
|
|
76
95
|
|
|
@@ -82,6 +101,16 @@ export const isDocumentRequest = (request: IncomingMessage): boolean => {
|
|
|
82
101
|
return String(request.headers.accept ?? "").includes("text/html");
|
|
83
102
|
};
|
|
84
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A page load in the window itself: a document request whose `Sec-Fetch-Dest`, when sent, is `document`. Only this
|
|
106
|
+
* may start a sign-in, so an image, a frame or a fetch from another site cannot overwrite a visitor's state cookie.
|
|
107
|
+
*/
|
|
108
|
+
export const isTopLevelNavigation = (request: IncomingMessage): boolean => {
|
|
109
|
+
if (!isDocumentRequest(request)) return false;
|
|
110
|
+
const dest = request.headers["sec-fetch-dest"];
|
|
111
|
+
return dest === undefined || dest === "document";
|
|
112
|
+
};
|
|
113
|
+
|
|
85
114
|
/**
|
|
86
115
|
* The form body, or null once it passes 16 KB. The rest of an oversized body is read and thrown away rather than the
|
|
87
116
|
* socket destroyed, so the visitor still gets the redirect back to the door; that response closes the connection.
|
|
@@ -124,9 +153,11 @@ export type GateOptions = {
|
|
|
124
153
|
* The gate: every request to a locked site, in this order. (1) A path with a backslash, a control character or a dot
|
|
125
154
|
* segment, raw or encoded, is a 400; a method other than GET, HEAD or POST is a 405. (2) A host not on the list is a
|
|
126
155
|
* 403. (3) robots.txt disallows everything, and /_door/health answers ok. (4) POST /_door verifies a ticket from the
|
|
127
|
-
* door and sets the session. (5) /_door/signout clears
|
|
128
|
-
* session is served from the export. (7) Without one, a page load
|
|
129
|
-
*
|
|
156
|
+
* door and sets the session; /_door/signin starts a sign-in at the door. (5) /_door/signout clears the door's cookies and walks
|
|
157
|
+
* a fixed map. (6) A request with a valid session is served from the export. (7) Without one, a page load gets the
|
|
158
|
+
* coming-soon page, a 401 whose sign-in link carries the canonical next (or, from a browser that has had a session
|
|
159
|
+
* here, a silent bounce through the door), and anything else gets an empty 401. (8) An
|
|
160
|
+
* exception is a 500 with nothing in it: the gate fails closed.
|
|
130
161
|
*/
|
|
131
162
|
export const createGate = ({
|
|
132
163
|
config,
|
|
@@ -149,6 +180,8 @@ export const createGate = ({
|
|
|
149
180
|
response.end(request.method === "HEAD" ? undefined : body);
|
|
150
181
|
};
|
|
151
182
|
const doorSignout = `${config.door}/signout/`;
|
|
183
|
+
const page = comingSoon(config.page, config.door, config.site);
|
|
184
|
+
const unauthorized = challenge(config.site);
|
|
152
185
|
|
|
153
186
|
/** Where `then` leads: the next stop's gate, or the door's sign-out page. Never a URL from the query. */
|
|
154
187
|
const signoutTarget = (then: string | null): string => {
|
|
@@ -190,20 +223,60 @@ export const createGate = ({
|
|
|
190
223
|
const claimNext = canonicalNext(verdict.claims.next, host);
|
|
191
224
|
if (formNext === null || claimNext === null || formNext !== claimNext) return toDoorWithError(response);
|
|
192
225
|
const maxAge = verdict.claims.exp - now();
|
|
193
|
-
return redirect(response, 303, claimNext, [
|
|
226
|
+
return redirect(response, 303, claimNext, [
|
|
227
|
+
cookie(sessionCookie, ticket ?? "", maxAge, "Lax"),
|
|
228
|
+
clearState,
|
|
229
|
+
markSeen,
|
|
230
|
+
]);
|
|
194
231
|
};
|
|
195
232
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Step 4's sign-in start, which the coming-soon page's button links to: a fresh state cookie and a 302 to the door,
|
|
235
|
+
* naming this site, this host and where to come back to. `next` is taken from the query only after *Canonical next*;
|
|
236
|
+
* one that fails, or is missing or sent twice, becomes `/`. Only a top-level navigation starts one; anything else
|
|
237
|
+
* gets an empty 401 and no cookie.
|
|
238
|
+
*/
|
|
239
|
+
const startSignin = (request: IncomingMessage, response: ServerResponse, rawQuery: string, host: string) => {
|
|
240
|
+
if (!isTopLevelNavigation(request)) return empty(response, 401, { "WWW-Authenticate": unauthorized });
|
|
241
|
+
const asked = new URLSearchParams(rawQuery).getAll("next");
|
|
242
|
+
const next = (asked.length === 1 ? canonicalNext(asked[0], host) : null) ?? "/";
|
|
243
|
+
return toDoor(response, next, host);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** A fresh state cookie and a 302 to the door, for an already canonical `next`. */
|
|
247
|
+
const toDoor = (response: ServerResponse, next: string, host: string) => {
|
|
199
248
|
const nonce = randomBytes(32).toString("base64url");
|
|
200
|
-
const next = canonicalNext(target, host) ?? "/";
|
|
201
249
|
const query = new URLSearchParams({ site: config.site, host, next, state: nonce });
|
|
202
250
|
return redirect(response, 302, `${config.door}/?${query}`, [
|
|
203
251
|
cookie(stateCookie, nonce, stateLifetimeSeconds, "None"),
|
|
204
252
|
]);
|
|
205
253
|
};
|
|
206
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Step 7: no session. A page load gets the coming-soon page, which is the gate's own and holds none of the site;
|
|
257
|
+
* its sign-in link carries the raw request target after *Canonical next*, or `/`. Anything else is a bare 401.
|
|
258
|
+
* Both are 401, so a request without a session never gets a 2xx with anything of the site's in it, and a probe can
|
|
259
|
+
* tell locked from open by the status alone. A page load from a browser that has held a session here (the seen
|
|
260
|
+
* cookie) is sent straight to the door instead, which renews the session silently while the door's sign-in lives.
|
|
261
|
+
*/
|
|
262
|
+
const refuse = (request: IncomingMessage, response: ServerResponse, target: string, host: string) => {
|
|
263
|
+
if (!isDocumentRequest(request)) return empty(response, 401, { "WWW-Authenticate": unauthorized });
|
|
264
|
+
const next = canonicalNext(target, host) ?? "/";
|
|
265
|
+
if (readCookie(request.headers.cookie, seenCookie) === "1" && isTopLevelNavigation(request)) {
|
|
266
|
+
return toDoor(response, next, host);
|
|
267
|
+
}
|
|
268
|
+
const body = page.html(next);
|
|
269
|
+
response.writeHead(401, {
|
|
270
|
+
...securityHeaders,
|
|
271
|
+
"Cache-Control": "no-store",
|
|
272
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
273
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
274
|
+
"Content-Security-Policy": page.csp,
|
|
275
|
+
"WWW-Authenticate": unauthorized,
|
|
276
|
+
});
|
|
277
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
278
|
+
};
|
|
279
|
+
|
|
207
280
|
/** Step 6: the export, as Amplify maps it, private to the browser that holds the session. */
|
|
208
281
|
const serveSite = async (
|
|
209
282
|
request: IncomingMessage,
|
|
@@ -229,13 +302,20 @@ export const createGate = ({
|
|
|
229
302
|
return sendFile(request, response, found.file, 200, { ...securityHeaders, "Cache-Control": cacheControl });
|
|
230
303
|
};
|
|
231
304
|
|
|
232
|
-
/** Steps 3 and 5: what the gate answers itself, which holds nothing of the site. */
|
|
233
|
-
const answerOwn = (
|
|
305
|
+
/** Steps 3, 4's sign-in start and 5: what the gate answers itself, which holds nothing of the site. */
|
|
306
|
+
const answerOwn = (
|
|
307
|
+
request: IncomingMessage,
|
|
308
|
+
response: ServerResponse,
|
|
309
|
+
path: string,
|
|
310
|
+
rawQuery: string,
|
|
311
|
+
host: string,
|
|
312
|
+
) => {
|
|
234
313
|
if (path === "/robots.txt") return text(response, request, "User-agent: *\nDisallow: /\n");
|
|
235
314
|
if (path === "/_door/health") return text(response, request, "ok");
|
|
315
|
+
if (path === "/_door/signin") return startSignin(request, response, rawQuery, host);
|
|
236
316
|
if (path === "/_door/signout") {
|
|
237
317
|
const then = new URLSearchParams(rawQuery).get("then");
|
|
238
|
-
return redirect(response, 303, signoutTarget(then), [clearSession, clearState]);
|
|
318
|
+
return redirect(response, 303, signoutTarget(then), [clearSession, clearState, clearSeen]);
|
|
239
319
|
}
|
|
240
320
|
return empty(response, 404);
|
|
241
321
|
};
|
|
@@ -267,7 +347,7 @@ export const createGate = ({
|
|
|
267
347
|
|
|
268
348
|
if (path === "/_door") return acceptTicket(request, response, host);
|
|
269
349
|
if (request.method === "POST") return empty(response, 405, { Allow: "GET, HEAD" });
|
|
270
|
-
if (isOwnPath(path)) return answerOwn(request, response, path, rawQuery);
|
|
350
|
+
if (isOwnPath(path)) return answerOwn(request, response, path, rawQuery, host);
|
|
271
351
|
|
|
272
352
|
if (!hasSession(request, host)) return refuse(request, response, target, host);
|
|
273
353
|
return serveSite(request, response, path, { rawPath, rawQuery, host });
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
export { canonicalNext } from "./door/next.ts";
|
|
11
11
|
export {
|
|
12
12
|
type BundleDoorOptions,
|
|
13
|
+
type BundlePageOptions,
|
|
13
14
|
bundleDoor,
|
|
14
15
|
checkDeployment,
|
|
15
16
|
type DeployManifest,
|
|
@@ -17,10 +18,19 @@ export {
|
|
|
17
18
|
gateFiles,
|
|
18
19
|
maxBundleBytes,
|
|
19
20
|
} from "./door/package.ts";
|
|
21
|
+
export {
|
|
22
|
+
type ComingSoon,
|
|
23
|
+
comingSoon,
|
|
24
|
+
type PageConfig,
|
|
25
|
+
type PageConfigFile,
|
|
26
|
+
type PageImage,
|
|
27
|
+
parsePageConfig,
|
|
28
|
+
} from "./door/page.ts";
|
|
20
29
|
export {
|
|
21
30
|
createGate,
|
|
22
31
|
type GateOptions,
|
|
23
32
|
type StartGateOptions,
|
|
33
|
+
seenCookie,
|
|
24
34
|
sessionCookie,
|
|
25
35
|
startGate,
|
|
26
36
|
stateCookie,
|
package/src/lighthouse.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type LighthouseOptions = {
|
|
|
18
18
|
threshold?: number;
|
|
19
19
|
/** The pages to audit; every page in the export's sitemap when none are given. */
|
|
20
20
|
paths?: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* How many more times a page under the threshold is measured; 2 by default, so a page gets three runs and the
|
|
23
|
+
* median of each category's scores. Performance on a shared runner swings a few points between runs of the same
|
|
24
|
+
* page, and one low run fails a gate that three agree would pass; a page every run finds low still fails.
|
|
25
|
+
*/
|
|
26
|
+
retries?: number;
|
|
21
27
|
/** The Lighthouse release `pnpm dlx` runs. */
|
|
22
28
|
lighthouseVersion?: string;
|
|
23
29
|
/** The Chrome binary; Playwright's Chromium when it is installed and nothing is given. */
|
|
@@ -28,10 +34,35 @@ export type LighthouseOptions = {
|
|
|
28
34
|
|
|
29
35
|
export type PageResult = {
|
|
30
36
|
path: string;
|
|
31
|
-
/** Each category's score, 0 to 1. */
|
|
37
|
+
/** Each category's score, 0 to 1: the median over the page's runs. */
|
|
32
38
|
scores: Record<Category, number>;
|
|
33
39
|
/** The categories under the threshold. */
|
|
34
40
|
low: Category[];
|
|
41
|
+
/** How many times the page was measured. */
|
|
42
|
+
runs: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The median of the scores, which three runs with one low outlier put at the typical run. */
|
|
46
|
+
export const median = (values: readonly number[]): number => {
|
|
47
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
48
|
+
const middle = Math.floor(sorted.length / 2);
|
|
49
|
+
if (sorted.length === 0) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
return sorted.length % 2 === 1
|
|
53
|
+
? (sorted[middle] as number)
|
|
54
|
+
: ((sorted[middle - 1] as number) + (sorted[middle] as number)) / 2;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The categories under the threshold, by the median of each one's scores over the runs so far. */
|
|
58
|
+
export const judge = (
|
|
59
|
+
runs: readonly Record<Category, number>[],
|
|
60
|
+
threshold: number,
|
|
61
|
+
): { scores: Record<Category, number>; low: Category[] } => {
|
|
62
|
+
const scores = Object.fromEntries(
|
|
63
|
+
categories.map((category) => [category, median(runs.map((run) => run[category]))]),
|
|
64
|
+
) as Record<Category, number>;
|
|
65
|
+
return { scores, low: categories.filter((category) => scores[category] < threshold) };
|
|
35
66
|
};
|
|
36
67
|
|
|
37
68
|
type Report = { categories: Record<string, { score: number | null } | undefined> };
|
|
@@ -86,6 +117,39 @@ const audit = (url: string, base: string, version: string, chrome: string | unde
|
|
|
86
117
|
run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
|
|
87
118
|
});
|
|
88
119
|
|
|
120
|
+
type Measure = {
|
|
121
|
+
origin: string;
|
|
122
|
+
version: string;
|
|
123
|
+
chrome: string | undefined;
|
|
124
|
+
threshold: number;
|
|
125
|
+
retries: number;
|
|
126
|
+
log: (line: string) => void;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const shown = (scores: Record<Category, number>) =>
|
|
130
|
+
categories.map((category) => Math.round(scores[category] * 100)).join(" ");
|
|
131
|
+
|
|
132
|
+
/** One page's verdict: measured again while under the threshold, up to `retries` more times; the median decides. */
|
|
133
|
+
const measurePage = async (pagePath: string, base: string, measure: Measure): Promise<PageResult> => {
|
|
134
|
+
const runs: Record<Category, number>[] = [];
|
|
135
|
+
let verdict = judge(runs, measure.threshold);
|
|
136
|
+
while (runs.length === 0 || (verdict.low.length > 0 && runs.length <= measure.retries)) {
|
|
137
|
+
await audit(`${measure.origin}${pagePath}`, base, measure.version, measure.chrome);
|
|
138
|
+
const report = JSON.parse(await readFile(`${base}.report.json`, "utf8")) as Report;
|
|
139
|
+
const scores = Object.fromEntries(
|
|
140
|
+
categories.map((category) => [category, report.categories[category]?.score ?? 0]),
|
|
141
|
+
) as Record<Category, number>;
|
|
142
|
+
runs.push(scores);
|
|
143
|
+
verdict = judge(runs, measure.threshold);
|
|
144
|
+
if (verdict.low.length > 0 && runs.length <= measure.retries) {
|
|
145
|
+
measure.log(`again ${pagePath.padEnd(28)} ${shown(scores)} (run ${runs.length} of ${measure.retries + 1})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const over = runs.length > 1 ? ` (median of ${runs.length} runs)` : "";
|
|
149
|
+
measure.log(`${verdict.low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown(verdict.scores)}${over}`);
|
|
150
|
+
return { path: pagePath, scores: verdict.scores, low: verdict.low, runs: runs.length };
|
|
151
|
+
};
|
|
152
|
+
|
|
89
153
|
/**
|
|
90
154
|
* The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
|
|
91
155
|
* mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
|
|
@@ -95,6 +159,7 @@ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<Pa
|
|
|
95
159
|
const root = options.root ?? "out";
|
|
96
160
|
const reportDir = options.reportDir ?? "lighthouse";
|
|
97
161
|
const threshold = options.threshold ?? 0.9;
|
|
162
|
+
const retries = Math.max(0, options.retries ?? 2);
|
|
98
163
|
const version = options.lighthouseVersion ?? "13.5.0";
|
|
99
164
|
const log = options.log ?? console.log;
|
|
100
165
|
const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
|
|
@@ -105,16 +170,9 @@ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<Pa
|
|
|
105
170
|
await mkdir(reportDir, { recursive: true });
|
|
106
171
|
for (const pagePath of paths) {
|
|
107
172
|
const base = reportBase(reportDir, pagePath);
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
categories.map((category) => [category, report.categories[category]?.score ?? 0]),
|
|
112
|
-
) as Record<Category, number>;
|
|
113
|
-
const low = categories.filter((category) => scores[category] < threshold);
|
|
114
|
-
results.push({ path: pagePath, scores, low });
|
|
115
|
-
const shown = categories.map((category) => Math.round(scores[category] * 100)).join(" ");
|
|
116
|
-
log(`${low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown}`);
|
|
117
|
-
if (low.length === 0) {
|
|
173
|
+
const result = await measurePage(pagePath, base, { origin, version, chrome, threshold, retries, log });
|
|
174
|
+
results.push(result);
|
|
175
|
+
if (result.low.length === 0) {
|
|
118
176
|
await rm(`${base}.report.json`);
|
|
119
177
|
await rm(`${base}.report.html`);
|
|
120
178
|
}
|