| 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 RepoFacts { |
| 27 | [key: string]: unknown; |
| 28 | generatedAt: string; |
| 29 | sourceRevision: string | null; |
| 30 | sourceCommittedAt: string | null; |
| 31 | version: string | null; |
| 32 | crates: string[]; |
| 33 | sandboxBackends: string[]; |
| 34 | providers: ProviderFact[]; |
| 35 | defaultModel: string | null; |
| 36 | nodeEngines: string | null; |
| 37 | toolCount: number | null; |
| 38 | license: string | null; |
| 39 | latestPublishedRelease: PublishedReleaseFact | null; |
| 40 | } |
| 41 | |
| 42 | function diffFacts( |
| 43 | committed: Record<string, unknown>, |
| 44 | fresh: Record<string, unknown>, |
| 45 | ): Array<{ field: string; committed: unknown; fresh: unknown }> { |
| 46 | const checkFields = [ |
| 47 | "version", |
| 48 | "crates", |
| 49 | "sandboxBackends", |
| 50 | "providers", |
| 51 | "defaultModel", |
| 52 | "nodeEngines", |
| 53 | "toolCount", |
| 54 | "license", |
| 55 | "latestPublishedRelease", |
| 56 | ]; |
| 57 | const diffs: Array<{ field: string; committed: unknown; fresh: unknown }> = []; |
| 58 | for (const field of checkFields) { |
| 59 | const a = JSON.stringify(committed[field] ?? null); |
| 60 | const b = JSON.stringify(fresh[field] ?? null); |
| 61 | if (a !== b) { |
| 62 | diffs.push({ field, committed: committed[field], fresh: fresh[field] }); |
| 63 | } |
| 64 | } |
| 65 | return diffs; |
| 66 | } |
| 67 | |
| 68 | // --- helpers --------------------------------------------------------- |
| 69 | |
| 70 | function freshFacts(overrides: Partial<RepoFacts> = {}): RepoFacts { |
| 71 | return { |
| 72 | generatedAt: new Date().toISOString(), |
| 73 | sourceRevision: null, |
| 74 | sourceCommittedAt: null, |
| 75 | version: "0.8.64", |
| 76 | crates: ["cli", "config", "tui"], |
| 77 | sandboxBackends: [ |
| 78 | "seatbelt (macOS, when available)", |
| 79 | "bubblewrap (Linux, opt-in when installed)", |
| 80 | ], |
| 81 | providers: [ |
| 82 | { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 83 | { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 84 | ], |
| 85 | defaultModel: "deepseek-v4-pro", |
| 86 | nodeEngines: ">=18", |
| 87 | toolCount: 78, |
| 88 | license: "MIT", |
| 89 | latestPublishedRelease: { |
| 90 | tag: "v0.8.63", |
| 91 | version: "0.8.63", |
| 92 | publishedAt: "2026-06-01T00:00:00Z", |
| 93 | url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.63", |
| 94 | }, |
| 95 | ...overrides, |
| 96 | }; |
| 97 | } |
| 98 | |
| 99 | // --- tests ----------------------------------------------------------- |
| 100 | |
| 101 | describe("diffFacts (check-facts parity)", () => { |
| 102 | it("returns empty array when facts match", () => { |
| 103 | const committed = freshFacts(); |
| 104 | const fresh = freshFacts(); |
| 105 | expect(diffFacts(committed, fresh)).toEqual([]); |
| 106 | }); |
| 107 | |
| 108 | it("detects stale version (0.8.62 vs 0.8.64)", () => { |
| 109 | const committed = freshFacts({ version: "0.8.62" }); |
| 110 | const fresh = freshFacts({ version: "0.8.64" }); |
| 111 | const diffs = diffFacts(committed, fresh); |
| 112 | expect(diffs).toHaveLength(1); |
| 113 | expect(diffs[0]).toEqual({ |
| 114 | field: "version", |
| 115 | committed: "0.8.62", |
| 116 | fresh: "0.8.64", |
| 117 | }); |
| 118 | }); |
| 119 | |
| 120 | it("detects provider list drift (added Anthropic)", () => { |
| 121 | const committed = freshFacts({ |
| 122 | providers: [{ id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }], |
| 123 | }); |
| 124 | const fresh = freshFacts({ |
| 125 | providers: [ |
| 126 | { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 127 | { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 128 | ], |
| 129 | }); |
| 130 | const diffs = diffFacts(committed, fresh); |
| 131 | expect(diffs).toHaveLength(1); |
| 132 | expect(diffs[0].field).toBe("providers"); |
| 133 | }); |
| 134 | |
| 135 | it("detects stale default model", () => { |
| 136 | const committed = freshFacts({ defaultModel: "deepseek-v3" }); |
| 137 | const fresh = freshFacts({ defaultModel: "deepseek-v4-pro" }); |
| 138 | const diffs = diffFacts(committed, fresh); |
| 139 | expect(diffs).toHaveLength(1); |
| 140 | expect(diffs[0].field).toBe("defaultModel"); |
| 141 | }); |
| 142 | |
| 143 | it("detects tool count drift", () => { |
| 144 | const committed = freshFacts({ toolCount: 70 }); |
| 145 | const fresh = freshFacts({ toolCount: 78 }); |
| 146 | const diffs = diffFacts(committed, fresh); |
| 147 | expect(diffs).toHaveLength(1); |
| 148 | expect(diffs[0].field).toBe("toolCount"); |
| 149 | }); |
| 150 | |
| 151 | it("detects multiple field drifts at once", () => { |
| 152 | const committed = freshFacts({ |
| 153 | version: "0.8.62", |
| 154 | toolCount: 70, |
| 155 | }); |
| 156 | const fresh = freshFacts({ |
| 157 | version: "0.8.64", |
| 158 | toolCount: 78, |
| 159 | }); |
| 160 | const diffs = diffFacts(committed, fresh); |
| 161 | expect(diffs).toHaveLength(2); |
| 162 | expect(diffs.map((d) => d.field).sort()).toEqual(["toolCount", "version"]); |
| 163 | }); |
| 164 | |
| 165 | it("ignores generatedAt and exact-build provenance changes", () => { |
| 166 | const committed = freshFacts({ generatedAt: "old" }); |
| 167 | const fresh = freshFacts({ |
| 168 | generatedAt: "new", |
| 169 | sourceRevision: "a".repeat(40), |
| 170 | sourceCommittedAt: "2026-07-21T22:00:00Z", |
| 171 | }); |
| 172 | expect(diffFacts(committed, fresh)).toEqual([]); |
| 173 | }); |
| 174 | |
| 175 | it("detects latest-published-release drift", () => { |
| 176 | const committed = freshFacts(); |
| 177 | const fresh = freshFacts({ |
| 178 | latestPublishedRelease: { |
| 179 | tag: "v0.8.64", |
| 180 | version: "0.8.64", |
| 181 | publishedAt: "2026-06-02T00:00:00Z", |
| 182 | url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.64", |
| 183 | }, |
| 184 | }); |
| 185 | const diffs = diffFacts(committed, fresh); |
| 186 | expect(diffs).toHaveLength(1); |
| 187 | expect(diffs[0].field).toBe("latestPublishedRelease"); |
| 188 | }); |
| 189 | |
| 190 | it("handles null-to-value drift for license", () => { |
| 191 | const committed = freshFacts({ license: null }); |
| 192 | const fresh = freshFacts({ license: "MIT" }); |
| 193 | const diffs = diffFacts(committed, fresh); |
| 194 | expect(diffs).toHaveLength(1); |
| 195 | expect(diffs[0].field).toBe("license"); |
| 196 | }); |
| 197 | |
| 198 | it("handles empty arrays in committed vs populated arrays in fresh", () => { |
| 199 | const committed = freshFacts({ crates: [], providers: [] }); |
| 200 | const fresh = freshFacts({ |
| 201 | crates: ["cli"], |
| 202 | providers: [{ id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }], |
| 203 | }); |
| 204 | const diffs = diffFacts(committed, fresh); |
| 205 | expect(diffs).toHaveLength(2); |
| 206 | expect(diffs.map((d) => d.field).sort()).toEqual(["crates", "providers"]); |
| 207 | }); |
| 208 | }); |
| 209 |