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
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { Hono } from "hono";
3import type { WebAppEnv, ResolvedTheme } from "../../lib/theme-resolution.js";
4import { createThemeMiddleware } from "../theme.js";
5
6vi.mock("../../lib/theme-resolution.js", () => ({
7 resolveTheme: vi.fn(),
8 FALLBACK_THEME: {
9 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" },
10 cssOverrides: null,
11 fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"],
12 colorScheme: "light",
13 },
14}));
15
16vi.mock("../../lib/logger.js", () => ({
17 logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
18}));
19
20import { resolveTheme, FALLBACK_THEME } from "../../lib/theme-resolution.js";
21import { logger } from "../../lib/logger.js";
22
23const mockResolveTheme = vi.mocked(resolveTheme);
24const mockLogger = vi.mocked(logger);
25
26const MOCK_THEME: ResolvedTheme = {
27 tokens: { "color-bg": "#ffffff", "color-primary": "#ff5c00" },
28 cssOverrides: null,
29 fontUrls: ["https://fonts.example.com/font.css"],
30 colorScheme: "light",
31};
32
33describe("createThemeMiddleware", () => {
34 beforeEach(() => {
35 mockResolveTheme.mockReset();
36 mockResolveTheme.mockResolvedValue(MOCK_THEME);
37 mockLogger.error.mockClear();
38 });
39
40 it("stores resolved theme in context for downstream handlers", async () => {
41 let capturedTheme: ResolvedTheme | undefined;
42
43 const app = new Hono<WebAppEnv>()
44 .use("*", createThemeMiddleware("http://appview.test"))
45 .get("/test", (c) => {
46 capturedTheme = c.get("theme");
47 return c.json({ ok: true });
48 });
49
50 const res = await app.request("http://localhost/test");
51 expect(res.status).toBe(200);
52 expect(capturedTheme).toEqual(MOCK_THEME);
53 });
54
55 it("passes Cookie header to resolveTheme", async () => {
56 const app = new Hono<WebAppEnv>()
57 .use("*", createThemeMiddleware("http://appview.test"))
58 .get("/test", (c) => c.json({ ok: true }));
59
60 await app.request("http://localhost/test", {
61 headers: { Cookie: "atbb-color-scheme=dark; session=abc" },
62 });
63
64 expect(mockResolveTheme).toHaveBeenCalledWith(
65 expect.any(String),
66 "atbb-color-scheme=dark; session=abc",
67 undefined
68 );
69 });
70
71 it("passes Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => {
72 const app = new Hono<WebAppEnv>()
73 .use("*", createThemeMiddleware("http://appview.test"))
74 .get("/test", (c) => c.json({ ok: true }));
75
76 await app.request("http://localhost/test", {
77 headers: { "Sec-CH-Prefers-Color-Scheme": "dark" },
78 });
79
80 expect(mockResolveTheme).toHaveBeenCalledWith(
81 expect.any(String),
82 undefined,
83 "dark"
84 );
85 });
86
87 it("passes appviewUrl to resolveTheme", async () => {
88 const app = new Hono<WebAppEnv>()
89 .use("*", createThemeMiddleware("http://custom-appview.example.com"))
90 .get("/test", (c) => c.json({ ok: true }));
91
92 await app.request("http://localhost/test");
93
94 expect(mockResolveTheme).toHaveBeenCalledWith(
95 "http://custom-appview.example.com",
96 undefined,
97 undefined
98 );
99 });
100
101 it("calls resolveTheme with undefined when Cookie and color-scheme headers are absent", async () => {
102 const app = new Hono<WebAppEnv>()
103 .use("*", createThemeMiddleware("http://appview.test"))
104 .get("/test", (c) => c.json({ ok: true }));
105
106 await app.request("http://localhost/test");
107
108 expect(mockResolveTheme).toHaveBeenCalledWith(
109 "http://appview.test",
110 undefined,
111 undefined
112 );
113 });
114
115 it("calls next() so the downstream handler executes", async () => {
116 const app = new Hono<WebAppEnv>()
117 .use("*", createThemeMiddleware("http://appview.test"))
118 .get("/test", (c) => c.json({ message: "handler ran" }));
119
120 const res = await app.request("http://localhost/test");
121 expect(res.status).toBe(200);
122 const body = await res.json() as { message: string };
123 expect(body.message).toBe("handler ran");
124 });
125
126 it("catches unexpected throws from resolveTheme, logs the error, and sets FALLBACK_THEME", async () => {
127 mockResolveTheme.mockRejectedValueOnce(new TypeError("Unexpected programming error"));
128
129 let capturedTheme: ResolvedTheme | undefined;
130 const app = new Hono<WebAppEnv>()
131 .use("*", createThemeMiddleware("http://appview.test"))
132 .get("/test", (c) => {
133 capturedTheme = c.get("theme");
134 return c.json({ ok: true });
135 });
136
137 // The request must NOT propagate the error — the middleware should catch it
138 const res = await app.request("http://localhost/test");
139 expect(res.status).toBe(200);
140 expect(capturedTheme).toEqual(FALLBACK_THEME);
141 expect(mockLogger.error).toHaveBeenCalledWith(
142 expect.stringContaining("resolveTheme threw unexpectedly"),
143 expect.objectContaining({ operation: "createThemeMiddleware" })
144 );
145 });
146});