laxy-verify 0.1.2 → 1.0.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.
Files changed (47) hide show
  1. package/README.md +150 -163
  2. package/dist/badge.d.ts +1 -0
  3. package/dist/badge.js +11 -0
  4. package/dist/build.d.ts +11 -0
  5. package/dist/build.js +59 -0
  6. package/dist/cli.d.ts +2 -2
  7. package/dist/cli.js +257 -102
  8. package/dist/comment.d.ts +20 -0
  9. package/dist/comment.js +53 -0
  10. package/dist/config.d.ts +34 -16
  11. package/dist/config.js +118 -55
  12. package/dist/detect.d.ts +10 -0
  13. package/dist/detect.js +97 -0
  14. package/dist/github.d.ts +8 -0
  15. package/dist/github.js +11 -0
  16. package/dist/grade.d.ts +37 -0
  17. package/dist/grade.js +57 -0
  18. package/dist/init.d.ts +1 -0
  19. package/dist/init.js +85 -0
  20. package/dist/lighthouse.d.ts +7 -0
  21. package/dist/lighthouse.js +94 -0
  22. package/dist/serve.d.ts +12 -0
  23. package/dist/serve.js +83 -0
  24. package/dist/status.d.ts +6 -0
  25. package/dist/status.js +33 -0
  26. package/package.json +29 -44
  27. package/action.yml +0 -97
  28. package/dist/build-runner.d.ts +0 -22
  29. package/dist/build-runner.js +0 -156
  30. package/dist/build-runner.js.map +0 -1
  31. package/dist/cli.js.map +0 -1
  32. package/dist/config.js.map +0 -1
  33. package/dist/dev-server.d.ts +0 -8
  34. package/dist/dev-server.js +0 -81
  35. package/dist/dev-server.js.map +0 -1
  36. package/dist/lighthouse-runner.d.ts +0 -10
  37. package/dist/lighthouse-runner.js +0 -119
  38. package/dist/lighthouse-runner.js.map +0 -1
  39. package/dist/project-runtime.d.ts +0 -32
  40. package/dist/project-runtime.js +0 -99
  41. package/dist/project-runtime.js.map +0 -1
  42. package/dist/reporter.d.ts +0 -21
  43. package/dist/reporter.js +0 -100
  44. package/dist/reporter.js.map +0 -1
  45. package/dist/verification.d.ts +0 -42
  46. package/dist/verification.js +0 -105
  47. package/dist/verification.js.map +0 -1
