WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-54-add-lightdark-mode-toggle 346 lines 13 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2import { 3 detectColorScheme, 4 parseRkeyFromUri, 5 FALLBACK_THEME, 6 resolveTheme, 7} from "../theme-resolution.js"; 8import { logger } from "../logger.js"; 9 10vi.mock("../logger.js", () => ({ 11 logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, 12})); 13 14describe("detectColorScheme", () => { 15 it("returns 'light' by default when no cookie or hint", () => { 16 expect(detectColorScheme(undefined, undefined)).toBe("light"); 17 }); 18 19 it("reads atbb-color-scheme=dark from cookie", () => { 20 expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); 21 }); 22 23 it("reads atbb-color-scheme=light from cookie", () => { 24 expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); 25 }); 26 27 it("prefers cookie over client hint", () => { 28 expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); 29 }); 30 31 it("falls back to client hint when no cookie", () => { 32 expect(detectColorScheme(undefined, "dark")).toBe("dark"); 33 }); 34 35 it("ignores unrecognized hint values and returns 'light'", () => { 36 expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 37 }); 38 39 it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => { 40 // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched. 41 // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted. 42 expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light"); 43 }); 44}); 45 46describe("parseRkeyFromUri", () => { 47 it("extracts rkey from valid AT URI", () => { 48 expect( 49 parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") 50 ).toBe("3lblthemeabc"); 51 }); 52 53 it("returns null for URI with no rkey segment", () => { 54 expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); 55 }); 56 57 it("returns null for malformed URI", () => { 58 expect(parseRkeyFromUri("not-a-uri")).toBeNull(); 59 }); 60 61 it("returns null for empty string", () => { 62 expect(parseRkeyFromUri("")).toBeNull(); 63 }); 64}); 65 66describe("FALLBACK_THEME", () => { 67 it("uses neobrutal-light tokens", () => { 68 expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); 69 expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); 70 }); 71 72 it("has light colorScheme", () => { 73 expect(FALLBACK_THEME.colorScheme).toBe("light"); 74 }); 75 76 it("includes Google Fonts URL for Space Grotesk", () => { 77 expect(FALLBACK_THEME.fontUrls).toEqual( 78 expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) 79 ); 80 }); 81 82 it("has null cssOverrides", () => { 83 expect(FALLBACK_THEME.cssOverrides).toBeNull(); 84 }); 85}); 86 87describe("resolveTheme", () => { 88 const mockFetch = vi.fn(); 89 const mockLogger = vi.mocked(logger); 90 const APPVIEW = "http://localhost:3001"; 91 92 beforeEach(() => { 93 vi.stubGlobal("fetch", mockFetch); 94 mockLogger.warn.mockClear(); 95 mockLogger.error.mockClear(); 96 }); 97 98 afterEach(() => { 99 mockFetch.mockReset(); 100 vi.unstubAllGlobals(); 101 }); 102 103 function policyResponse(overrides: object = {}) { 104 return { 105 ok: true, 106 json: () => 107 Promise.resolve({ 108 defaultLightThemeUri: 109 "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 110 defaultDarkThemeUri: 111 "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 112 allowUserChoice: true, 113 availableThemes: [ 114 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, 115 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 116 ], 117 ...overrides, 118 }), 119 }; 120 } 121 122 function themeResponse(colorScheme: "light" | "dark", cid: string) { 123 return { 124 ok: true, 125 json: () => 126 Promise.resolve({ 127 cid, 128 tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 129 cssOverrides: null, 130 fontUrls: null, 131 colorScheme, 132 }), 133 }; 134 } 135 136 it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { 137 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 138 const result = await resolveTheme(APPVIEW, undefined, undefined); 139 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 140 expect(result.colorScheme).toBe("light"); 141 expect(mockLogger.warn).toHaveBeenCalledWith( 142 expect.stringContaining("non-ok status"), 143 expect.objectContaining({ operation: "resolveTheme", status: 404 }) 144 ); 145 }); 146 147 it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 148 mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 149 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 150 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 151 expect(result.colorScheme).toBe("dark"); 152 expect(mockLogger.warn).toHaveBeenCalledWith( 153 expect.stringContaining("non-ok status"), 154 expect.any(Object) 155 ); 156 }); 157 158 it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { 159 mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null })); 160 const result = await resolveTheme(APPVIEW, undefined, undefined); 161 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 162 }); 163 164 it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => { 165 mockFetch.mockResolvedValueOnce( 166 policyResponse({ defaultLightThemeUri: "malformed-uri" }) 167 ); 168 const result = await resolveTheme(APPVIEW, undefined, undefined); 169 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 170 // Only one fetch should happen (policy only — no theme fetch) 171 expect(mockFetch).toHaveBeenCalledTimes(1); 172 }); 173 174 it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => { 175 mockFetch 176 .mockResolvedValueOnce(policyResponse()) 177 .mockResolvedValueOnce({ ok: false, status: 404 }); 178 const result = await resolveTheme(APPVIEW, undefined, undefined); 179 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 180 expect(mockLogger.warn).toHaveBeenCalledWith( 181 expect.stringContaining("non-ok status"), 182 expect.objectContaining({ operation: "resolveTheme", status: 404 }) 183 ); 184 }); 185 186 it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { 187 mockFetch 188 .mockResolvedValueOnce(policyResponse()) 189 .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); 190 const result = await resolveTheme(APPVIEW, undefined, undefined); 191 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 192 expect(logger.warn).toHaveBeenCalledWith( 193 expect.stringContaining("CID mismatch"), 194 expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) 195 ); 196 }); 197 198 it("resolves the light theme on happy path (no cookie)", async () => { 199 mockFetch 200 .mockResolvedValueOnce(policyResponse()) 201 .mockResolvedValueOnce(themeResponse("light", "bafylight")); 202 const result = await resolveTheme(APPVIEW, undefined, undefined); 203 expect(result.tokens["color-bg"]).toBe("#fff"); 204 expect(result.colorScheme).toBe("light"); 205 expect(result.cssOverrides).toBeNull(); 206 expect(result.fontUrls).toBeNull(); 207 }); 208 209 it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { 210 mockFetch 211 .mockResolvedValueOnce(policyResponse()) 212 .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 213 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 214 expect(result.tokens["color-bg"]).toBe("#111"); 215 expect(result.colorScheme).toBe("dark"); 216 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); 217 }); 218 219 it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { 220 mockFetch 221 .mockResolvedValueOnce(policyResponse()) 222 .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 223 const result = await resolveTheme(APPVIEW, undefined, "dark"); 224 expect(result.colorScheme).toBe("dark"); 225 }); 226 227 it("returns FALLBACK_THEME and logs error on network exception", async () => { 228 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 229 const result = await resolveTheme(APPVIEW, undefined, undefined); 230 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 231 expect(logger.error).toHaveBeenCalledWith( 232 expect.stringContaining("Theme policy fetch failed"), 233 expect.objectContaining({ operation: "resolveTheme" }) 234 ); 235 }); 236 237 it("re-throws programming errors (TypeError) rather than swallowing them", async () => { 238 // A TypeError from a bug in the code should propagate, not be silently logged. 239 // This TypeError comes from the fetch() mock itself (not from .json()), so it 240 // is caught by the policy-fetch try block and re-thrown as a programming error. 241 mockFetch.mockImplementationOnce(() => { 242 throw new TypeError("Cannot read properties of null"); 243 }); 244 await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError); 245 }); 246 247 it("passes cssOverrides and fontUrls through from theme response", async () => { 248 mockFetch 249 .mockResolvedValueOnce(policyResponse()) 250 .mockResolvedValueOnce({ 251 ok: true, 252 json: () => 253 Promise.resolve({ 254 cid: "bafylight", 255 tokens: { "color-bg": "#fff" }, 256 cssOverrides: ".btn { font-weight: 700; }", 257 fontUrls: ["https://fonts.example.com/font.css"], 258 colorScheme: "light", 259 }), 260 }); 261 const result = await resolveTheme(APPVIEW, undefined, undefined); 262 expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 263 expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 264 }); 265 266 it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => { 267 mockFetch.mockResolvedValueOnce({ 268 ok: true, 269 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 270 }); 271 const result = await resolveTheme(APPVIEW, undefined, undefined); 272 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 273 expect(mockLogger.error).toHaveBeenCalledWith( 274 expect.stringContaining("invalid JSON"), 275 expect.objectContaining({ operation: "resolveTheme" }) 276 ); 277 }); 278 279 it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => { 280 mockFetch 281 .mockResolvedValueOnce(policyResponse()) 282 .mockResolvedValueOnce({ 283 ok: true, 284 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 285 }); 286 const result = await resolveTheme(APPVIEW, undefined, undefined); 287 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 288 expect(mockLogger.error).toHaveBeenCalledWith( 289 expect.stringContaining("invalid JSON"), 290 expect.objectContaining({ operation: "resolveTheme" }) 291 ); 292 }); 293 294 it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => { 295 mockFetch 296 .mockResolvedValueOnce(policyResponse({ availableThemes: [] })) 297 .mockResolvedValueOnce(themeResponse("light", "bafylight")); 298 await resolveTheme(APPVIEW, undefined, undefined); 299 expect(mockLogger.warn).toHaveBeenCalledWith( 300 expect.stringContaining("not in availableThemes"), 301 expect.objectContaining({ 302 operation: "resolveTheme", 303 themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 304 }) 305 ); 306 }); 307 308 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".." 310 // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch 311 mockFetch.mockResolvedValueOnce( 312 policyResponse({ 313 defaultLightThemeUri: "at://did/col/../../secret", 314 }) 315 ); 316 const result = await resolveTheme(APPVIEW, undefined, undefined); 317 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 318 // Only the policy fetch should have been made (no theme fetch) 319 expect(mockFetch).toHaveBeenCalledTimes(1); 320 }); 321 322 it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { 323 // Live refs have no CID — canonical atbb.space presets ship this way. 324 // The CID integrity check must be skipped when expectedCid is null. 325 mockFetch 326 .mockResolvedValueOnce( 327 policyResponse({ 328 availableThemes: [ 329 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid 330 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid 331 ], 332 }) 333 ) 334 .mockResolvedValueOnce(themeResponse("light", "bafylight")); 335 336 const result = await resolveTheme(APPVIEW, undefined, undefined); 337 338 // Theme resolved successfully — live ref does not trigger CID mismatch 339 expect(result.tokens["color-bg"]).toBe("#fff"); 340 expect(result.colorScheme).toBe("light"); 341 expect(mockLogger.warn).not.toHaveBeenCalledWith( 342 expect.stringContaining("CID mismatch"), 343 expect.any(Object) 344 ); 345 }); 346});