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-56-theme-caching-layer 348 lines 13 kB view raw
1import { describe, it, expect } from "vitest"; 2import { Hono } from "hono"; 3import { BaseLayout } from "../base.js"; 4import type { WebSession } from "../../lib/session.js"; 5import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 6 7const app = new Hono().get("/", (c) => 8 c.html( 9 <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}> 10 Page content 11 </BaseLayout> 12 ) 13); 14 15describe("BaseLayout", () => { 16 it("injects neobrutal tokens as :root CSS custom properties", async () => { 17 const res = await app.request("/"); 18 const html = await res.text(); 19 // css-tree generates compact CSS (no space before brace) 20 expect(html).toContain(":root{"); 21 expect(html).toContain("--color-bg:"); 22 expect(html).toContain("--color-primary:"); 23 }); 24 25 it("loads reset.css and theme.css stylesheets", async () => { 26 const res = await app.request("/"); 27 const html = await res.text(); 28 expect(html).toContain('href="/static/css/reset.css"'); 29 expect(html).toContain('href="/static/css/theme.css"'); 30 }); 31 32 it("loads Space Grotesk from Google Fonts", async () => { 33 const res = await app.request("/"); 34 const html = await res.text(); 35 expect(html).toContain("fonts.googleapis.com"); 36 expect(html).toContain("Space+Grotesk"); 37 }); 38 39 it("renders semantic site-header, content-container, and site-footer", async () => { 40 const res = await app.request("/"); 41 const html = await res.text(); 42 expect(html).toContain('class="site-header"'); 43 expect(html).toContain('class="content-container"'); 44 expect(html).toContain('class="site-footer"'); 45 }); 46 47 it("renders provided page title", async () => { 48 const res = await app.request("/"); 49 const html = await res.text(); 50 expect(html).toContain("<title>Test Page</title>"); 51 }); 52 53 it("falls back to default title when none provided", async () => { 54 const defaultApp = new Hono().get("/", (c) => 55 c.html( 56 <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 57 ) 58 ); 59 const res = await defaultApp.request("/"); 60 const html = await res.text(); 61 expect(html).toContain("<title>atBB Forum</title>"); 62 }); 63 64 it("renders children inside content-container", async () => { 65 const res = await app.request("/"); 66 const html = await res.text(); 67 expect(html).toContain("Page content"); 68 }); 69 70 it("renders header title link pointing to /", async () => { 71 const res = await app.request("/"); 72 const html = await res.text(); 73 expect(html).toContain('href="/"'); 74 expect(html).toContain('class="site-header__title"'); 75 }); 76 77 it("includes Accept-CH meta tag for color scheme hint", async () => { 78 const res = await app.request("/"); 79 const html = await res.text(); 80 expect(html).toContain('http-equiv="Accept-CH"'); 81 expect(html).toContain('content="Sec-CH-Prefers-Color-Scheme"'); 82 }); 83 84 it("renders cssOverrides in a style tag when non-null", async () => { 85 const themeWithOverrides = { 86 ...FALLBACK_THEME, 87 cssOverrides: ".card { border: 2px solid black; }", 88 }; 89 const overridesApp = new Hono().get("/", (c) => 90 c.html( 91 <BaseLayout resolvedTheme={themeWithOverrides}>content</BaseLayout> 92 ) 93 ); 94 const res = await overridesApp.request("/"); 95 const html = await res.text(); 96 // css-tree generates compact CSS — check for key selectors and properties 97 expect(html).toContain(".card{"); 98 expect(html).toContain("border:2px solid black"); 99 }); 100 101 it("does not render Google Fonts preconnect tags when fontUrls is null", async () => { 102 const themeNoFonts = { ...FALLBACK_THEME, fontUrls: null }; 103 const noFontsApp = new Hono().get("/", (c) => 104 c.html( 105 <BaseLayout resolvedTheme={themeNoFonts}>content</BaseLayout> 106 ) 107 ); 108 const res = await noFontsApp.request("/"); 109 const html = await res.text(); 110 expect(html).not.toContain("fonts.googleapis.com"); 111 }); 112 113 it("filters out non-https font URLs and does not render them", async () => { 114 const themeWithUnsafeFontUrl = { 115 ...FALLBACK_THEME, 116 fontUrls: ["http://evil.com/style.css", "https://fonts.example.com/safe.css"], 117 }; 118 const unsafeFontApp = new Hono().get("/", (c) => 119 c.html( 120 <BaseLayout resolvedTheme={themeWithUnsafeFontUrl}>content</BaseLayout> 121 ) 122 ); 123 const res = await unsafeFontApp.request("/"); 124 const html = await res.text(); 125 expect(html).not.toContain("http://evil.com/style.css"); 126 expect(html).toContain("https://fonts.example.com/safe.css"); 127 }); 128 129 it("does not render cssOverrides style tag when cssOverrides is null", async () => { 130 const themeNoOverrides = { ...FALLBACK_THEME, cssOverrides: null }; 131 const noOverridesApp = new Hono().get("/", (c) => 132 c.html( 133 <BaseLayout resolvedTheme={themeNoOverrides}>content</BaseLayout> 134 ) 135 ); 136 const res = await noOverridesApp.request("/"); 137 const html = await res.text(); 138 // The only <style> tag should be the :root block — no second style tag for overrides 139 const styleTagMatches = html.match(/<style/g); 140 expect(styleTagMatches).toHaveLength(1); 141 // css-tree generates compact CSS (no space before brace) 142 expect(html).toContain(":root{"); 143 }); 144 145 describe("auth-aware navigation", () => { 146 it("shows Log in link when auth is not provided (default unauthenticated)", async () => { 147 const unauthApp = new Hono().get("/", (c) => 148 c.html( 149 <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 150 ) 151 ); 152 const res = await unauthApp.request("/"); 153 const html = await res.text(); 154 expect(html).toContain('href="/login"'); 155 expect(html).toContain("Log in"); 156 }); 157 158 it("shows Log in link when auth is explicitly unauthenticated", async () => { 159 const auth: WebSession = { authenticated: false }; 160 const unauthApp = new Hono().get("/", (c) => 161 c.html( 162 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 163 content 164 </BaseLayout> 165 ) 166 ); 167 const res = await unauthApp.request("/"); 168 const html = await res.text(); 169 expect(html).toContain('href="/login"'); 170 expect(html).toContain("Log in"); 171 expect(html).not.toContain("Log out"); 172 }); 173 174 it("shows handle and Log out button when authenticated", async () => { 175 const auth: WebSession = { 176 authenticated: true, 177 did: "did:plc:abc123", 178 handle: "alice.bsky.social", 179 }; 180 const authApp = new Hono().get("/", (c) => 181 c.html( 182 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 183 content 184 </BaseLayout> 185 ) 186 ); 187 const res = await authApp.request("/"); 188 const html = await res.text(); 189 expect(html).toContain("alice.bsky.social"); 190 expect(html).toContain("Log out"); 191 expect(html).not.toContain('href="/login"'); 192 }); 193 194 it("renders logout as a form POST (not a link)", async () => { 195 const auth: WebSession = { 196 authenticated: true, 197 did: "did:plc:abc123", 198 handle: "alice.bsky.social", 199 }; 200 const authApp = new Hono().get("/", (c) => 201 c.html( 202 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 203 content 204 </BaseLayout> 205 ) 206 ); 207 const res = await authApp.request("/"); 208 const html = await res.text(); 209 // Logout must be a form POST for CSRF protection, not a plain link 210 expect(html).toContain('action="/logout"'); 211 expect(html).toContain('method="post"'); 212 expect(html).toContain("Log out"); 213 }); 214 }); 215 216 describe("accessibility", () => { 217 it("renders skip-to-content link before the site header", async () => { 218 const res = await app.request("/"); 219 const html = await res.text(); 220 expect(html).toContain('class="skip-link"'); 221 expect(html).toContain('href="#main-content"'); 222 expect(html).toContain("Skip to main content"); 223 // Skip link must come before header in DOM order 224 const skipLinkPos = html.indexOf("skip-link"); 225 const headerPos = html.indexOf("site-header"); 226 expect(skipLinkPos).toBeLessThan(headerPos); 227 }); 228 229 it("renders main element with id for skip link target", async () => { 230 const res = await app.request("/"); 231 const html = await res.text(); 232 expect(html).toContain('id="main-content"'); 233 }); 234 235 it("desktop nav has aria-label for Main navigation", async () => { 236 const res = await app.request("/"); 237 const html = await res.text(); 238 expect(html).toContain('aria-label="Main navigation"'); 239 }); 240 241 it("mobile nav has distinct aria-label", async () => { 242 const res = await app.request("/"); 243 const html = await res.text(); 244 expect(html).toContain('aria-label="Mobile navigation"'); 245 }); 246 }); 247 248 describe("color scheme toggle", () => { 249 it("renders toggle button in site header when color scheme is light", async () => { 250 const res = await app.request("/"); 251 const html = await res.text(); 252 expect(html).toContain("color-scheme-toggle"); 253 expect(html).toContain('aria-label="Switch to dark mode"'); 254 }); 255 256 it("renders toggle button with aria-label 'Switch to light mode' when dark theme", async () => { 257 const darkTheme = { ...FALLBACK_THEME, colorScheme: "dark" as const }; 258 const darkApp = new Hono().get("/", (c) => 259 c.html(<BaseLayout resolvedTheme={darkTheme}>content</BaseLayout>) 260 ); 261 const res = await darkApp.request("/"); 262 const html = await res.text(); 263 expect(html).toContain("color-scheme-toggle"); 264 expect(html).toContain('aria-label="Switch to light mode"'); 265 }); 266 267 it("toggle button appears in both desktop and mobile nav", async () => { 268 const res = await app.request("/"); 269 const html = await res.text(); 270 const toggleMatches = html.match(/color-scheme-toggle/g); 271 expect(toggleMatches).toHaveLength(2); 272 }); 273 274 it("toggle button calls toggleColorScheme on click", async () => { 275 const res = await app.request("/"); 276 const html = await res.text(); 277 expect(html).toContain("toggleColorScheme()"); 278 }); 279 280 it("page includes toggleColorScheme script that sets cookie and reloads", async () => { 281 const res = await app.request("/"); 282 const html = await res.text(); 283 expect(html).toContain("atbb-color-scheme"); 284 expect(html).toContain("location.reload"); 285 expect(html).toContain("max-age=31536000"); 286 expect(html).toContain("SameSite=Lax"); 287 expect(html).toContain("path=/"); 288 }); 289 }); 290 291 describe("favicon", () => { 292 it("includes favicon link in head", async () => { 293 const res = await app.request("/"); 294 const html = await res.text(); 295 expect(html).toContain('rel="icon"'); 296 expect(html).toContain("favicon.svg"); 297 }); 298 }); 299 300 describe("mobile navigation", () => { 301 it("renders details/summary hamburger menu for mobile", async () => { 302 const res = await app.request("/"); 303 const html = await res.text(); 304 expect(html).toContain("mobile-nav"); 305 expect(html).toContain("mobile-nav__toggle"); 306 }); 307 308 it("renders desktop nav separately from mobile nav", async () => { 309 const res = await app.request("/"); 310 const html = await res.text(); 311 expect(html).toContain("desktop-nav"); 312 }); 313 314 it("hamburger has aria-label for accessibility", async () => { 315 const res = await app.request("/"); 316 const html = await res.text(); 317 expect(html).toContain('aria-label="Menu"'); 318 }); 319 320 it("mobile nav contains login link when not authenticated", async () => { 321 const res = await app.request("/"); 322 const html = await res.text(); 323 // Both mobile and desktop nav should have "Log in" 324 const loginMatches = html.match(/Log in/g); 325 expect(loginMatches!.length).toBe(2); 326 }); 327 328 it("mobile nav contains auth state when logged in", async () => { 329 const auth: WebSession = { 330 authenticated: true, 331 did: "did:plc:abc123", 332 handle: "alice.bsky.social", 333 }; 334 const authApp = new Hono().get("/", (c) => 335 c.html( 336 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 337 content 338 </BaseLayout> 339 ) 340 ); 341 const res = await authApp.request("/"); 342 const html = await res.text(); 343 // Both mobile and desktop nav should have "Log out" 344 const logoutMatches = html.match(/Log out/g); 345 expect(logoutMatches!.length).toBe(2); 346 }); 347 }); 348});