package/dist/config.js CHANGED
@@ -1,55 +1,118 @@
1
- import { readFileSync, existsSync } from "fs";
2
- import { join } from "path";
3
- import { parse as parseYaml } from "yaml";
4
- import { LH_THRESHOLDS, LH_CI_THRESHOLDS } from "./verification.js";
5
- const DEFAULT_CONFIG = {
6
- thresholds: { ...LH_THRESHOLDS },
7
- port: 3000,
8
- runs: 1,
9
- };
10
- export function loadConfig(projectPath, ciMode = false) {
11
- const warnings = [];
12
- const configPath = join(projectPath, ".laxy.yml");
13
- const defaults = {
14
- thresholds: ciMode ? { ...LH_CI_THRESHOLDS } : { ...LH_THRESHOLDS },
15
- port: DEFAULT_CONFIG.port,
16
- runs: ciMode ? 3 : 1,
17
- };
18
- if (!existsSync(configPath)) {
19
- return { config: defaults, warnings };
20
- }
21
- try {
22
- const raw = readFileSync(configPath, "utf-8");
23
- const parsed = parseYaml(raw);
24
- if (!parsed || typeof parsed !== "object") {
25
- warnings.push("Invalid .laxy.yml: not an object. Using defaults.");
26
- return { config: defaults, warnings };
27
- }
28
- const config = { ...defaults };
29
- if (parsed.thresholds && typeof parsed.thresholds === "object") {
30
- const t = parsed.thresholds;
31
- if (typeof t.performance === "number")
32
- config.thresholds.performance = t.performance;
33
- if (typeof t.accessibility === "number")
34
- config.thresholds.accessibility = t.accessibility;
35
- if (typeof t.seo === "number")
36
- config.thresholds.seo = t.seo;
37
- if (typeof t.bestPractices === "number")
38
- config.thresholds.bestPractices = t.bestPractices;
39
- }
40
- if (typeof parsed.buildCommand === "string")
41
- config.buildCommand = parsed.buildCommand;
42
- if (typeof parsed.devCommand === "string")
43
- config.devCommand = parsed.devCommand;
44
- if (typeof parsed.port === "number")
45
- config.port = parsed.port;
46
- if (typeof parsed.runs === "number")
47
- config.runs = Math.max(1, Math.min(parsed.runs, 5));
48
- return { config, warnings };
49
- }
50
- catch {
51
- warnings.push("Failed to parse .laxy.yml. Using defaults.");
52
- return { config: defaults, warnings };
53
- }
54
- }
55
- //# sourceMappingURL=config.js.map
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as yaml from "js-yaml";
4
+ const DEFAULT_CONFIG = {
5
+ framework: "auto",
6
+ build_command: "",
7
+ dev_command: "",
8
+ package_manager: "auto",
9
+ port: 3000,
10
+ build_timeout: 300,
11
+ dev_timeout: 60,
12
+ lighthouse_runs: 1,
13
+ thresholds: {
14
+ performance: 70,
15
+ accessibility: 85,
16
+ seo: 80,
17
+ bestPractices: 80,
18
+ },
19
+ fail_on: "bronze",
20
+ };
21
+ const VALID_FAIL_ON = ["unverified", "bronze", "silver", "gold"];
22
+ export class ConfigParseError extends Error {
23
+ constructor(msg) {
24
+ super(msg);
25
+ this.name = "ConfigParseError";
26
+ }
27
+ }
28
+ function parseYaml(filePath) {
29
+ const content = fs.readFileSync(filePath, "utf-8");
30
+ const raw = yaml.load(content);
31
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
32
+ throw new ConfigParseError("Invalid YAML structure in .laxy.yml");
33
+ }
34
+ const result = {};
35
+ if (typeof raw.framework === "string")
36
+ result.framework = raw.framework;
37
+ if (typeof raw.build_command === "string")
38
+ result.build_command = raw.build_command;
39
+ if (typeof raw.dev_command === "string")
40
+ result.dev_command = raw.dev_command;
41
+ if (typeof raw.package_manager === "string")
42
+ result.package_manager = raw.package_manager;
43
+ if (typeof raw.port === "number")
44
+ result.port = raw.port;
45
+ if (typeof raw.build_timeout === "number")
46
+ result.build_timeout = raw.build_timeout;
47
+ if (typeof raw.dev_timeout === "number")
48
+ result.dev_timeout = raw.dev_timeout;
49
+ if (typeof raw.lighthouse_runs === "number")
50
+ result.lighthouse_runs = raw.lighthouse_runs;
51
+ if (typeof raw.fail_on === "string") {
52
+ const f = raw.fail_on;
53
+ if (!VALID_FAIL_ON.includes(f)) {
54
+ throw new ConfigParseError(`Invalid fail_on value: "${f}". Must be one of: ${VALID_FAIL_ON.join(", ")}`);
55
+ }
56
+ result.fail_on = f;
57
+ }
58
+ if (typeof raw.thresholds === "object" &&
59
+ raw.thresholds !== null &&
60
+ !Array.isArray(raw.thresholds)) {
61
+ const t = raw.thresholds;
62
+ const thr = {};
63
+ if (typeof t.performance === "number")
64
+ thr.performance = t.performance;
65
+ if (typeof t.accessibility === "number")
66
+ thr.accessibility = t.accessibility;
67
+ if (typeof t.seo === "number")
68
+ thr.seo = t.seo;
69
+ if (typeof t.best_practices === "number")
70
+ thr.bestPractices = t.best_practices;
71
+ result.thresholds = thr;
72
+ }
73
+ return result;
74
+ }
75
+ export function loadConfig(options) {
76
+ const configPath = options.configPath ?? path.join(options.dir, ".laxy.yml");
77
+ let base = {};
78
+ if (fs.existsSync(configPath)) {
79
+ base = parseYaml(configPath);
80
+ }
81
+ const config = {
82
+ ...DEFAULT_CONFIG,
83
+ framework: base.framework ?? DEFAULT_CONFIG.framework,
84
+ build_command: base.build_command ?? DEFAULT_CONFIG.build_command,
85
+ dev_command: base.dev_command ?? DEFAULT_CONFIG.dev_command,
86
+ package_manager: base.package_manager ?? DEFAULT_CONFIG.package_manager,
87
+ port: base.port ?? DEFAULT_CONFIG.port,
88
+ build_timeout: base.build_timeout ?? DEFAULT_CONFIG.build_timeout,
89
+ dev_timeout: base.dev_timeout ?? DEFAULT_CONFIG.dev_timeout,
90
+ lighthouse_runs: base.lighthouse_runs ?? DEFAULT_CONFIG.lighthouse_runs,
91
+ fail_on: base.fail_on ?? DEFAULT_CONFIG.fail_on,
92
+ };
93
+ config.thresholds = { ...DEFAULT_CONFIG.thresholds, ...(base.thresholds ?? {}) };
94
+ // CLI flag overrides
95
+ if (options.cliFlags?.failOn !== undefined) {
96
+ if (!VALID_FAIL_ON.includes(options.cliFlags.failOn)) {
97
+ throw new ConfigParseError(`Invalid --fail-on value: "${options.cliFlags.failOn}". Must be one of: ${VALID_FAIL_ON.join(", ")}`);
98
+ }
99
+ config.fail_on = options.cliFlags.failOn;
100
+ }
101
+ // CI mode: apply CI defaults
102
+ const ciMode = options.ciMode;
103
+ if (ciMode) {
104
+ // dev_timeout: 90s in CI
105
+ if (!base.dev_timeout) {
106
+ config.dev_timeout = 90;
107
+ }
108
+ // lighthouse_runs: default to 3 in CI, but explicit config file value wins
109
+ if (!base.lighthouse_runs) {
110
+ config.lighthouse_runs = 3;
111
+ }
112
+ }
113
+ // Skip lighthouse: max grade is Bronze
114
+ if (options.cliFlags?.skipLighthouse) {
115
+ // Effectively disables Lighthouse grading
116
+ }
117
+ return { ...config, ciMode };
118
+ }
@@ -0,0 +1,10 @@
1
+ export type Framework = "nextjs" | "vite" | "cra" | "sveltekit";
2
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
3
+ export interface DetectResult {
4
+ framework: Framework | null;
5
+ packageManager: PackageManager;
6
+ buildCmd: string;
7
+ devCmd: string;
8
+ port: number;
9
+ }
10
+ export declare function detect(dir: string): DetectResult;
package/dist/detect.js ADDED
@@ -0,0 +1,97 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ const FRAMEWORK_DEFAULT_PORTS = {
4
+ nextjs: 3000,
5
+ vite: 5173,
6
+ cra: 3000,
7
+ sveltekit: 5173,
8
+ };
9
+ function detectPackageManager(dir) {
10
+ if (fs.existsSync(path.join(dir, "pnpm-lock.yaml")))
11
+ return "pnpm";
12
+ if (fs.existsSync(path.join(dir, "yarn.lock")))
13
+ return "yarn";
14
+ if (fs.existsSync(path.join(dir, "bun.lockb")) ||
15
+ fs.existsSync(path.join(dir, "bun.lock")))
16
+ return "bun";
17
+ return "npm";
18
+ }
19
+ function detectFramework(packageJson) {
20
+ const allDeps = {
21
+ ...(packageJson.dependencies ?? {}),
22
+ ...(packageJson.devDependencies ?? {}),
23
+ };
24
+ const scripts = packageJson.scripts ?? {};
25
+ const startScript = scripts.start ?? scripts.dev ?? "";
26
+ if (allDeps["next"])
27
+ return "nextjs";
28
+ if (allDeps["@sveltejs/kit"] || allDeps["svelte-kit"])
29
+ return "sveltekit";
30
+ // CRA: react-scripts or @craco/craco with react-scripts
31
+ if (allDeps["react-scripts"])
32
+ return "cra";
33
+ // Vite
34
+ if (allDeps["vite"])
35
+ return "vite";
36
+ // Fallback: check start script heuristics
37
+ if (startScript.includes("next"))
38
+ return "nextjs";
39
+ if (startScript.includes("vite"))
40
+ return "vite";
41
+ if (startScript.includes("react-scripts"))
42
+ return "cra";
43
+ return null;
44
+ }
45
+ function getBuildCommand(framework, packageManager, scripts) {
46
+ if (scripts.build) {
47
+ const pmRun = packageManager === "npm" ? "npm run" : packageManager;
48
+ return `${pmRun} build`;
49
+ }
50
+ // Framework defaults
51
+ const pmRun = packageManager === "npm" ? "npm run" : packageManager;
52
+ return `${pmRun} build`;
53
+ }
54
+ function getDevCommand(framework, packageManager, scripts) {
55
+ const pmRun = packageManager === "npm" ? "npm run" : packageManager;
56
+ if (scripts.dev)
57
+ return `${pmRun} dev`;
58
+ if (scripts.start)
59
+ return `${pmRun} start`;
60
+ if (scripts.serve)
61
+ return `${pmRun} serve`;
62
+ // Framework defaults
63
+ switch (framework) {
64
+ case "nextjs":
65
+ return "next dev";
66
+ case "vite":
67
+ return "vite";
68
+ case "cra":
69
+ return "react-scripts start";
70
+ case "sveltekit":
71
+ return "vite dev";
72
+ }
73
+ return `${pmRun} dev`;
74
+ }
75
+ export function detect(dir) {
76
+ const pkgPath = path.join(dir, "package.json");
77
+ if (!fs.existsSync(pkgPath)) {
78
+ throw new Error(`Not a Node.js project: no package.json found at ${pkgPath}`);
79
+ }
80
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
81
+ if (!pkg.scripts || !pkg.scripts.build) {
82
+ throw new Error("No 'build' script found in package.json");
83
+ }
84
+ const framework = detectFramework(pkg);
85
+ const packageManager = detectPackageManager(dir);
86
+ const scripts = pkg.scripts ?? {};
87
+ const buildCmd = getBuildCommand(framework, packageManager, scripts);
88
+ const devCmd = getDevCommand(framework, packageManager, scripts);
89
+ const defaultPort = framework ? FRAMEWORK_DEFAULT_PORTS[framework] : 3000;
90
+ return {
91
+ framework,
92
+ packageManager,
93
+ buildCmd,
94
+ devCmd,
95
+ port: defaultPort,
96
+ };
97
+ }
@@ -0,0 +1,8 @@
1
+ export interface GitHubContext {
2
+ token: string;
3
+ repository: string;
4
+ sha: string;
5
+ eventPath?: string;
6
+ eventName: string;
7
+ }
8
+ export declare function getGitHubContext(): GitHubContext | null;
package/dist/github.js ADDED
@@ -0,0 +1,11 @@
1
+ export function getGitHubContext() {
2
+ const token = process.env.GITHUB_TOKEN;
3
+ const repository = process.env.GITHUB_REPOSITORY;
4
+ const sha = process.env.GITHUB_SHA;
5
+ const eventPath = process.env.GITHUB_EVENT_PATH;
6
+ const eventName = process.env.GITHUB_EVENT_NAME ?? "";
7
+ if (!token || !repository || !sha) {
8
+ return null;
9
+ }
10
+ return { token, repository, sha, eventPath, eventName };
11
+ }
@@ -0,0 +1,37 @@
1
+ export type VerificationGrade = "gold" | "silver" | "bronze" | "unverified";
2
+ export interface LighthouseScores {
3
+ performance: number;
4
+ accessibility: number;
5
+ bestPractices: number;
6
+ seo: number;
7
+ }
8
+ export declare function getLighthousePass(scores: LighthouseScores, thresholds: {
9
+ performance: number;
10
+ accessibility: number;
11
+ seo: number;
12
+ bestPractices: number;
13
+ }): boolean;
14
+ export declare function isWorseOrEqual(actual: VerificationGrade, threshold: VerificationGrade): boolean;
15
+ interface GradeResult {
16
+ grade: VerificationGrade;
17
+ exitCode: number;
18
+ }
19
+ export declare function calculateGrade(options: {
20
+ buildSuccess: boolean;
21
+ scores?: LighthouseScores;
22
+ thresholds: {
23
+ performance: number;
24
+ accessibility: number;
25
+ seo: number;
26
+ bestPractices: number;
27
+ };
28
+ failOn: VerificationGrade;
29
+ }): GradeResult;
30
+ export declare function gradeToColor(grade: VerificationGrade): {
31
+ text: string;
32
+ bg: string;
33
+ border: string;
34
+ hex: string;
35
+ };
36
+ export declare function gradeToLabel(grade: VerificationGrade): string;
37
+ export {};
package/dist/grade.js ADDED
@@ -0,0 +1,57 @@
1
+ const GRADE_ORDER = ["gold", "silver", "bronze", "unverified"];
2
+ export function getLighthousePass(scores, thresholds) {
3
+ return (scores.performance >= thresholds.performance &&
4
+ scores.accessibility >= thresholds.accessibility &&
5
+ scores.seo >= thresholds.seo &&
6
+ scores.bestPractices >= thresholds.bestPractices);
7
+ }
8
+ export function isWorseOrEqual(actual, threshold) {
9
+ // Returns true if actual is worse than threshold
10
+ // Grade order (best to worst): gold, silver, bronze, unverified
11
+ return GRADE_ORDER.indexOf(actual) > GRADE_ORDER.indexOf(threshold);
12
+ }
13
+ export function calculateGrade(options) {
14
+ const { buildSuccess, scores, thresholds, failOn } = options;
15
+ let grade;
16
+ if (!buildSuccess) {
17
+ grade = "unverified";
18
+ }
19
+ else if (scores && getLighthousePass(scores, thresholds)) {
20
+ grade = "silver";
21
+ }
22
+ else if (buildSuccess) {
23
+ grade = "bronze";
24
+ }
25
+ else {
26
+ grade = "unverified";
27
+ }
28
+ // Determine exit code
29
+ // fail_on: unverified means "never fail" (informational only)
30
+ // any other fail_on: exit 1 if grade is worse than fail_on
31
+ const exitCode = failOn === "unverified"
32
+ ? 0
33
+ : isWorseOrEqual(grade, failOn)
34
+ ? 1
35
+ : 0;
36
+ return { grade, exitCode };
37
+ }
38
+ export function gradeToColor(grade) {
39
+ switch (grade) {
40
+ case "gold":
41
+ return { text: "text-yellow-400", bg: "bg-yellow-400/10", border: "border-yellow-400/30", hex: "#FACC15" };
42
+ case "silver":
43
+ return { text: "text-emerald-400", bg: "bg-emerald-400/10", border: "border-emerald-400/30", hex: "#34D399" };
44
+ case "bronze":
45
+ return { text: "text-blue-400", bg: "bg-blue-400/10", border: "border-blue-400/30", hex: "#60A5FA" };
46
+ default:
47
+ return { text: "text-gray-400", bg: "bg-gray-500/5", border: "border-gray-500/30", hex: "#9CA3AF" };
48
+ }
49
+ }
50
+ export function gradeToLabel(grade) {
51
+ return {
52
+ gold: "Gold",
53
+ silver: "Silver",
54
+ bronze: "Bronze",
55
+ unverified: "Unverified",
56
+ }[grade];
57
+ }
package/dist/init.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runInit(dir: string): void;
package/dist/init.js ADDED
@@ -0,0 +1,85 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { detect } from "./detect.js";
4
+ export function runInit(dir) {
5
+ const laxyYmlPath = path.join(dir, ".laxy.yml");
6
+ const workflowDir = path.join(dir, ".github", "workflows");
7
+ const workflowPath = path.join(workflowDir, "laxy-verify.yml");
8
+ const gitignorePath = path.join(dir, ".gitignore");
9
+ // 1. Generate .laxy.yml
10
+ if (fs.existsSync(laxyYmlPath)) {
11
+ console.log(".laxy.yml already exists — skipping. Delete it to regenerate.");
12
+ }
13
+ else {
14
+ let detectedFramework = null;
15
+ let detectedPort = 3000;
16
+ try {
17
+ const detected = detect(dir);
18
+ detectedFramework = detected.framework ?? "auto";
19
+ detectedPort = detected.port;
20
+ }
21
+ catch {
22
+ // ignore, default to auto
23
+ }
24
+ const ymlContent = `# Generated by laxy-verify --init
25
+ # See https://github.com/psungmin24/laxy-verify for full docs
26
+ framework: ${detectedFramework} # auto-detected
27
+ port: ${detectedPort}
28
+ fail_on: bronze
29
+
30
+ thresholds:
31
+ performance: 70
32
+ accessibility: 85
33
+ seo: 80
34
+ best_practices: 80
35
+ `;
36
+ fs.writeFileSync(laxyYmlPath, ymlContent, "utf-8");
37
+ console.log("Created .laxy.yml");
38
+ }
39
+ // 2. Generate workflow file
40
+ if (fs.existsSync(workflowPath)) {
41
+ console.log(".github/workflows/laxy-verify.yml already exists — skipping.");
42
+ }
43
+ else {
44
+ fs.mkdirSync(workflowDir, { recursive: true });
45
+ const workflowContent = `name: Laxy Verify
46
+ on:
47
+ pull_request:
48
+ branches: [main, master]
49
+ push:
50
+ branches: [main, master]
51
+
52
+ permissions:
53
+ pull-requests: write
54
+ statuses: write
55
+
56
+ jobs:
57
+ verify:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - uses: psungmin24/laxy-verify@v1
62
+ with:
63
+ github-token: \${{ secrets.GITHUB_TOKEN }}
64
+ `;
65
+ fs.writeFileSync(workflowPath, workflowContent, "utf-8");
66
+ console.log("Created .github/workflows/laxy-verify.yml");
67
+ }
68
+ // 3. Append to .gitignore
69
+ const gitignoreEntries = [".lighthouseci/", ".laxy-result.json"];
70
+ if (fs.existsSync(gitignorePath)) {
71
+ const existing = fs.readFileSync(gitignorePath, "utf-8");
72
+ const lines = existing.split("\n").map(l => l.trim());
73
+ const missing = gitignoreEntries.filter(e => !lines.includes(e));
74
+ if (missing.length > 0) {
75
+ const append = missing.join("\n") + "\n";
76
+ fs.appendFileSync(gitignorePath, append, "utf-8");
77
+ console.log(`Appended to .gitignore: ${missing.join(", ")}`);
78
+ }
79
+ }
80
+ else {
81
+ fs.writeFileSync(gitignorePath, gitignoreEntries.join("\n") + "\n", "utf-8");
82
+ console.log("Created .gitignore");
83
+ }
84
+ console.log("\n Done! Run 'npx laxy-verify .' to verify your project.");
85
+ }
@@ -0,0 +1,7 @@
1
+ import type { LighthouseScores } from "./grade.js";
2
+ interface LhResult {
3
+ scores: LighthouseScores | null;
4
+ errors: string[];
5
+ }
6
+ export declare function runLighthouse(port: number, runs: number): Promise<LhResult>;
7
+ export {};
@@ -0,0 +1,94 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ const req = createRequire(import.meta.url);
6
+ function resolveLhciBin() {
7
+ try {
8
+ return req.resolve("@lhci/cli/src/cli.js");
9
+ }
10
+ catch {
11
+ throw new Error("@lhci/cli not found — make sure it is installed in laxy-verify's node_modules");
12
+ }
13
+ }
14
+ function median(values) {
15
+ const sorted = [...values].sort((a, b) => a - b);
16
+ const mid = Math.floor(sorted.length / 2);
17
+ return sorted.length % 2 !== 0
18
+ ? sorted[mid]
19
+ : (sorted[mid - 1] + sorted[mid]) / 2;
20
+ }
21
+ export async function runLighthouse(port, runs) {
22
+ const lhciBin = resolveLhciBin();
23
+ console.log(`\n Running Lighthouse (${runs} run${runs > 1 ? "s" : ""})…`);
24
+ const lhciDir = ".lighthouseci";
25
+ if (!fs.existsSync(lhciDir)) {
26
+ fs.mkdirSync(lhciDir, { recursive: true });
27
+ }
28
+ const child = spawn("node", [
29
+ lhciBin,
30
+ "collect",
31
+ `--url=http://localhost:${port}`,
32
+ `--numberOfRuns=${runs}`,
33
+ `--outputDir=${lhciDir}`,
34
+ ], { shell: false, stdio: ["ignore", "pipe", "pipe"] });
35
+ const errors = [];
36
+ child.stdout?.on("data", (chunk) => {
37
+ const lines = chunk.toString().split("\n").filter(Boolean);
38
+ for (const line of lines)
39
+ console.log(` [lhci] ${line}`);
40
+ });
41
+ child.stderr?.on("data", (chunk) => {
42
+ const lines = chunk.toString().split("\n").filter(Boolean);
43
+ errors.push(...lines);
44
+ for (const line of lines)
45
+ console.error(` [lhci] ${line}`);
46
+ });
47
+ const exitCode = await new Promise((resolve) => {
48
+ child.on("exit", (code) => resolve(code ?? 1));
49
+ });
50
+ if (exitCode !== 0) {
51
+ console.error(" Lighthouse exited with an error");
52
+ return { scores: null, errors };
53
+ }
54
+ // Extract median scores from .lighthouseci/lhr-*.json
55
+ try {
56
+ if (!fs.existsSync(lhciDir)) {
57
+ return { scores: null, errors: ["No .lighthouseci/ directory found"] };
58
+ }
59
+ const lhrFiles = fs
60
+ .readdirSync(lhciDir)
61
+ .filter((f) => f.startsWith("lhr-") && f.endsWith(".json"));
62
+ if (lhrFiles.length === 0) {
63
+ return { scores: null, errors: ["No lhr JSON files found in .lighthouseci/"] };
64
+ }
65
+ const allScores = lhrFiles.map((f) => {
66
+ const report = JSON.parse(fs.readFileSync(path.join(lhciDir, f), "utf8"));
67
+ return {
68
+ performance: (report.categories.performance?.score ?? 0) * 100,
69
+ accessibility: (report.categories.accessibility?.score ?? 0) * 100,
70
+ seo: (report.categories.seo?.score ?? 0) * 100,
71
+ bestPractices: (report.categories["best-practices"]?.score ?? 0) * 100,
72
+ };
73
+ });
74
+ const scores = {
75
+ performance: Math.round(median(allScores.map((s) => s.performance))),
76
+ accessibility: Math.round(median(allScores.map((s) => s.accessibility))),
77
+ seo: Math.round(median(allScores.map((s) => s.seo))),
78
+ bestPractices: Math.round(median(allScores.map((s) => s.bestPractices))),
79
+ };
80
+ console.log(` Lighthouse median: P=${scores.performance} A=${scores.accessibility} S=${scores.seo} BP=${scores.bestPractices}`);
81
+ return { scores, errors: [] };
82
+ }
83
+ finally {
84
+ // Cleanup .lighthouseci/
85
+ try {
86
+ if (fs.existsSync(lhciDir)) {
87
+ fs.rmSync(lhciDir, { recursive: true, force: true });
88
+ }
89
+ }
90
+ catch {
91
+ // ignore cleanup errors
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,12 @@
1
+ export declare class PortConflictError extends Error {
2
+ constructor(port: number);
3
+ }
4
+ export declare class DevServerTimeoutError extends Error {
5
+ constructor(port: number, timeoutSec: number);
6
+ }
7
+ export interface ServeResult {
8
+ pid: number;
9
+ port: number;
10
+ }
11
+ export declare function startDevServer(command: string, port: number, timeoutSec: number): Promise<ServeResult>;
12
+ export declare function stopDevServer(pid: number): void;