@bejamas/bench 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @bejamas/bench
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Compare an original site with its ported version. Built for checking framework
4
+ migrations, such as a React + shadcn/ui site ported to Astro + b/ui, but it
5
+ works with any two URLs.
6
+
7
+ ```bash
8
+ npx @bejamas/bench --original http://localhost:3000 --ported http://localhost:4321
9
+ # or through the Bejamas CLI
10
+ npx bejamas bench --original http://localhost:3000 --ported http://localhost:4321
11
+ ```
12
+
13
+ The URLs can also be passed positionally (`<original> <ported>`), but swapping
14
+ them silently inverts every comparison, so the named flags are preferred and the
15
+ positional form prints which URL it read as which.
16
+
17
+ Requires Node.js 22.19+ and Google Chrome. Set `CHROME_PATH` or pass
18
+ `--chrome-path` if Chrome is installed in a nonstandard location.
19
+
20
+ ## What it measures
21
+
22
+ | Stage | What it compares |
23
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `lighthouse` | Median performance score, FCP, LCP, TBT, CLS and Speed Index. Runs alternate between the two URLs (original first, then ported first) so drift affects both equally. |
25
+ | `assets` | Every response of a cold load at 412×823: JavaScript, CSS, HTML, fonts, images and other requests. HTML, CSS and JavaScript are recompressed with gzip level 9, so a local server without compression and a CDN compare equally. |
26
+ | `quality` | Visible text, title, description and `lang`, heading outline, axe-core violations, console and page errors, failed requests, DOM size and nested interactive controls. |
27
+ | `visual` | Visible `data-slot` components matched by slot name, text and order, then compared by size. Top-level header, main, section and footer regions are compared by position and size. Full-page screenshots are pixel-diffed. |
28
+
29
+ Accessibility violations, browser errors and failed requests only fail the run
30
+ when they are new in the ported page. Problems that the original already has are
31
+ reported as warnings.
32
+
33
+ Each broken resource is reported once, with its HTTP status, a CORS block or
34
+ Chrome's network error. Loads the browser cancels, such as link prefetches, are
35
+ not failures. Generic `data-slot` part names (`root`, `outer`, `inner`) are left
36
+ out of component matching because they don't identify a component.
37
+
38
+ ## Production builds only
39
+
40
+ Development servers ship unminified code and HMR clients. When a Vite, Astro,
41
+ Next.js or webpack dev server is detected, the `assets` and `lighthouse` stages
42
+ are skipped. Pass `--allow-dev` to measure anyway.
43
+
44
+ Lighthouse timings are reported as not comparable when one URL is local and the
45
+ other is remote, and timing budgets are skipped. Asset budgets still apply
46
+ because their compression is recomputed locally.
47
+
48
+ ## Budgets
49
+
50
+ `--fail-on` takes comma-separated budgets, and can be repeated. Size and timing
51
+ budgets compare the change from original to ported:
52
+
53
+ | Budget | Fails when |
54
+ | ------------ | ---------------------------------------------------------------- |
55
+ | `lcp>10%` | LCP grows by more than 10% |
56
+ | `tbt>50ms` | TBT grows by more than 50 ms |
57
+ | `score<-5` | The performance score drops by more than 5 points |
58
+ | `js>0` | Route JavaScript (gzip) grows at all |
59
+ | `total>20kb` | HTML + CSS + JS (gzip) + fonts grow by more than 20 KiB |
60
+ | `requests>0` | The page makes more requests |
61
+ | `pixels>1%` | More than 1% of screenshot pixels differ at any width (absolute) |
62
+
63
+ Metrics: `score`, `fcp`, `lcp`, `tbt`, `cls`, `si`, `js`, `css`, `html`,
64
+ `fonts`, `total`, `requests`, `pixels`.
65
+
66
+ ## Output
67
+
68
+ The summary is printed to the terminal: checks, budgets, Lighthouse, route
69
+ assets, visual parity and page quality. Nothing is written to disk unless you
70
+ pass `--out <dir>`, which adds:
71
+
72
+ - `report.md`: the readable report
73
+ - `report.json`: every measurement, including per-run Lighthouse values and per-request assets
74
+ - `screenshots/`: `original-<width>.png`, `ported-<width>.png` and `diff-<width>.png`
75
+ - `lighthouse/`: the full Lighthouse JSON for every run
76
+
77
+ Exit codes: `0` when all checks and budgets pass, `1` when any fail, `2` when
78
+ the run cannot complete. Use `--report-only` to always exit with `0` for
79
+ checks and budgets. Use `--json` to print the report to stdout for scripts and
80
+ agents.
81
+
82
+ ## Options
83
+
84
+ ```
85
+ --original <url> URL of the original site
86
+ --ported <url> URL of the ported site
87
+ -o, --out <dir> also write report files to this directory
88
+ -r, --runs <count> Lighthouse runs per URL (default: 5)
89
+ -w, --widths <list> viewport widths for visual parity (default: 412,1280)
90
+ --form-factor <type> mobile or desktop Lighthouse emulation (default: mobile)
91
+ --only <stages> run only these stages
92
+ --skip <stages> skip these stages
93
+ --tolerance <px> allowed size difference (default: 0.5)
94
+ --fail-on <budgets> budgets, see above
95
+ --report-only exit with 0 even when checks or budgets fail
96
+ --allow-dev measure assets and Lighthouse on dev servers
97
+ --chrome-path <path> Chrome executable
98
+ --timeout <ms> navigation timeout (default: 60000)
99
+ --json print the JSON report to stdout
100
+ ```
101
+
102
+ ## Programmatic use
103
+
104
+ ```ts
105
+ import { runBench } from "@bejamas/bench";
106
+
107
+ const { report } = await runBench({
108
+ original: "http://localhost:3000",
109
+ ported: "http://localhost:4321",
110
+ outDir: "bench-report", // optional; omit to skip writing files
111
+ runs: 5,
112
+ widths: [412, 1280],
113
+ formFactor: "mobile",
114
+ stages: ["assets", "quality", "visual", "lighthouse"],
115
+ tolerance: 0.5,
116
+ budgets: ["lcp>10%"],
117
+ allowDev: false,
118
+ timeout: 60_000,
119
+ });
120
+ ```
121
+
122
+ ## Limitations
123
+
124
+ - Lab measurements of one route, not field Core Web Vitals.
125
+ - Assets are those loaded without interaction. Code loaded only after a click is not counted.
126
+ - Interaction latency is not measured yet.
127
+ - Pages with randomized or time-based content will report text and pixel differences.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { c as BUDGET_HELP, d as BenchError, f as version, n as runBench, s as STAGES, t as renderSummary } from "./summary-DD9dfSgQ.js";
3
+ import { relative } from "node:path";
4
+ import kleur from "kleur";
5
+ import { Command, InvalidArgumentError, Option } from "commander";
6
+
7
+ //#region src/args.ts
8
+ function positiveInteger(value) {
9
+ const parsed = Number(value);
10
+ if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("Expected a positive integer.");
11
+ return parsed;
12
+ }
13
+ function nonNegativeNumber(value) {
14
+ const parsed = Number(value);
15
+ if (!Number.isFinite(parsed) || parsed < 0) throw new InvalidArgumentError("Expected a non-negative number.");
16
+ return parsed;
17
+ }
18
+ function widthList(value) {
19
+ const widths = value.split(",").map((part) => Number(part.trim()));
20
+ if (widths.length === 0 || widths.some((width) => !Number.isInteger(width) || width < 200 || width > 3840)) throw new InvalidArgumentError("Expected comma-separated widths between 200 and 3840.");
21
+ return [...new Set(widths)];
22
+ }
23
+ const isStageName = (value) => STAGES.includes(value);
24
+ function stageList(value) {
25
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
26
+ const stages = parts.filter(isStageName);
27
+ if (stages.length === 0 || stages.length !== parts.length) throw new InvalidArgumentError(`Expected a comma-separated list of: ${STAGES.join(", ")}.`);
28
+ return stages;
29
+ }
30
+ function collect(value, previous) {
31
+ return [...previous, value];
32
+ }
33
+ /** Stages to run, in their fixed order, after --only and --skip. */
34
+ function selectStages(only = STAGES, skip = []) {
35
+ const stages = only.filter((stage) => !skip.includes(stage));
36
+ if (stages.length === 0) throw new BenchError("No stages left to run.");
37
+ return stages;
38
+ }
39
+ /**
40
+ * Resolve which URL is which. Named flags are preferred because positional
41
+ * URLs are easy to swap, which silently inverts every comparison.
42
+ */
43
+ function resolveTargetUrls(flags, positional) {
44
+ const named = flags.original !== void 0 || flags.ported !== void 0;
45
+ if (named && positional.length > 0) throw new BenchError(`Pass the URLs either as --original and --ported or positionally, not both (got ${positional.join(" ")}).`);
46
+ if (named) {
47
+ const { original, ported } = flags;
48
+ if (original === void 0 || ported === void 0) {
49
+ const missing = [original === void 0 && "--original <url>", ported === void 0 && "--ported <url>"].filter(Boolean);
50
+ throw new BenchError(`Missing ${missing.join(" and ")}.`);
51
+ }
52
+ return {
53
+ original,
54
+ ported
55
+ };
56
+ }
57
+ if (positional.length !== 2) throw new BenchError("Pass both URLs: --original <url> --ported <url>.");
58
+ const [original, ported] = positional;
59
+ return {
60
+ original,
61
+ ported,
62
+ warning: `Reading ${original} as the original and ${ported} as the port. Use --original and --ported to avoid swapping them.`
63
+ };
64
+ }
65
+
66
+ //#endregion
67
+ //#region src/cli.ts
68
+ function createProgram() {
69
+ return new Command().name("bejamas-bench").description("Compare an original site with its ported version: Lighthouse, route assets, accessibility, text and visual parity.").version(version, "-v, --version", "output the version number").usage("--original <url> --ported <url> [options]").option("--original <url>", "URL of the original site, e.g. https://example.com").option("--ported <url>", "URL of the ported site, e.g. http://localhost:4321").argument("[urls...]", "positional <original> <ported>; prefer the named flags so the URLs cannot be swapped").option("-o, --out <dir>", "also write report.md, report.json, screenshots and Lighthouse reports to this directory").option("-r, --runs <count>", "Lighthouse runs per URL; medians are reported", positiveInteger, 5).addOption(new Option("-w, --widths <list>", "viewport widths for visual parity").argParser(widthList).default([412, 1280], "412,1280")).addOption(new Option("--form-factor <type>", "Lighthouse device emulation").choices(["mobile", "desktop"]).default("mobile")).option("--only <stages>", `run only these stages (${STAGES.join(", ")})`, stageList).option("--skip <stages>", "skip these stages", stageList).option("--tolerance <px>", "allowed size difference for matched components and regions", nonNegativeNumber, .5).addOption(new Option("--fail-on <budgets>", "fail when the ported URL exceeds these budgets, e.g. lcp>10%,js>0 (repeatable)").argParser(collect).default([], "none")).option("--report-only", "always exit with 0 unless the run itself fails").option("--allow-dev", "measure assets and Lighthouse even when a dev server is detected").option("--chrome-path <path>", "Chrome executable (defaults to CHROME_PATH or an installed Chrome)").option("--timeout <ms>", "navigation timeout", positiveInteger, 6e4).option("--json", "print the JSON report to stdout instead of the summary").addHelpText("after", `
70
+ Exit codes: 0 when all checks and budgets pass, 1 when any fail, 2 when the run cannot complete.
71
+
72
+ ${BUDGET_HELP}
73
+
74
+ Examples:
75
+ $ bejamas-bench --original http://localhost:3000 --ported http://localhost:4321
76
+ $ bejamas-bench --original http://localhost:3000 --ported http://localhost:4321 --out bench-report
77
+ $ bejamas-bench --original https://example.com --ported https://new.example.com --fail-on "lcp>10%,js>0,pixels>1%"
78
+ $ bejamas-bench --original localhost:3000 --ported localhost:4321 --only visual,quality --widths 375,768,1440`).action(async (positional, opts) => {
79
+ const warn = (message) => process.stderr.write(`${kleur.yellow("warning")} ${message}\n`);
80
+ const { original, ported, warning } = resolveTargetUrls(opts, positional);
81
+ if (warning) warn(warning);
82
+ const { report, files } = await runBench({
83
+ original,
84
+ ported,
85
+ outDir: opts.out,
86
+ runs: opts.runs,
87
+ widths: opts.widths,
88
+ formFactor: opts.formFactor,
89
+ stages: selectStages(opts.only, opts.skip),
90
+ tolerance: opts.tolerance,
91
+ budgets: opts.failOn,
92
+ allowDev: opts.allowDev ?? false,
93
+ chromePath: opts.chromePath,
94
+ timeout: opts.timeout
95
+ }, (message) => process.stderr.write(`${kleur.dim("›")} ${message}\n`));
96
+ if (opts.json) {
97
+ for (const reason of report.comparability.reasons) warn(reason);
98
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
99
+ } else process.stdout.write(`\n${renderSummary(report)}\n`);
100
+ if (files) {
101
+ const shown = relative(process.cwd(), files.markdown);
102
+ process.stderr.write(`\nReport written to ${shown.startsWith("..") ? files.markdown : shown}\n`);
103
+ }
104
+ if (!report.passed && !opts.reportOnly) process.exitCode = 1;
105
+ if (report.errors.length > 0) process.exitCode = 2;
106
+ });
107
+ }
108
+ async function main(argv = process.argv) {
109
+ const program = createProgram();
110
+ program.exitOverride((error) => {
111
+ process.exit(error.exitCode === 0 ? 0 : 2);
112
+ });
113
+ try {
114
+ await program.parseAsync(argv);
115
+ } catch (error) {
116
+ const message = error instanceof BenchError ? error.message : error instanceof Error ? error.stack ?? error.message : String(error);
117
+ process.stderr.write(`${kleur.red("error")} ${message}\n`);
118
+ process.exitCode = 2;
119
+ }
120
+ }
121
+ await main();
122
+
123
+ //#endregion
124
+ export { };
125
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","names":["pkg.version"],"sources":["../src/args.ts","../src/cli.ts"],"sourcesContent":["import { InvalidArgumentError } from \"commander\";\nimport { BenchError } from \"./errors\";\nimport { STAGES, type StageName } from \"./types\";\n\nexport function positiveInteger(value: string) {\n const parsed = Number(value);\n if (!Number.isInteger(parsed) || parsed < 1)\n throw new InvalidArgumentError(\"Expected a positive integer.\");\n return parsed;\n}\n\nexport function nonNegativeNumber(value: string) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed) || parsed < 0)\n throw new InvalidArgumentError(\"Expected a non-negative number.\");\n return parsed;\n}\n\nexport function widthList(value: string) {\n const widths = value.split(\",\").map((part) => Number(part.trim()));\n if (\n widths.length === 0 ||\n widths.some(\n (width) => !Number.isInteger(width) || width < 200 || width > 3840,\n )\n ) {\n throw new InvalidArgumentError(\n \"Expected comma-separated widths between 200 and 3840.\",\n );\n }\n return [...new Set(widths)];\n}\n\nconst isStageName = (value: string): value is StageName =>\n (STAGES as readonly string[]).includes(value);\n\nexport function stageList(value: string): StageName[] {\n const parts = value\n .split(\",\")\n .map((part) => part.trim())\n .filter(Boolean);\n const stages = parts.filter(isStageName);\n if (stages.length === 0 || stages.length !== parts.length) {\n throw new InvalidArgumentError(\n `Expected a comma-separated list of: ${STAGES.join(\", \")}.`,\n );\n }\n return stages;\n}\n\nexport function collect(value: string, previous: string[]) {\n return [...previous, value];\n}\n\n/** Stages to run, in their fixed order, after --only and --skip. */\nexport function selectStages(\n only: readonly StageName[] = STAGES,\n skip: readonly StageName[] = [],\n) {\n const stages = only.filter((stage) => !skip.includes(stage));\n if (stages.length === 0) throw new BenchError(\"No stages left to run.\");\n return stages;\n}\n\n/**\n * Resolve which URL is which. Named flags are preferred because positional\n * URLs are easy to swap, which silently inverts every comparison.\n */\nexport function resolveTargetUrls(\n flags: { original?: string; ported?: string },\n positional: readonly string[],\n): { original: string; ported: string; warning?: string } {\n const named = flags.original !== undefined || flags.ported !== undefined;\n if (named && positional.length > 0) {\n throw new BenchError(\n `Pass the URLs either as --original and --ported or positionally, not both (got ${positional.join(\" \")}).`,\n );\n }\n if (named) {\n const { original, ported } = flags;\n if (original === undefined || ported === undefined) {\n const missing = [\n original === undefined && \"--original <url>\",\n ported === undefined && \"--ported <url>\",\n ].filter(Boolean);\n throw new BenchError(`Missing ${missing.join(\" and \")}.`);\n }\n return { original, ported };\n }\n if (positional.length !== 2) {\n throw new BenchError(\"Pass both URLs: --original <url> --ported <url>.\");\n }\n const [original, ported] = positional as [string, string];\n return {\n original,\n ported,\n warning: `Reading ${original} as the original and ${ported} as the port. Use --original and --ported to avoid swapping them.`,\n };\n}\n","#!/usr/bin/env node\nimport { relative } from \"node:path\";\nimport { Command, Option } from \"commander\";\nimport kleur from \"kleur\";\nimport pkg from \"../package.json\" with { type: \"json\" };\nimport {\n collect,\n nonNegativeNumber,\n positiveInteger,\n resolveTargetUrls,\n selectStages,\n stageList,\n widthList,\n} from \"./args\";\nimport { BUDGET_HELP } from \"./budgets\";\nimport { BenchError } from \"./errors\";\nimport { renderSummary } from \"./summary\";\nimport { runBench } from \"./run\";\nimport { STAGES, type FormFactor, type StageName } from \"./types\";\n\n/** Parsed options, as the parsers and defaults below produce them. */\ninterface CliOptions {\n original?: string;\n ported?: string;\n out?: string;\n runs: number;\n widths: number[];\n formFactor: FormFactor;\n only?: StageName[];\n skip?: StageName[];\n tolerance: number;\n failOn: string[];\n reportOnly?: true;\n allowDev?: true;\n chromePath?: string;\n timeout: number;\n json?: true;\n}\n\nfunction createProgram() {\n return new Command()\n .name(\"bejamas-bench\")\n .description(\n \"Compare an original site with its ported version: Lighthouse, route assets, accessibility, text and visual parity.\",\n )\n .version(pkg.version, \"-v, --version\", \"output the version number\")\n .usage(\"--original <url> --ported <url> [options]\")\n .option(\n \"--original <url>\",\n \"URL of the original site, e.g. https://example.com\",\n )\n .option(\n \"--ported <url>\",\n \"URL of the ported site, e.g. http://localhost:4321\",\n )\n .argument(\n \"[urls...]\",\n \"positional <original> <ported>; prefer the named flags so the URLs cannot be swapped\",\n )\n .option(\n \"-o, --out <dir>\",\n \"also write report.md, report.json, screenshots and Lighthouse reports to this directory\",\n )\n .option(\n \"-r, --runs <count>\",\n \"Lighthouse runs per URL; medians are reported\",\n positiveInteger,\n 5,\n )\n .addOption(\n new Option(\"-w, --widths <list>\", \"viewport widths for visual parity\")\n .argParser(widthList)\n .default([412, 1280], \"412,1280\"),\n )\n .addOption(\n new Option(\"--form-factor <type>\", \"Lighthouse device emulation\")\n .choices([\"mobile\", \"desktop\"])\n .default(\"mobile\"),\n )\n .option(\n \"--only <stages>\",\n `run only these stages (${STAGES.join(\", \")})`,\n stageList,\n )\n .option(\"--skip <stages>\", \"skip these stages\", stageList)\n .option(\n \"--tolerance <px>\",\n \"allowed size difference for matched components and regions\",\n nonNegativeNumber,\n 0.5,\n )\n .addOption(\n new Option(\n \"--fail-on <budgets>\",\n \"fail when the ported URL exceeds these budgets, e.g. lcp>10%,js>0 (repeatable)\",\n )\n .argParser(collect)\n .default([], \"none\"),\n )\n .option(\"--report-only\", \"always exit with 0 unless the run itself fails\")\n .option(\n \"--allow-dev\",\n \"measure assets and Lighthouse even when a dev server is detected\",\n )\n .option(\n \"--chrome-path <path>\",\n \"Chrome executable (defaults to CHROME_PATH or an installed Chrome)\",\n )\n .option(\"--timeout <ms>\", \"navigation timeout\", positiveInteger, 60_000)\n .option(\"--json\", \"print the JSON report to stdout instead of the summary\")\n .addHelpText(\n \"after\",\n `\nExit codes: 0 when all checks and budgets pass, 1 when any fail, 2 when the run cannot complete.\n\n${BUDGET_HELP}\n\nExamples:\n $ bejamas-bench --original http://localhost:3000 --ported http://localhost:4321\n $ bejamas-bench --original http://localhost:3000 --ported http://localhost:4321 --out bench-report\n $ bejamas-bench --original https://example.com --ported https://new.example.com --fail-on \"lcp>10%,js>0,pixels>1%\"\n $ bejamas-bench --original localhost:3000 --ported localhost:4321 --only visual,quality --widths 375,768,1440`,\n )\n .action(async (positional: string[], opts: CliOptions) => {\n const warn = (message: string) =>\n process.stderr.write(`${kleur.yellow(\"warning\")} ${message}\\n`);\n const { original, ported, warning } = resolveTargetUrls(opts, positional);\n if (warning) warn(warning);\n\n const { report, files } = await runBench(\n {\n original,\n ported,\n outDir: opts.out,\n runs: opts.runs,\n widths: opts.widths,\n formFactor: opts.formFactor,\n stages: selectStages(opts.only, opts.skip),\n tolerance: opts.tolerance,\n budgets: opts.failOn,\n allowDev: opts.allowDev ?? false,\n chromePath: opts.chromePath,\n timeout: opts.timeout,\n },\n (message) => process.stderr.write(`${kleur.dim(\"›\")} ${message}\\n`),\n );\n\n if (opts.json) {\n // The summary shows comparability; JSON consumers get it on stderr.\n for (const reason of report.comparability.reasons) warn(reason);\n process.stdout.write(`${JSON.stringify(report, null, 2)}\\n`);\n } else {\n process.stdout.write(`\\n${renderSummary(report)}\\n`);\n }\n if (files) {\n const shown = relative(process.cwd(), files.markdown);\n process.stderr.write(\n `\\nReport written to ${shown.startsWith(\"..\") ? files.markdown : shown}\\n`,\n );\n }\n if (!report.passed && !opts.reportOnly) process.exitCode = 1;\n if (report.errors.length > 0) process.exitCode = 2;\n });\n}\n\nasync function main(argv = process.argv) {\n const program = createProgram();\n program.exitOverride((error) => {\n // Commander errors (usage) exit with 2; help and version exit with 0.\n process.exit(error.exitCode === 0 ? 0 : 2);\n });\n try {\n await program.parseAsync(argv);\n } catch (error) {\n const message =\n error instanceof BenchError\n ? error.message\n : error instanceof Error\n ? (error.stack ?? error.message)\n : String(error);\n process.stderr.write(`${kleur.red(\"error\")} ${message}\\n`);\n process.exitCode = 2;\n }\n}\n\nawait main();\n"],"mappings":"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"}
@@ -0,0 +1,289 @@
1
+ //#region src/types.d.ts
2
+ export type Side = "original" | "ported";
3
+ export declare const SIDES: readonly Side[];
4
+ /** One value per side. */
5
+ export type Paired<T> = Record<Side, T>;
6
+ export type StageName = "assets" | "quality" | "visual" | "lighthouse";
7
+ export declare const STAGES: readonly StageName[];
8
+ export type FormFactor = "mobile" | "desktop";
9
+ export interface BenchOptions {
10
+ original: string;
11
+ ported: string;
12
+ /** Write report files here. Nothing is written when omitted. */
13
+ outDir?: string;
14
+ runs: number;
15
+ widths: number[];
16
+ formFactor: FormFactor;
17
+ stages: StageName[];
18
+ tolerance: number;
19
+ budgets: string[];
20
+ allowDev: boolean;
21
+ chromePath?: string;
22
+ timeout: number;
23
+ }
24
+ export interface Target {
25
+ side: Side;
26
+ input: string;
27
+ url: string;
28
+ finalUrl: string;
29
+ status: number;
30
+ local: boolean;
31
+ devServer: string | null;
32
+ }
33
+ export interface Comparability {
34
+ comparable: boolean;
35
+ reasons: string[];
36
+ }
37
+ export type AssetCategory = "html" | "css" | "js" | "fonts" | "images" | "other";
38
+ export declare const ASSET_CATEGORIES: readonly AssetCategory[];
39
+ export interface AssetEntry {
40
+ url: string;
41
+ category: AssetCategory;
42
+ thirdParty: boolean;
43
+ /** Encoded body bytes received over the network. */
44
+ transfer: number;
45
+ /** Decoded body bytes. */
46
+ raw: number;
47
+ /** Deterministic gzip level 9 estimate, compressible categories only. */
48
+ gzip: number | null;
49
+ }
50
+ export interface AssetSummary {
51
+ count: number;
52
+ transfer: number;
53
+ raw: number;
54
+ /** Comparable size: the gzip estimate for HTML, CSS and JS, transferred bytes otherwise. */
55
+ size: number;
56
+ }
57
+ export interface SideAssets {
58
+ finalUrl: string;
59
+ viewport: {
60
+ width: number;
61
+ height: number;
62
+ };
63
+ categories: Record<AssetCategory, AssetSummary>;
64
+ /** Comparable size of HTML + CSS + JS + fonts. */
65
+ coreSize: number;
66
+ requests: number;
67
+ transfer: number;
68
+ entries: AssetEntry[];
69
+ }
70
+ export type AssetsResult = Paired<SideAssets>;
71
+ export interface AxeViolation {
72
+ id: string;
73
+ impact: string | null;
74
+ help: string;
75
+ nodes: number;
76
+ }
77
+ export interface Heading {
78
+ level: number;
79
+ text: string;
80
+ }
81
+ export interface FailedRequest {
82
+ url: string;
83
+ /** An HTTP status, "blocked by CORS" or Chrome's network error. */
84
+ reason: string;
85
+ }
86
+ export interface SideQuality {
87
+ finalUrl: string;
88
+ title: string;
89
+ description: string | null;
90
+ lang: string | null;
91
+ text: string;
92
+ headings: Heading[];
93
+ domElements: number;
94
+ domBytes: number;
95
+ nestedInteractiveControls: number;
96
+ violations: AxeViolation[];
97
+ errors: string[];
98
+ failedRequests: FailedRequest[];
99
+ }
100
+ export interface TextDifference {
101
+ identical: boolean;
102
+ originalWords: number;
103
+ portedWords: number;
104
+ /** Context around the first differing region, when the text differs. */
105
+ original?: string;
106
+ ported?: string;
107
+ }
108
+ export interface QualityResult extends Paired<SideQuality> {
109
+ viewport: {
110
+ width: number;
111
+ height: number;
112
+ };
113
+ text: TextDifference;
114
+ }
115
+ export interface Box {
116
+ x: number;
117
+ y: number;
118
+ width: number;
119
+ height: number;
120
+ }
121
+ export interface SlotElement {
122
+ slot: string;
123
+ text: string;
124
+ width: number;
125
+ height: number;
126
+ }
127
+ export interface Landmark extends Box {
128
+ tag: string;
129
+ }
130
+ export interface Capture {
131
+ side: Side;
132
+ width: number;
133
+ /** Screenshot file, when report files are written. */
134
+ screenshot: string | null;
135
+ pageHeight: number;
136
+ elements: SlotElement[];
137
+ landmarks: Landmark[];
138
+ }
139
+ export interface SlotDifference {
140
+ slot: string;
141
+ text: string;
142
+ original: [number, number];
143
+ ported: [number, number];
144
+ }
145
+ export interface LandmarkDifference {
146
+ index: number;
147
+ tag: string;
148
+ original: Box | null;
149
+ ported: Box | null;
150
+ }
151
+ export interface GeometryComparison {
152
+ /** Named `data-slot` elements on each side; generic part names are left out. */
153
+ originalElements: number;
154
+ portedElements: number;
155
+ matchedElements: number;
156
+ differences: SlotDifference[];
157
+ unmatchedOriginal: Record<string, number>;
158
+ unmatchedPorted: Record<string, number>;
159
+ landmarkDifferences: LandmarkDifference[];
160
+ }
161
+ export interface PixelComparison {
162
+ /** Diff image file, when report files are written. */
163
+ diff: string | null;
164
+ comparedWidth: number;
165
+ comparedHeight: number;
166
+ heightDelta: number;
167
+ mismatchedPixels: number;
168
+ /** Percentage of compared pixels that differ. */
169
+ mismatch: number;
170
+ }
171
+ export interface VisualWidthResult extends GeometryComparison {
172
+ width: number;
173
+ pixels: PixelComparison;
174
+ }
175
+ export interface VisualResult {
176
+ tolerance: number;
177
+ captures: Capture[];
178
+ widths: VisualWidthResult[];
179
+ }
180
+ export type LighthouseMetric = "score" | "fcp" | "lcp" | "tbt" | "cls" | "si";
181
+ export declare const LIGHTHOUSE_METRICS: readonly LighthouseMetric[];
182
+ export type LighthouseValues = Record<LighthouseMetric, number | null>;
183
+ export interface LighthouseRun extends LighthouseValues {
184
+ run: number;
185
+ /** Full Lighthouse report file, when report files are written. */
186
+ report: string | null;
187
+ }
188
+ export interface LighthouseSide {
189
+ runs: LighthouseRun[];
190
+ median: LighthouseValues;
191
+ }
192
+ export interface LighthouseResult extends Paired<LighthouseSide> {
193
+ formFactor: FormFactor;
194
+ version: string;
195
+ runs: number;
196
+ order: string;
197
+ }
198
+ export type CheckStatus = "pass" | "fail" | "warn" | "skip";
199
+ export interface Check {
200
+ id: string;
201
+ label: string;
202
+ status: CheckStatus;
203
+ detail: string;
204
+ }
205
+ export interface BudgetResult {
206
+ budget: string;
207
+ status: "pass" | "fail" | "skip";
208
+ detail: string;
209
+ }
210
+ export interface SkippedStage {
211
+ stage: StageName;
212
+ reason: string;
213
+ }
214
+ export interface StageError {
215
+ stage: StageName;
216
+ message: string;
217
+ }
218
+ export interface BenchReport {
219
+ schemaVersion: 1;
220
+ tool: {
221
+ name: string;
222
+ version: string;
223
+ };
224
+ measuredAt: string;
225
+ environment: {
226
+ node: string;
227
+ platform: string;
228
+ arch: string;
229
+ browser: string | null;
230
+ };
231
+ options: Omit<BenchOptions, "original" | "ported">;
232
+ targets: Paired<Target>;
233
+ comparability: Comparability;
234
+ skipped: SkippedStage[];
235
+ errors: StageError[];
236
+ assets?: AssetsResult;
237
+ quality?: QualityResult;
238
+ visual?: VisualResult;
239
+ lighthouse?: LighthouseResult;
240
+ checks: Check[];
241
+ budgets: BudgetResult[];
242
+ passed: boolean;
243
+ }
244
+ //#endregion
245
+ //#region src/run.d.ts
246
+ interface RunResult {
247
+ report: BenchReport;
248
+ /** Report files, when `outDir` is set. */
249
+ files?: {
250
+ json: string;
251
+ markdown: string;
252
+ };
253
+ }
254
+ export declare function runBench(options: BenchOptions, log?: (message: string) => void): Promise<RunResult>;
255
+ //#endregion
256
+ //#region src/report.d.ts
257
+ export declare function renderMarkdown(report: BenchReport, outDir: string): string;
258
+ //#endregion
259
+ //#region src/summary.d.ts
260
+ /** Everything a reader needs from the report, formatted for a terminal. */
261
+ export declare function renderSummary(report: BenchReport, width?: number): string;
262
+ //#endregion
263
+ //#region src/budgets.d.ts
264
+ type BudgetMetric = LighthouseMetric | "js" | "css" | "html" | "fonts" | "total" | "requests" | "pixels";
265
+ interface Budget {
266
+ source: string;
267
+ metric: BudgetMetric;
268
+ op: ">" | "<";
269
+ value: number;
270
+ /** Compare the relative change in percent instead of the absolute change. */
271
+ relative: boolean;
272
+ }
273
+ interface BudgetInputs {
274
+ assets?: AssetsResult;
275
+ lighthouse?: LighthouseResult;
276
+ visual?: VisualResult;
277
+ comparability: Comparability;
278
+ }
279
+ export declare function parseBudgets(inputs: readonly string[]): Budget[];
280
+ export declare function evaluateBudgets(budgets: readonly Budget[], inputs: BudgetInputs): BudgetResult[];
281
+ //#endregion
282
+ //#region src/errors.d.ts
283
+ /** An expected failure with a message that is safe to print without a stack. */
284
+ export declare class BenchError extends Error {
285
+ name: string;
286
+ }
287
+ //#endregion
288
+ export type { Budget, BudgetMetric, RunResult };
289
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/run.ts","../src/report.ts","../src/summary.ts","../src/budgets.ts","../src/errors.ts"],"mappings":";YAAY;qBACC,gBAAgB;;YAGjB,OAAO,KAAK,OAAO,MAAM;YAEzB;qBACC,iBAAiB;YAOlB;iBAEK;EACf;EACA;;EAEA;EACA;EACA;EACA,YAAY;EACZ,QAAQ;EACR;EACA;EACA;EACA;EACA;;iBAGe;EACf,MAAM;EACN;EACA;EACA;EACA;EACA;EACA;;iBAGe;EACf;EACA;;YAGU;qBAEC,2BAA2B;iBASvB;EACf;EACA,UAAU;EACV;;EAEA;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;EAEA;;iBAGe;EACf;EACA;IAAY;IAAe;;EAC3B,YAAY,OAAO,eAAe;;EAElC;EACA;EACA;EACA,SAAS;;YAGC,eAAe,OAAO;iBAEjB;EACf;EACA;EACA;EACA;;iBAGe;EACf;EACA;;iBAGe;EACf;;EAEA;;iBAGe;EACf;EACA;EACA;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA,YAAY;EACZ;EACA,gBAAgB;;iBAGD;EACf;EACA;EACA;;EAEA;EACA;;iBAGe,sBAAsB,OAAO;EAC5C;IAAY;IAAe;;EAC3B,MAAM;;iBAGS;EACf;EACA;EACA;EACA;;iBAGe;EACf;EACA;EACA;EACA;;iBAGe,iBAAiB;EAChC;;iBAGe;EACf,MAAM;EACN;;EAEA;EACA;EACA,UAAU;EACV,WAAW;;iBAGI;EACf;EACA;EACA;EACA;;iBAGe;EACf;EACA;EACA,UAAU;EACV,QAAQ;;iBAGO;;EAEf;EACA;EACA;EACA,aAAa;EACb,mBAAmB;EACnB,iBAAiB;EACjB,qBAAqB;;iBAGN;;EAEf;EACA;EACA;EACA;EACA;;EAEA;;iBAGe,0BAA0B;EACzC;EACA,QAAQ;;iBAGO;EACf;EACA,UAAU;EACV,QAAQ;;YAGE;qBACC,6BAA6B;YAS9B,mBAAmB,OAAO;iBAErB,sBAAsB;EACrC;;EAEA;;iBAGe;EACf,MAAM;EACN,QAAQ;;iBAGO,yBAAyB,OAAO;EAC/C,YAAY;EACZ;EACA;EACA;;YAGU;iBAEK;EACf;EACA;EACA,QAAQ;EACR;;iBAGe;EACf;EACA;EACA;;iBAGe;EACf,OAAO;EACP;;iBAGe;EACf,OAAO;EACP;;iBAGe;EACf;EACA;IAAQ;IAAc;;EACtB;EACA;IACE;IACA;IACA;IACA;;EAEF,SAAS,KAAK;EACd,SAAS,OAAO;EAChB,eAAe;EACf,SAAS;EACT,QAAQ;EACR,SAAS;EACT,UAAU;EACV,SAAS;EACT,aAAa;EACb,QAAQ;EACR,SAAS;EACT;;;;UC3Qe;EACf,QAAQ;;EAER;IAAU;IAAc;;;wBAMJ,SACpB,SAAS,cACT,OAAM,2BACL,QAAQ;;;wBC2BK,eAAe,QAAQ,aAAa;;;;wBCiCpC,cACd,QAAQ,aACR;;;KClFU,eACR;UASa;EACf;EACA,QAAQ;EACR;EACA;;EAEA;;UAGe;EACf,SAAS;EACT,aAAa;EACb,SAAS;EACT,eAAe;;wBA6FD,aAAa,4BAA4B;wBAwFzC,gBACd,kBAAkB,UAClB,QAAQ,eACP;;;;qBC5NU,mBAAmB;EACrB"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { a as LIGHTHOUSE_METRICS, d as BenchError, i as ASSET_CATEGORIES, l as evaluateBudgets, n as runBench, o as SIDES, r as renderMarkdown, s as STAGES, t as renderSummary, u as parseBudgets } from "./summary-DD9dfSgQ.js";
2
+
3
+ export { ASSET_CATEGORIES, BenchError, LIGHTHOUSE_METRICS, SIDES, STAGES, evaluateBudgets, parseBudgets, renderMarkdown, renderSummary, runBench };