| 1 | /** |
| 2 | * Tests for check-facts.mjs via its re-importable logic. |
| 3 | * |
| 4 | * We test the diffFacts helper (ported from check-facts.mjs) against |
| 5 | * fixture-like objects to prove the stale-version detection works. |
| 6 | * End-to-end `node scripts/check-facts.mjs` exit-code tests are not run |
| 7 | * from vitest since they depend on the actual workspace file tree. |
| 8 | */ |
| 9 | import { describe, it, expect } from "vitest"; |
| 10 | |
| 11 | // --- inline diffFacts (same logic as check-facts.mjs) ---------------- |
| 12 | |
| 13 | interface ProviderFact { |
| 14 | id: string; |
| 15 | label: string; |
| 16 | env: string; |
| 17 | } |
| 18 | |
| 19 | interface PublishedReleaseFact { |
| 20 | tag: string; |
| 21 | version: string; |
| 22 | publishedAt: string; |
| 23 | url: string; |
| 24 | } |
| 25 | |
| 26 | interface ModelFact { |
| 27 | id: string; |
| 28 | provider: string | null; |
| 29 | contextWindow: number | null; |
| 30 | maxOutput: number | null; |
| 31 | reasoning: boolean; |
| 32 | addedAt: string | null; |
| 33 | } |
| 34 | |
| 35 | interface RepoFacts { |
| 36 | [key: string]: unknown; |
| 37 | generatedAt: string; |
| 38 | sourceRevision: string | null; |
| 39 | sourceCommittedAt: string | null; |
| 40 | version: string | null; |
| 41 | crates: string[]; |
| 42 | sandboxBackends: string[]; |
| 43 | providers: ProviderFact[]; |
| 44 | models: ModelFact[]; |
| 45 | defaultModel: string | null; |
| 46 | nodeEngines: string | null; |
| 47 | toolCount: number | null; |
| 48 | license: string | null; |
| 49 | latestPublishedRelease: PublishedReleaseFact | null; |
| 50 | } |
| 51 | |
| 52 | function diffFacts( |
| 53 | committed: Record<string, unknown>, |
| 54 | fresh: Record<string, unknown>, |
| 55 | ): Array<{ field: string; committed: unknown; fresh: unknown }> { |
| 56 | const checkFields = [ |
| 57 | "version", |
| 58 | "crates", |
| 59 | "sandboxBackends", |
| 60 | "providers", |
| 61 | "models", |
| 62 | "defaultModel", |
| 63 | "nodeEngines", |
| 64 | "toolCount", |
| 65 | "license", |
| 66 | "latestPublishedRelease", |
| 67 | ]; |
| 68 | const diffs: Array<{ field: string; committed: unknown; fresh: unknown }> = []; |
| 69 | for (const field of checkFields) { |
| 70 | const a = JSON.stringify(committed[field] ?? null); |
| 71 | const b = JSON.stringify(fresh[field] ?? null); |
| 72 | if (a !== b) { |
| 73 | diffs.push({ field, committed: committed[field], fresh: fresh[field] }); |
| 74 | } |
| 75 | } |
| 76 | return diffs; |
| 77 | } |
| 78 | |
| 79 | // --- helpers --------------------------------------------------------- |
| 80 | |
| 81 | function freshFacts(overrides: Partial<RepoFacts> = {}): RepoFacts { |
| 82 | return { |
| 83 | generatedAt: new Date().toISOString(), |
| 84 | sourceRevision: null, |
| 85 | sourceCommittedAt: null, |
| 86 | version: "0.8.64", |
| 87 | crates: ["cli", "config", "tui"], |
| 88 | sandboxBackends: [ |
| 89 | "seatbelt (macOS, when available)", |
| 90 | "bubblewrap (Linux, opt-in when installed)", |
| 91 | ], |
| 92 | providers: [ |
| 93 | { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 94 | { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 95 | ], |
| 96 | models: [ |
| 97 | { |
| 98 | id: "deepseek-v4-pro", |
| 99 | provider: "DeepSeek", |
| 100 | contextWindow: 1000000, |
| 101 | maxOutput: 128000, |
| 102 | reasoning: true, |
| 103 | addedAt: "2026-07-01", |
| 104 | }, |
| 105 | ], |
| 106 | defaultModel: "deepseek-v4-pro", |
| 107 | nodeEngines: ">=18", |
| 108 | toolCount: 78, |
| 109 | license: "MIT", |
| 110 | latestPublishedRelease: { |
| 111 | tag: "v0.8.63", |
| 112 | version: "0.8.63", |
| 113 | publishedAt: "2026-06-01T00:00:00Z", |
| 114 | url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.63", |
| 115 | }, |
| 116 | ...overrides, |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | // --- tests ----------------------------------------------------------- |
| 121 | |
| 122 | describe("diffFacts (check-facts parity)", () => { |
| 123 | it("returns empty array when facts match", () => { |
| 124 | const committed = freshFacts(); |
| 125 | const fresh = freshFacts(); |
| 126 | expect(diffFacts(committed, fresh)).toEqual([]); |
| 127 | }); |
| 128 | |
| 129 | it("detects stale version (0.8.62 vs 0.8.64)", () => { |
| 130 | const committed = freshFacts({ version: "0.8.62" }); |
| 131 | const fresh = freshFacts({ version: "0.8.64" }); |
| 132 | const diffs = diffFacts(committed, fresh); |
| 133 | expect(diffs).toHaveLength(1); |
| 134 | expect(diffs[0]).toEqual({ |
| 135 | field: "version", |
| 136 | committed: "0.8.62", |
| 137 | fresh: "0.8.64", |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | it("detects provider list drift (added Anthropic)", () => { |
| 142 | const committed = freshFacts({ |
| 143 | providers: [{ id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }], |
| 144 | }); |
| 145 | const fresh = freshFacts({ |
| 146 | providers: [ |
| 147 | { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 148 | { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 149 | ], |
| 150 | }); |
| 151 | const diffs = diffFacts(committed, fresh); |
| 152 | expect(diffs).toHaveLength(1); |
| 153 | expect(diffs[0].field).toBe("providers"); |
| 154 | }); |
| 155 | |
| 156 | it("detects stale default model", () => { |
| 157 | const committed = freshFacts({ defaultModel: "deepseek-v3" }); |
| 158 | const fresh = freshFacts({ defaultModel: "deepseek-v4-pro" }); |
| 159 | const diffs = diffFacts(committed, fresh); |
| 160 | expect(diffs).toHaveLength(1); |
| 161 | expect(diffs[0].field).toBe("defaultModel"); |
| 162 | }); |
| 163 | |
| 164 | it("detects tool count drift", () => { |
| 165 | const committed = freshFacts({ toolCount: 70 }); |
| 166 | const fresh = freshFacts({ toolCount: 78 }); |
| 167 | const diffs = diffFacts(committed, fresh); |
| 168 | expect(diffs).toHaveLength(1); |
| 169 | expect(diffs[0].field).toBe("toolCount"); |
| 170 | }); |
| 171 | |
| 172 | it("detects multiple field drifts at once", () => { |
| 173 | const committed = freshFacts({ |
| 174 | version: "0.8.62", |
| 175 | toolCount: 70, |
| 176 | }); |
| 177 | const fresh = freshFacts({ |
| 178 | version: "0.8.64", |
| 179 | toolCount: 78, |
| 180 | }); |
| 181 | const diffs = diffFacts(committed, fresh); |
| 182 | expect(diffs).toHaveLength(2); |
| 183 | expect(diffs.map((d) => d.field).sort()).toEqual(["toolCount", "version"]); |
| 184 | }); |
| 185 | |
| 186 | it("ignores generatedAt and exact-build provenance changes", () => { |
| 187 | const committed = freshFacts({ generatedAt: "old" }); |
| 188 | const fresh = freshFacts({ |
| 189 | generatedAt: "new", |
| 190 | sourceRevision: "a".repeat(40), |
| 191 | sourceCommittedAt: "2026-07-21T22:00:00Z", |
| 192 | }); |
| 193 | expect(diffFacts(committed, fresh)).toEqual([]); |
| 194 | }); |
| 195 | |
| 196 | it("detects latest-published-release drift", () => { |
| 197 | const committed = freshFacts(); |
| 198 | const fresh = freshFacts({ |
| 199 | latestPublishedRelease: { |
| 200 | tag: "v0.8.64", |
| 201 | version: "0.8.64", |
| 202 | publishedAt: "2026-06-02T00:00:00Z", |
| 203 | url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.64", |
| 204 | }, |
| 205 | }); |
| 206 | const diffs = diffFacts(committed, fresh); |
| 207 | expect(diffs).toHaveLength(1); |
| 208 | expect(diffs[0].field).toBe("latestPublishedRelease"); |
| 209 | }); |
| 210 | |
| 211 | it("handles null-to-value drift for license", () => { |
| 212 | const committed = freshFacts({ license: null }); |
| 213 | const fresh = freshFacts({ license: "MIT" }); |
| 214 | const diffs = diffFacts(committed, fresh); |
| 215 | expect(diffs).toHaveLength(1); |
| 216 | expect(diffs[0].field).toBe("license"); |
| 217 | }); |
| 218 | |
| 219 | it("handles empty arrays in committed vs populated arrays in fresh", () => { |
| 220 | const committed = freshFacts({ crates: [], providers: [] }); |
| 221 | const fresh = freshFacts({ |
| 222 | crates: ["cli"], |
| 223 | providers: [{ id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }], |
| 224 | }); |
| 225 | const diffs = diffFacts(committed, fresh); |
| 226 | expect(diffs).toHaveLength(2); |
| 227 | expect(diffs.map((d) => d.field).sort()).toEqual(["crates", "providers"]); |
| 228 | }); |
| 229 | }); |
| 230 |