@coralreefventures/site-tools 0.4.0 → 0.4.2

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 CHANGED
@@ -6,7 +6,7 @@ products' scripts go. The third locks a site behind the door.
6
6
 
7
7
  ```sh
8
8
  reef-serve [--root out] [--port 3140]
9
- reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]
9
+ reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--retries 2] [--chrome <path>] [path ...]
10
10
  reef-door-bundle --site <id> [--out out] --door-url <url> --hosts <h1,h2> --jwks-url <url> [--signout-chain id=host,...]
11
11
  [--name <display name>] [--mark <file>] [--mark-dark <file>] [--accent #rrggbb] [--accent-dark #rrggbb]
12
12
  ```
@@ -22,7 +22,12 @@ webServer: { command: `pnpm exec reef-serve --port ${port}`, port },
22
22
 
23
23
  `reef-lighthouse` is the merge gate: Lighthouse on every page in the export's sitemap (or the paths given), with its
24
24
  default mobile emulation, each of performance, accessibility, best practices and SEO at the threshold or above. A
25
- page under it fails the run and leaves its report in `--reports`. Lighthouse itself is not a dependency, because it
25
+ page under it is measured again, up to `--retries` more times (2 by default), and the median of its runs decides:
26
+ a performance score swings a few points between runs of the same page on a shared runner, and one low run should
27
+ not fail a gate three runs would pass, while a page every run finds low still fails. A page under it then fails the
28
+ run and leaves its last report in `--reports`. A category's own floor (`--performance 85`) stands in for the
29
+ threshold for that category alone, where a site's shell puts every page a few points under 90 on a shared runner
30
+ while the other three categories hold at 90. Lighthouse itself is not a dependency, because it
26
31
  bundles axe-core (MPL-2.0), which is outside the family's licence allowlist; it runs through `pnpm dlx`, pinned
27
32
  (`--lighthouse-version`). Chrome is Playwright's Chromium when `@playwright/test` is installed beside this package,
28
33
  or `--chrome`.
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { runLighthouse } from "../lighthouse.js";
3
+ import { categories, runLighthouse } from "../lighthouse.js";
4
4
  /**
5
- * reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]
5
+ * reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--performance N]
6
+ * [--accessibility N] [--best-practices N] [--seo N] [--chrome <path>] [path ...]
6
7
  *
7
8
  * Lighthouse on every page in the export's sitemap, or the paths given, each category at the threshold or above. The
8
- * export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160).
9
- * Exits 1 with the failing pages named when one is under, and leaves their reports in --reports.
9
+ * export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160). A page
10
+ * under the threshold is measured again, up to --retries more times, and the median of its runs decides, since a
11
+ * performance score swings a few points between runs on a shared runner. Exits 1 with the failing pages named when
12
+ * one is under, and leaves their reports in --reports. A category's own floor (--performance 85) stands in for the
13
+ * threshold for that category alone.
10
14
  */
