install-glo 2.0.1 → 2.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 2.0.2
4
+
5
+ - Refactor glo-loop monolith (675 LOC) into 6 focused modules under `lib/`
6
+ - `vitals.mjs` — Web Vitals constants and thresholds
7
+ - `lighthouse.mjs` — Lighthouse runner, metric/diagnostic extraction
8
+ - `source-discovery.mjs` — Page file and component import discovery
9
+ - `ai-analysis.mjs` — AI prompt construction and analysis
10
+ - `model.mjs` — Provider selection (Anthropic/OpenAI)
11
+ - `display.mjs` — Terminal output formatting
12
+ - Add 36 unit tests using `node:test` with dependency injection
13
+ - Fix duplicate file discovery bug for root route candidates
14
+ - Add `npm test` script
15
+
16
+ ## 2.0.1
17
+
18
+ - Add missing `zod` dependency
19
+
20
+ ## 2.0.0
21
+
22
+ - Rewrite as GLO Loop — AI-powered web vitals optimization engine
23
+ - Built on Vercel AI SDK with Anthropic and OpenAI support
24
+ - Interactive Lighthouse → AI analysis → fix loop
25
+ - Source file discovery for Next.js App/Pages Router
26
+ - Support for LCP, FCP, CLS, TBT, SI, TTFB targets
27
+
28
+ ## 1.0.0
29
+
30
+ - Initial release
@@ -0,0 +1,108 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { analyzeWithAI, buildPrompt } from "../lib/ai-analysis.mjs";
4
+
5
+ describe("buildPrompt", () => {
6
+ const baseMetrics = {
7
+ LCP: { value: 3200, display: "3.2 s", score: 0 },
8
+ FCP: { value: 1500, display: "1.5 s", score: 1 },
9
+ performanceScore: 72,
10
+ };
11
+
12
+ it("includes target vital and current value", () => {
13
+ const prompt = buildPrompt("LCP", baseMetrics, [], [], 1, []);
14
+ assert.ok(prompt.includes("LCP"));
15
+ assert.ok(prompt.includes("3200ms"));
16
+ assert.ok(prompt.includes("Largest Contentful Paint"));
17
+ });
18
+
19
+ it("includes performance score", () => {
20
+ const prompt = buildPrompt("LCP", baseMetrics, [], [], 1, []);
21
+ assert.ok(prompt.includes("72/100"));
22
+ });
23
+
24
+ it("includes diagnostics", () => {
25
+ const diags = [
26
+ { title: "Remove unused JS", displayValue: "200 KiB", score: 0.3 },
27
+ ];
28
+ const prompt = buildPrompt("LCP", baseMetrics, diags, [], 1, []);
29
+ assert.ok(prompt.includes("Remove unused JS"));
30
+ assert.ok(prompt.includes("200 KiB"));
31
+ });
32
+
33
+ it("includes source file contents", () => {
34
+ const files = [{ path: "app/page.tsx", content: "<Hero />" }];
35
+ const prompt = buildPrompt("LCP", baseMetrics, [], files, 1, []);
36
+ assert.ok(prompt.includes("app/page.tsx"));
37
+ assert.ok(prompt.includes("<Hero />"));
38
+ });
39
+
40
+ it("includes previous suggestions context", () => {
41
+ const prev = ["DIAGNOSIS: images not optimized"];
42
+ const prompt = buildPrompt("LCP", baseMetrics, [], [], 2, prev);
43
+ assert.ok(prompt.includes("Previous suggestions already applied"));
44
+ assert.ok(prompt.includes("images not optimized"));
45
+ assert.ok(prompt.includes("Do NOT repeat"));
46
+ });
47
+
48
+ it("handles CLS (unitless) correctly", () => {
49
+ const metrics = {
50
+ CLS: { value: 0.25, display: "0.25", score: 0 },
51
+ performanceScore: 60,
52
+ };
53
+ const prompt = buildPrompt("CLS", metrics, [], [], 1, []);
54
+ assert.ok(prompt.includes("0.250"));
55
+ assert.ok(prompt.includes("<0.1"));
56
+ });
57
+
58
+ it("handles missing target vital value gracefully", () => {
59
+ const metrics = { performanceScore: 50 };
60
+ const prompt = buildPrompt("LCP", metrics, [], [], 1, []);
61
+ assert.ok(prompt.includes("unknown"));
62
+ });
63
+ });
64
+
65
+ describe("analyzeWithAI", () => {
66
+ it("calls generateText with model and prompt", async () => {
67
+ let capturedArgs;
68
+ const fakeGenerateText = async (args) => {
69
+ capturedArgs = args;
70
+ return { text: "DIAGNOSIS: test response" };
71
+ };
72
+
73
+ const fakeModel = { id: "fake-model" };
74
+ const metrics = {
75
+ LCP: { value: 3200, display: "3.2 s", score: 0 },
76
+ performanceScore: 72,
77
+ };
78
+
79
+ const result = await analyzeWithAI(
80
+ fakeModel,
81
+ "LCP",
82
+ metrics,
83
+ [],
84
+ [],
85
+ 1,
86
+ [],
87
+ { generateText: fakeGenerateText }
88
+ );
89
+
90
+ assert.equal(result, "DIAGNOSIS: test response");
91
+ assert.equal(capturedArgs.model, fakeModel);
92
+ assert.ok(capturedArgs.prompt.includes("LCP"));
93
+ });
94
+
95
+ it("propagates errors from generateText", async () => {
96
+ const fakeGenerateText = async () => {
97
+ throw new Error("API down");
98
+ };
99
+
100
+ await assert.rejects(
101
+ () =>
102
+ analyzeWithAI({}, "LCP", { performanceScore: 0 }, [], [], 1, [], {
103
+ generateText: fakeGenerateText,
104
+ }),
105
+ { message: "API down" }
106
+ );
107
+ });
108
+ });
@@ -0,0 +1,192 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ checkLighthouse,
5
+ runLighthouse,
6
+ extractMetrics,
7
+ extractDiagnostics,
8
+ } from "../lib/lighthouse.mjs";
9
+
10
+ // ── Fixtures ──────────────────────────────────────────────────────────
11
+
12
+ function makeLighthouseReport({
13
+ perfScore = 0.72,
14
+ lcp = 3200,
15
+ fcp = 1500,
16
+ cls = 0.05,
17
+ tbt = 300,
18
+ si = 4000,
19
+ ttfb = 600,
20
+ diagnostics = {},
21
+ } = {}) {
22
+ return {
23
+ categories: { performance: { score: perfScore } },
24
+ audits: {
25
+ "largest-contentful-paint": {
26
+ numericValue: lcp,
27
+ displayValue: `${(lcp / 1000).toFixed(1)} s`,
28
+ score: lcp <= 2500 ? 1 : 0,
29
+ },
30
+ "first-contentful-paint": {
31
+ numericValue: fcp,
32
+ displayValue: `${(fcp / 1000).toFixed(1)} s`,
33
+ score: fcp <= 1800 ? 1 : 0,
34
+ },
35
+ "cumulative-layout-shift": {
36
+ numericValue: cls,
37
+ displayValue: cls.toFixed(3),
38
+ score: cls <= 0.1 ? 1 : 0,
39
+ },
40
+ "total-blocking-time": {
41
+ numericValue: tbt,
42
+ displayValue: `${tbt} ms`,
43
+ score: tbt <= 200 ? 1 : 0,
44
+ },
45
+ "speed-index": {
46
+ numericValue: si,
47
+ displayValue: `${(si / 1000).toFixed(1)} s`,
48
+ score: si <= 3400 ? 1 : 0,
49
+ },
50
+ "server-response-time": {
51
+ numericValue: ttfb,
52
+ displayValue: `${ttfb} ms`,
53
+ score: ttfb <= 800 ? 1 : 0,
54
+ },
55
+ ...diagnostics,
56
+ },
57
+ };
58
+ }
59
+
60
+ // ── checkLighthouse ───────────────────────────────────────────────────
61
+
62
+ describe("checkLighthouse", () => {
63
+ it("returns true when lighthouse is available", () => {
64
+ const execSync = () => "10.0.0";
65
+ assert.equal(checkLighthouse({ execSync }), true);
66
+ });
67
+
68
+ it("returns false when lighthouse throws", () => {
69
+ const execSync = () => {
70
+ throw new Error("not found");
71
+ };
72
+ assert.equal(checkLighthouse({ execSync }), false);
73
+ });
74
+ });
75
+
76
+ // ── runLighthouse ─────────────────────────────────────────────────────
77
+
78
+ describe("runLighthouse", () => {
79
+ it("parses JSON output from execSync", () => {
80
+ const fakeReport = { categories: {}, audits: {} };
81
+ const execSync = () => Buffer.from(JSON.stringify(fakeReport));
82
+ const result = runLighthouse("http://localhost:3000", { execSync });
83
+ assert.deepStrictEqual(result, fakeReport);
84
+ });
85
+
86
+ it("passes the URL into the command", () => {
87
+ let capturedCmd;
88
+ const execSync = (cmd) => {
89
+ capturedCmd = cmd;
90
+ return Buffer.from("{}");
91
+ };
92
+ runLighthouse("http://example.com", { execSync });
93
+ assert.ok(capturedCmd.includes('"http://example.com"'));
94
+ });
95
+
96
+ it("throws on invalid JSON", () => {
97
+ const execSync = () => Buffer.from("not json");
98
+ assert.throws(() => runLighthouse("http://localhost:3000", { execSync }));
99
+ });
100
+ });
101
+
102
+ // ── extractMetrics ────────────────────────────────────────────────────
103
+
104
+ describe("extractMetrics", () => {
105
+ it("extracts all six vitals from a report", () => {
106
+ const report = makeLighthouseReport();
107
+ const metrics = extractMetrics(report);
108
+
109
+ assert.equal(metrics.LCP.value, 3200);
110
+ assert.equal(metrics.FCP.value, 1500);
111
+ assert.equal(metrics.CLS.value, 0.05);
112
+ assert.equal(metrics.TBT.value, 300);
113
+ assert.equal(metrics.SI.value, 4000);
114
+ assert.equal(metrics.TTFB.value, 600);
115
+ });
116
+
117
+ it("computes performanceScore as percentage", () => {
118
+ const report = makeLighthouseReport({ perfScore: 0.85 });
119
+ const metrics = extractMetrics(report);
120
+ assert.equal(metrics.performanceScore, 85);
121
+ });
122
+
123
+ it("defaults performanceScore to 0 when missing", () => {
124
+ const report = { audits: {}, categories: {} };
125
+ const metrics = extractMetrics(report);
126
+ assert.equal(metrics.performanceScore, 0);
127
+ });
128
+
129
+ it("skips audits that are missing", () => {
130
+ const report = { audits: {}, categories: { performance: { score: 0.5 } } };
131
+ const metrics = extractMetrics(report);
132
+ assert.equal(metrics.LCP, undefined);
133
+ assert.equal(metrics.performanceScore, 50);
134
+ });
135
+ });
136
+
137
+ // ── extractDiagnostics ────────────────────────────────────────────────
138
+
139
+ describe("extractDiagnostics", () => {
140
+ it("returns failing audits sorted worst-first", () => {
141
+ const report = makeLighthouseReport({
142
+ diagnostics: {
143
+ "unused-javascript": {
144
+ title: "Remove unused JavaScript",
145
+ displayValue: "200 KiB",
146
+ score: 0.3,
147
+ },
148
+ "render-blocking-resources": {
149
+ title: "Eliminate render-blocking resources",
150
+ displayValue: "1,200 ms",
151
+ score: 0.1,
152
+ },
153
+ },
154
+ });
155
+ const diags = extractDiagnostics(report);
156
+ assert.ok(diags.length >= 2);
157
+ assert.equal(diags[0].id, "render-blocking-resources");
158
+ assert.equal(diags[1].id, "unused-javascript");
159
+ });
160
+
161
+ it("excludes audits with score === 1 (passing)", () => {
162
+ const report = makeLighthouseReport({
163
+ diagnostics: {
164
+ "dom-size": { title: "DOM size", score: 1, displayValue: "" },
165
+ },
166
+ });
167
+ const diags = extractDiagnostics(report);
168
+ const domSize = diags.find((d) => d.id === "dom-size");
169
+ assert.equal(domSize, undefined);
170
+ });
171
+
172
+ it("excludes audits with score === null", () => {
173
+ const report = makeLighthouseReport({
174
+ diagnostics: {
175
+ "font-display": {
176
+ title: "Font display",
177
+ score: null,
178
+ displayValue: "",
179
+ },
180
+ },
181
+ });
182
+ const diags = extractDiagnostics(report);
183
+ const fontDisplay = diags.find((d) => d.id === "font-display");
184
+ assert.equal(fontDisplay, undefined);
185
+ });
186
+
187
+ it("returns empty array for clean report", () => {
188
+ const report = { audits: {} };
189
+ const diags = extractDiagnostics(report);
190
+ assert.deepStrictEqual(diags, []);
191
+ });
192
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getModel } from "../lib/model.mjs";
4
+
5
+ describe("getModel", () => {
6
+ it("returns Anthropic model when ANTHROPIC_API_KEY is set", () => {
7
+ const fakeModel = { id: "claude" };
8
+ const createAnthropic = () => () => fakeModel;
9
+ const createOpenAI = () => () => ({ id: "gpt" });
10
+
11
+ const result = getModel({
12
+ env: { ANTHROPIC_API_KEY: "sk-ant-test" },
13
+ createAnthropic,
14
+ createOpenAI,
15
+ });
16
+
17
+ assert.equal(result.model, fakeModel);
18
+ assert.equal(result.label, "Claude (Anthropic)");
19
+ });
20
+
21
+ it("falls back to OpenAI when only OPENAI_API_KEY is set", () => {
22
+ const fakeModel = { id: "gpt" };
23
+ const createAnthropic = () => () => ({ id: "claude" });
24
+ const createOpenAI = () => () => fakeModel;
25
+
26
+ const result = getModel({
27
+ env: { OPENAI_API_KEY: "sk-test" },
28
+ createAnthropic,
29
+ createOpenAI,
30
+ });
31
+
32
+ assert.equal(result.model, fakeModel);
33
+ assert.equal(result.label, "GPT-4o-mini (OpenAI)");
34
+ });
35
+
36
+ it("prefers Anthropic when both keys are set", () => {
37
+ const claudeModel = { id: "claude" };
38
+ const createAnthropic = () => () => claudeModel;
39
+ const createOpenAI = () => () => ({ id: "gpt" });
40
+
41
+ const result = getModel({
42
+ env: { ANTHROPIC_API_KEY: "sk-ant-test", OPENAI_API_KEY: "sk-test" },
43
+ createAnthropic,
44
+ createOpenAI,
45
+ });
46
+
47
+ assert.equal(result.model, claudeModel);
48
+ });
49
+
50
+ it("returns null when no API keys are set", () => {
51
+ const result = getModel({
52
+ env: {},
53
+ createAnthropic: () => () => ({}),
54
+ createOpenAI: () => () => ({}),
55
+ });
56
+
57
+ assert.equal(result, null);
58
+ });
59
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { discoverPageFiles } from "../lib/source-discovery.mjs";
4
+
5
+ function makeFs(fileMap) {
6
+ return {
7
+ existsSync: (path) => path in fileMap,
8
+ readFileSync: (path) => {
9
+ if (!(path in fileMap)) throw new Error(`ENOENT: ${path}`);
10
+ return fileMap[path];
11
+ },
12
+ };
13
+ }
14
+
15
+ describe("discoverPageFiles", () => {
16
+ it("finds App Router page.tsx for root route", () => {
17
+ const fs = makeFs({
18
+ "/project/app/page.tsx": "export default function Home() {}",
19
+ "/project/app/layout.tsx": "<html>{children}</html>",
20
+ });
21
+ const files = discoverPageFiles("/project", "/", fs);
22
+ assert.ok(files.length >= 2);
23
+ assert.equal(files[0].path, "app/page.tsx");
24
+ });
25
+
26
+ it("finds Pages Router file for named route", () => {
27
+ const fs = makeFs({
28
+ "/project/pages/about.tsx": "export default function About() {}",
29
+ });
30
+ const files = discoverPageFiles("/project", "/about", fs);
31
+ assert.equal(files.length, 1);
32
+ assert.equal(files[0].path, "pages/about.tsx");
33
+ });
34
+
35
+ it("returns empty array when no files match", () => {
36
+ const fs = makeFs({});
37
+ const files = discoverPageFiles("/project", "/", fs);
38
+ assert.deepStrictEqual(files, []);
39
+ });
40
+
41
+ it("truncates large files", () => {
42
+ const bigContent = "x".repeat(20_000);
43
+ const fs = makeFs({
44
+ "/project/app/page.tsx": bigContent,
45
+ });
46
+ const files = discoverPageFiles("/project", "/", fs);
47
+ assert.ok(files[0].content.length < bigContent.length);
48
+ assert.ok(files[0].content.endsWith("// ... truncated"));
49
+ });
50
+
51
+ it("resolves relative imports from page file", () => {
52
+ const fs = makeFs({
53
+ "/project/app/page.tsx": 'import Hero from "./components/Hero";\nexport default function Home() {}',
54
+ "/project/app/components/Hero.tsx": "export default function Hero() {}",
55
+ });
56
+ const files = discoverPageFiles("/project", "/", fs);
57
+ const hero = files.find((f) => f.path.includes("Hero"));
58
+ assert.ok(hero, "should discover imported Hero component");
59
+ });
60
+
61
+ it("does not duplicate already-discovered files", () => {
62
+ const fs = makeFs({
63
+ "/project/app/page.tsx": 'import Layout from "./layout";\nexport default function Home() {}',
64
+ "/project/app/layout.tsx": "export default function Layout() {}",
65
+ });
66
+ const files = discoverPageFiles("/project", "/", fs);
67
+ const layouts = files.filter((f) => f.path === "app/layout.tsx");
68
+ assert.equal(layouts.length, 1);
69
+ });
70
+
71
+ it("discovers next.config.js when present", () => {
72
+ const fs = makeFs({
73
+ "/project/next.config.js": "module.exports = { images: {} }",
74
+ });
75
+ const files = discoverPageFiles("/project", "/", fs);
76
+ assert.ok(files.find((f) => f.path === "next.config.js"));
77
+ });
78
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { VITALS } from "../lib/vitals.mjs";
4
+
5
+ describe("VITALS", () => {
6
+ it("has all six core web vitals", () => {
7
+ const keys = Object.keys(VITALS);
8
+ assert.deepStrictEqual(keys, ["LCP", "FCP", "CLS", "TBT", "SI", "TTFB"]);
9
+ });
10
+
11
+ it("each vital has required fields", () => {
12
+ for (const [key, info] of Object.entries(VITALS)) {
13
+ assert.ok(typeof info.good === "number", `${key}.good should be a number`);
14
+ assert.ok(typeof info.unit === "string", `${key}.unit should be a string`);
15
+ assert.ok(typeof info.name === "string", `${key}.name should be a string`);
16
+ assert.ok(typeof info.audit === "string", `${key}.audit should be a string`);
17
+ }
18
+ });
19
+
20
+ it("CLS threshold is sub-1 (unitless)", () => {
21
+ assert.equal(VITALS.CLS.unit, "");
22
+ assert.ok(VITALS.CLS.good < 1);
23
+ });
24
+ });