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.
- package/README.md +150 -163
- package/dist/badge.d.ts +1 -0
- package/dist/badge.js +11 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +59 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +257 -102
- package/dist/comment.d.ts +20 -0
- package/dist/comment.js +53 -0
- package/dist/config.d.ts +34 -16
- package/dist/config.js +118 -55
- package/dist/detect.d.ts +10 -0
- package/dist/detect.js +97 -0
- package/dist/github.d.ts +8 -0
- package/dist/github.js +11 -0
- package/dist/grade.d.ts +37 -0
- package/dist/grade.js +57 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +85 -0
- package/dist/lighthouse.d.ts +7 -0
- package/dist/lighthouse.js +94 -0
- package/dist/serve.d.ts +12 -0
- package/dist/serve.js +83 -0
- package/dist/status.d.ts +6 -0
- package/dist/status.js +33 -0
- package/package.json +29 -44
- package/action.yml +0 -97
- package/dist/build-runner.d.ts +0 -22
- package/dist/build-runner.js +0 -156
- package/dist/build-runner.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/dev-server.d.ts +0 -8
- package/dist/dev-server.js +0 -81
- package/dist/dev-server.js.map +0 -1
- package/dist/lighthouse-runner.d.ts +0 -10
- package/dist/lighthouse-runner.js +0 -119
- package/dist/lighthouse-runner.js.map +0 -1
- package/dist/project-runtime.d.ts +0 -32
- package/dist/project-runtime.js +0 -99
- package/dist/project-runtime.js.map +0 -1
- package/dist/reporter.d.ts +0 -21
- package/dist/reporter.js +0 -100
- package/dist/reporter.js.map +0 -1
- package/dist/verification.d.ts +0 -42
- package/dist/verification.js +0 -105
- package/dist/verification.js.map +0 -1
package/dist/config.js
CHANGED
|
@@ -1,55 +1,118 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
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
|
+
}
|
package/dist/detect.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/github.d.ts
ADDED
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
|
+
}
|
package/dist/grade.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/serve.d.ts
ADDED
|
@@ -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;
|