11
15
  const { values, positionals } = parseArgs({
12
16
  allowPositionals: true,
@@ -15,28 +19,36 @@ const { values, positionals } = parseArgs({
15
19
  reports: { type: "string", default: "lighthouse" },
16
20
  port: { type: "string" },
17
21
  threshold: { type: "string", default: "90" },
22
+ retries: { type: "string", default: "2" },
23
+ performance: { type: "string" },
24
+ accessibility: { type: "string" },
25
+ "best-practices": { type: "string" },
26
+ seo: { type: "string" },
18
27
  chrome: { type: "string" },
19
28
  "lighthouse-version": { type: "string", default: "13.5.0" },
20
29
  help: { type: "boolean", short: "h" },
21
30
  },
22
31
  });
23
32
  if (values.help) {
24
- console.log("reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]");
33
+ console.log("reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--performance N] [--accessibility N] [--best-practices N] [--seo N] [--chrome <path>] [path ...]");
25
34
  process.exit(0);
26
35
  }
27
36
  const threshold = Number(values.threshold) / 100;
37
+ const thresholds = Object.fromEntries(categories.flatMap((category) => values[category] === undefined ? [] : [[category, Number(values[category]) / 100]]));
28
38
  const results = await runLighthouse({
29
39
  root: values.root,
30
40
  reportDir: values.reports,
31
41
  ...(values.port !== undefined && { port: Number(values.port) }),
32
42
  threshold,
43
+ retries: Number(values.retries),
44
+ thresholds,
33
45
  paths: positionals,
34
46
  lighthouseVersion: values["lighthouse-version"],
35
47
  ...(values.chrome !== undefined && { chrome: values.chrome }),
36
48
  });
37
49
  const failures = results.filter((result) => result.low.length > 0);
38
50
  if (failures.length) {
39
- console.error(`Under ${threshold * 100}:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`);
51
+ console.error(`Under the threshold:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`);
40
52
  process.exit(1);
41
53
  }
42
54
  //# sourceMappingURL=reef-lighthouse.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"reef-lighthouse.js","sourceRoot":"","sources":["../../src/bin/reef-lighthouse.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD;;;;;;GAMG;AACH,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC;IACxC,gBAAgB,EAAE,IAAI;IACtB,OAAO,EAAE;QACP,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QACxC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE;QAClD,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACxB,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE;QAC5C,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC1B,oBAAoB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;QAC3D,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,EAAE;KACtC;CACF,CAAC,CAAC;AAEH,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,CAAC,GAAG,CACT,8GAA8G,CAC/G,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,GAAG,CAAC;AACjD,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,IAAI;IACjB,SAAS,EAAE,MAAM,CAAC,OAAO;IACzB,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;IAC/D,SAAS;IACT,KAAK,EAAE,WAAW;IAClB,iBAAiB,EAAE,MAAM,CAAC,oBAAoB,CAAC;IAC/C,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;CAC9D,CAAC,CAAC;AAEH,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACnE,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CACX,SAAS,SAAS,GAAG,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,OAAO,GAAG,CAC/I,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC"}
1
+ {"version":3,"file":"reef-lighthouse.js","sourceRoot":"","sources":["../../src/bin/reef-lighthouse.ts"],"names":[],"mappings":"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"}
@@ -9,8 +9,19 @@ export type LighthouseOptions = {
9
9
  port?: number;
10
10
  /** The least score, 0 to 1, every category must reach on every page; 0.9 by default. */
11
11
  threshold?: number;
12
+ /**
13
+ * A category's own floor, 0 to 1, in place of the threshold: a site holds performance at 0.85, say, where the
14
+ * shell's scripts and stylesheets on a shared runner put every page a few points under 90, and the rest at 0.9.
15
+ */
16
+ thresholds?: Partial<Record<Category, number>>;
12
17
  /** The pages to audit; every page in the export's sitemap when none are given. */
13
18
  paths?: readonly string[];
19
+ /**
20
+ * How many more times a page under the threshold is measured; 2 by default, so a page gets three runs and the
21
+ * median of each category's scores. Performance on a shared runner swings a few points between runs of the same
22
+ * page, and one low run fails a gate that three agree would pass; a page every run finds low still fails.
23
+ */
24
+ retries?: number;
14
25
  /** The Lighthouse release `pnpm dlx` runs. */
15
26
  lighthouseVersion?: string;
16
27
  /** The Chrome binary; Playwright's Chromium when it is installed and nothing is given. */
@@ -20,10 +31,19 @@ export type LighthouseOptions = {
20
31
  };
21
32
  export type PageResult = {
22
33
  path: string;
23
- /** Each category's score, 0 to 1. */
34
+ /** Each category's score, 0 to 1: the median over the page's runs. */
24
35
  scores: Record<Category, number>;
25
36
  /** The categories under the threshold. */
26
37
  low: Category[];
38
+ /** How many times the page was measured. */
39
+ runs: number;
40
+ };
41
+ /** The median of the scores, which three runs with one low outlier put at the typical run. */
42
+ export declare const median: (values: readonly number[]) => number;
43
+ /** The categories under the threshold, by the median of each one's scores over the runs so far. */
44
+ export declare const judge: (runs: readonly Record<Category, number>[], threshold: number, thresholds?: Partial<Record<Category, number>>) => {
45
+ scores: Record<Category, number>;
46
+ low: Category[];
27
47
  };
28
48
  /** Every page's path from the export's sitemap, whatever origin the sitemap names. */
29
49
  export declare const pagesFromSitemap: (root: string) => Promise<string[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"lighthouse.d.ts","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,UAAU,oEAAqE,CAAC;AAC7F,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kHAAkH;IAClH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kFAAkF;IAClF,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,GAAG,EAAE,QAAQ,EAAE,CAAC;CACjB,CAAC;AAIF,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAGrE,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,UAAU,GAAI,WAAW,MAAM,EAAE,UAAU,MAAM,WACyB,CAAC;AA0CxF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAU,UAAS,iBAAsB,KAAG,OAAO,CAAC,UAAU,EAAE,CAiCzF,CAAC"}
1
+ {"version":3,"file":"lighthouse.d.ts","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,UAAU,oEAAqE,CAAC;AAC7F,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kHAAkH;IAClH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IAC/C,kFAAkF;IAClF,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,GAAG,EAAE,QAAQ,EAAE,CAAC;IAChB,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,MAAM,GAAI,QAAQ,SAAS,MAAM,EAAE,KAAG,MASlD,CAAC;AAEF,mGAAmG;AACnG,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,EACzC,WAAW,MAAM,EACjB,aAAY,OAAO,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAM,KACjD;IAAE,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAAC,GAAG,EAAE,QAAQ,EAAE,CAAA;CAKrD,CAAC;AAIF,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAGrE,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,UAAU,GAAI,WAAW,MAAM,EAAE,UAAU,MAAM,WACyB,CAAC;AA4ExF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAU,UAAS,iBAAsB,KAAG,OAAO,CAAC,UAAU,EAAE,CAwCzF,CAAC"}
@@ -11,6 +11,22 @@ import { mkdir, readFile, rm } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { serve } from "./serve.js";
13
13
  export const categories = ["performance", "accessibility", "best-practices", "seo"];
14
+ /** The median of the scores, which three runs with one low outlier put at the typical run. */
15
+ export const median = (values) => {
16
+ const sorted = [...values].sort((left, right) => left - right);
17
+ const middle = Math.floor(sorted.length / 2);
18
+ if (sorted.length === 0) {
19
+ return 0;
20
+ }
21
+ return sorted.length % 2 === 1
22
+ ? sorted[middle]
23
+ : (sorted[middle - 1] + sorted[middle]) / 2;
24
+ };
25
+ /** The categories under the threshold, by the median of each one's scores over the runs so far. */
26
+ export const judge = (runs, threshold, thresholds = {}) => {
27
+ const scores = Object.fromEntries(categories.map((category) => [category, median(runs.map((run) => run[category]))]));
28
+ return { scores, low: categories.filter((category) => scores[category] < (thresholds[category] ?? threshold)) };
29
+ };
14
30
  /** Every page's path from the export's sitemap, whatever origin the sitemap names. */
15
31
  export const pagesFromSitemap = async (root) => {
16
32
  const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8");
@@ -52,6 +68,25 @@ const audit = (url, base, version, chrome) => new Promise((resolve, reject) => {
52
68
  run.on("error", reject);
53
69
  run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
54
70
  });
71
+ const shown = (scores) => categories.map((category) => Math.round(scores[category] * 100)).join(" ");
72
+ /** One page's verdict: measured again while under the threshold, up to `retries` more times; the median decides. */
73
+ const measurePage = async (pagePath, base, measure) => {
74
+ const runs = [];
75
+ let verdict = judge(runs, measure.threshold, measure.thresholds);
76
+ while (runs.length === 0 || (verdict.low.length > 0 && runs.length <= measure.retries)) {
77
+ await audit(`${measure.origin}${pagePath}`, base, measure.version, measure.chrome);
78
+ const report = JSON.parse(await readFile(`${base}.report.json`, "utf8"));
79
+ const scores = Object.fromEntries(categories.map((category) => [category, report.categories[category]?.score ?? 0]));
80
+ runs.push(scores);
81
+ verdict = judge(runs, measure.threshold, measure.thresholds);
82
+ if (verdict.low.length > 0 && runs.length <= measure.retries) {
83
+ measure.log(`again ${pagePath.padEnd(28)} ${shown(scores)} (run ${runs.length} of ${measure.retries + 1})`);
84
+ }
85
+ }
86
+ const over = runs.length > 1 ? ` (median of ${runs.length} runs)` : "";
87
+ measure.log(`${verdict.low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown(verdict.scores)}${over}`);
88
+ return { path: pagePath, scores: verdict.scores, low: verdict.low, runs: runs.length };
89
+ };
55
90
  /**
56
91
  * The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
57
92
  * mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
@@ -61,6 +96,8 @@ export const runLighthouse = async (options = {}) => {
61
96
  const root = options.root ?? "out";
62
97
  const reportDir = options.reportDir ?? "lighthouse";
63
98
  const threshold = options.threshold ?? 0.9;
99
+ const retries = Math.max(0, options.retries ?? 2);
100
+ const thresholds = options.thresholds ?? {};
64
101
  const version = options.lighthouseVersion ?? "13.5.0";
65
102
  const log = options.log ?? console.log;
66
103
  const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
@@ -71,14 +108,17 @@ export const runLighthouse = async (options = {}) => {
71
108
  await mkdir(reportDir, { recursive: true });
72
109
  for (const pagePath of paths) {
73
110
  const base = reportBase(reportDir, pagePath);
74
- await audit(`${origin}${pagePath}`, base, version, chrome);
75
- const report = JSON.parse(await readFile(`${base}.report.json`, "utf8"));
76
- const scores = Object.fromEntries(categories.map((category) => [category, report.categories[category]?.score ?? 0]));
77
- const low = categories.filter((category) => scores[category] < threshold);
78
- results.push({ path: pagePath, scores, low });
79
- const shown = categories.map((category) => Math.round(scores[category] * 100)).join(" ");
80
- log(`${low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown}`);
81
- if (low.length === 0) {
111
+ const result = await measurePage(pagePath, base, {
112
+ origin,
113
+ version,
114
+ chrome,
115
+ threshold,
116
+ thresholds,
117
+ retries,
118
+ log,
119
+ });
120
+ results.push(result);
121
+ if (result.low.length === 0) {
82
122
  await rm(`${base}.report.json`);
83
123
  await rm(`${base}.report.html`);
84
124
  }
@@ -87,7 +127,11 @@ export const runLighthouse = async (options = {}) => {
87
127
  finally {
88
128
  server.close();
89
129
  }
90
- log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}`);
130
+ const floors = categories
131
+ .filter((category) => thresholds[category] !== undefined)
132
+ .map((category) => `, ${category} ${(thresholds[category] ?? 0) * 100}`)
133
+ .join("");
134
+ log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}${floors}`);
91
135
  return results;
92
136
  };
93
137
  //# sourceMappingURL=lighthouse.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"lighthouse.js","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"lighthouse.js","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coralreefventures/site-tools",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Command-line tools for a Coral Reef public site: serve the static export as the host does, gate it with Lighthouse, and lock it behind the door.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
 
4
- import { runLighthouse } from "../lighthouse.ts";
4
+ import { categories, runLighthouse } from "../lighthouse.ts";
5
5
 
6
6
  /**
7
- * reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]
7
+ * reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--performance N]
8
+ * [--accessibility N] [--best-practices N] [--seo N] [--chrome <path>] [path ...]
8
9
  *
9
10
  * Lighthouse on every page in the export's sitemap, or the paths given, each category at the threshold or above. The
10
- * export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160).
11
- * Exits 1 with the failing pages named when one is under, and leaves their reports in --reports.
11
+ * export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160). A page
12
+ * under the threshold is measured again, up to --retries more times, and the median of its runs decides, since a
13
+ * performance score swings a few points between runs on a shared runner. Exits 1 with the failing pages named when
14
+ * one is under, and leaves their reports in --reports. A category's own floor (--performance 85) stands in for the
15
+ * threshold for that category alone.
12
16
  */
13
17
  const { values, positionals } = parseArgs({
14
18
  allowPositionals: true,
@@ -17,6 +21,11 @@ const { values, positionals } = parseArgs({
17
21
  reports: { type: "string", default: "lighthouse" },
18
22
  port: { type: "string" },
19
23
  threshold: { type: "string", default: "90" },
24
+ retries: { type: "string", default: "2" },
25
+ performance: { type: "string" },
26
+ accessibility: { type: "string" },
27
+ "best-practices": { type: "string" },
28
+ seo: { type: "string" },
20
29
  chrome: { type: "string" },
21
30
  "lighthouse-version": { type: "string", default: "13.5.0" },
22
31
  help: { type: "boolean", short: "h" },
@@ -25,17 +34,24 @@ const { values, positionals } = parseArgs({
25
34
 
26
35
  if (values.help) {
27
36
  console.log(
28
- "reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]",
37
+ "reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--performance N] [--accessibility N] [--best-practices N] [--seo N] [--chrome <path>] [path ...]",
29
38
  );
30
39
  process.exit(0);
31
40
  }
32
41
 
33
42
  const threshold = Number(values.threshold) / 100;
43
+ const thresholds = Object.fromEntries(
44
+ categories.flatMap((category) =>
45
+ values[category] === undefined ? [] : [[category, Number(values[category]) / 100]],
46
+ ),
47
+ );
34
48
  const results = await runLighthouse({
35
49
  root: values.root,
36
50
  reportDir: values.reports,
37
51
  ...(values.port !== undefined && { port: Number(values.port) }),
38
52
  threshold,
53
+ retries: Number(values.retries),
54
+ thresholds,
39
55
  paths: positionals,
40
56
  lighthouseVersion: values["lighthouse-version"],
41
57
  ...(values.chrome !== undefined && { chrome: values.chrome }),
@@ -44,7 +60,7 @@ const results = await runLighthouse({
44
60
  const failures = results.filter((result) => result.low.length > 0);
45
61
  if (failures.length) {
46
62
  console.error(
47
- `Under ${threshold * 100}:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`,
63
+ `Under the threshold:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`,
48
64
  );
49
65
  process.exit(1);
50
66
  }
package/src/lighthouse.ts CHANGED
@@ -16,8 +16,19 @@ export type LighthouseOptions = {
16
16
  port?: number;
17
17
  /** The least score, 0 to 1, every category must reach on every page; 0.9 by default. */
18
18
  threshold?: number;
19
+ /**
20
+ * A category's own floor, 0 to 1, in place of the threshold: a site holds performance at 0.85, say, where the
21
+ * shell's scripts and stylesheets on a shared runner put every page a few points under 90, and the rest at 0.9.
22
+ */
23
+ thresholds?: Partial<Record<Category, number>>;
19
24
  /** The pages to audit; every page in the export's sitemap when none are given. */
20
25
  paths?: readonly string[];
26
+ /**
27
+ * How many more times a page under the threshold is measured; 2 by default, so a page gets three runs and the
28
+ * median of each category's scores. Performance on a shared runner swings a few points between runs of the same
29
+ * page, and one low run fails a gate that three agree would pass; a page every run finds low still fails.
30
+ */
31
+ retries?: number;
21
32
  /** The Lighthouse release `pnpm dlx` runs. */
22
33
  lighthouseVersion?: string;
23
34
  /** The Chrome binary; Playwright's Chromium when it is installed and nothing is given. */
@@ -28,10 +39,36 @@ export type LighthouseOptions = {
28
39
 
29
40
  export type PageResult = {
30
41
  path: string;
31
- /** Each category's score, 0 to 1. */
42
+ /** Each category's score, 0 to 1: the median over the page's runs. */
32
43
  scores: Record<Category, number>;
33
44
  /** The categories under the threshold. */
34
45
  low: Category[];
46
+ /** How many times the page was measured. */
47
+ runs: number;
48
+ };
49
+
50
+ /** The median of the scores, which three runs with one low outlier put at the typical run. */
51
+ export const median = (values: readonly number[]): number => {
52
+ const sorted = [...values].sort((left, right) => left - right);
53
+ const middle = Math.floor(sorted.length / 2);
54
+ if (sorted.length === 0) {
55
+ return 0;
56
+ }
57
+ return sorted.length % 2 === 1
58
+ ? (sorted[middle] as number)
59
+ : ((sorted[middle - 1] as number) + (sorted[middle] as number)) / 2;
60
+ };
61
+
62
+ /** The categories under the threshold, by the median of each one's scores over the runs so far. */
63
+ export const judge = (
64
+ runs: readonly Record<Category, number>[],
65
+ threshold: number,
66
+ thresholds: Partial<Record<Category, number>> = {},
67
+ ): { scores: Record<Category, number>; low: Category[] } => {
68
+ const scores = Object.fromEntries(
69
+ categories.map((category) => [category, median(runs.map((run) => run[category]))]),
70
+ ) as Record<Category, number>;
71
+ return { scores, low: categories.filter((category) => scores[category] < (thresholds[category] ?? threshold)) };
35
72
  };
36
73
 
37
74
  type Report = { categories: Record<string, { score: number | null } | undefined> };
@@ -86,6 +123,40 @@ const audit = (url: string, base: string, version: string, chrome: string | unde
86
123
  run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
87
124
  });
88
125
 
126
+ type Measure = {
127
+ origin: string;
128
+ version: string;
129
+ chrome: string | undefined;
130
+ threshold: number;
131
+ thresholds: Partial<Record<Category, number>>;
132
+ retries: number;
133
+ log: (line: string) => void;
134
+ };
135
+
136
+ const shown = (scores: Record<Category, number>) =>
137
+ categories.map((category) => Math.round(scores[category] * 100)).join(" ");
138
+
139
+ /** One page's verdict: measured again while under the threshold, up to `retries` more times; the median decides. */
140
+ const measurePage = async (pagePath: string, base: string, measure: Measure): Promise<PageResult> => {
141
+ const runs: Record<Category, number>[] = [];
142
+ let verdict = judge(runs, measure.threshold, measure.thresholds);
143
+ while (runs.length === 0 || (verdict.low.length > 0 && runs.length <= measure.retries)) {
144
+ await audit(`${measure.origin}${pagePath}`, base, measure.version, measure.chrome);
145
+ const report = JSON.parse(await readFile(`${base}.report.json`, "utf8")) as Report;
146
+ const scores = Object.fromEntries(
147
+ categories.map((category) => [category, report.categories[category]?.score ?? 0]),
148
+ ) as Record<Category, number>;
149
+ runs.push(scores);
150
+ verdict = judge(runs, measure.threshold, measure.thresholds);
151
+ if (verdict.low.length > 0 && runs.length <= measure.retries) {
152
+ measure.log(`again ${pagePath.padEnd(28)} ${shown(scores)} (run ${runs.length} of ${measure.retries + 1})`);
153
+ }
154
+ }
155
+ const over = runs.length > 1 ? ` (median of ${runs.length} runs)` : "";
156
+ measure.log(`${verdict.low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown(verdict.scores)}${over}`);
157
+ return { path: pagePath, scores: verdict.scores, low: verdict.low, runs: runs.length };
158
+ };
159
+
89
160
  /**
90
161
  * The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
91
162
  * mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
@@ -95,6 +166,8 @@ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<Pa
95
166
  const root = options.root ?? "out";
96
167
  const reportDir = options.reportDir ?? "lighthouse";
97
168
  const threshold = options.threshold ?? 0.9;
169
+ const retries = Math.max(0, options.retries ?? 2);
170
+ const thresholds = options.thresholds ?? {};
98
171
  const version = options.lighthouseVersion ?? "13.5.0";
99
172
  const log = options.log ?? console.log;
100
173
  const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
@@ -105,16 +178,17 @@ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<Pa
105
178
  await mkdir(reportDir, { recursive: true });
106
179
  for (const pagePath of paths) {
107
180
  const base = reportBase(reportDir, pagePath);
108
- await audit(`${origin}${pagePath}`, base, version, chrome);
109
- const report = JSON.parse(await readFile(`${base}.report.json`, "utf8")) as Report;
110
- const scores = Object.fromEntries(
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) {
181
+ const result = await measurePage(pagePath, base, {
182
+ origin,
183
+ version,
184
+ chrome,
185
+ threshold,
186
+ thresholds,
187
+ retries,
188
+ log,
189
+ });
190
+ results.push(result);
191
+ if (result.low.length === 0) {
118
192
  await rm(`${base}.report.json`);
119
193
  await rm(`${base}.report.html`);
120
194
  }
@@ -122,6 +196,10 @@ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<Pa
122
196
  } finally {
123
197
  server.close();
124
198
  }
125
- log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}`);
199
+ const floors = categories
200
+ .filter((category) => thresholds[category] !== undefined)
201
+ .map((category) => `, ${category} ${(thresholds[category] ?? 0) * 100}`)
202
+ .join("");
203
+ log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}${floors}`);
126
204
  return results;
127
205
  };