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 user-theme-preferences 415 lines 15 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 it("shows Settings link when authenticated", async () => { 216 const auth: WebSession = { 217 authenticated: true, 218 did: "did:plc:abc123", 219 handle: "alice.bsky.social", 220 }; 221 const authApp = new Hono().get("/", (c) => 222 c.html( 223 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 224 content 225 </BaseLayout> 226 ) 227 ); 228 const res = await authApp.request("/"); 229 const html = await res.text(); 230 expect(html).toContain('href="/settings"'); 231 expect(html).toContain("Settings"); 232 }); 233 234 it("does not show Settings link when unauthenticated", async () => { 235 const auth: WebSession = { authenticated: false }; 236 const unauthApp = new Hono().get("/", (c) => 237 c.html( 238 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 239 content 240 </BaseLayout> 241 ) 242 ); 243 const res = await unauthApp.request("/"); 244 const html = await res.text(); 245 expect(html).not.toContain('href="/settings"'); 246 expect(html).not.toContain("Settings"); 247 }); 248 }); 249 250 describe("accessibility", () => { 251 it("renders skip-to-content link before the site header", async () => { 252 const res = await app.request("/"); 253 const html = await res.text(); 254 expect(html).toContain('class="skip-link"'); 255 expect(html).toContain('href="#main-content"'); 256 expect(html).toContain("Skip to main content"); 257 // Skip link must come before header in DOM order 258 const skipLinkPos = html.indexOf("skip-link"); 259 const headerPos = html.indexOf("site-header"); 260 expect(skipLinkPos).toBeLessThan(headerPos); 261 }); 262 263 it("renders main element with id for skip link target", async () => { 264 const res = await app.request("/"); 265 const html = await res.text(); 266 expect(html).toContain('id="main-content"'); 267 }); 268 269 it("desktop nav has aria-label for Main navigation", async () => { 270 const res = await app.request("/"); 271 const html = await res.text(); 272 expect(html).toContain('aria-label="Main navigation"'); 273 }); 274 275 it("mobile nav has distinct aria-label", async () => { 276 const res = await app.request("/"); 277 const html = await res.text(); 278 expect(html).toContain('aria-label="Mobile navigation"'); 279 }); 280 }); 281 282 describe("color scheme toggle", () => { 283 it("renders toggle button in site header when color scheme is light", async () => { 284 const res = await app.request("/"); 285 const html = await res.text(); 286 expect(html).toContain("color-scheme-toggle"); 287 expect(html).toContain('aria-label="Switch to dark mode"'); 288 }); 289 290 it("renders toggle button with aria-label 'Switch to light mode' when dark theme", async () => { 291 const darkTheme = { ...FALLBACK_THEME, colorScheme: "dark" as const }; 292 const darkApp = new Hono().get("/", (c) => 293 c.html(<BaseLayout resolvedTheme={darkTheme}>content</BaseLayout>) 294 ); 295 const res = await darkApp.request("/"); 296 const html = await res.text(); 297 expect(html).toContain("color-scheme-toggle"); 298 expect(html).toContain('aria-label="Switch to light mode"'); 299 }); 300 301 it("toggle button appears in both desktop and mobile nav", async () => { 302 const res = await app.request("/"); 303 const html = await res.text(); 304 const toggleMatches = html.match(/color-scheme-toggle/g); 305 expect(toggleMatches).toHaveLength(2); 306 }); 307 308 it("toggle button calls toggleColorScheme on click", async () => { 309 const res = await app.request("/"); 310 const html = await res.text(); 311 expect(html).toContain("toggleColorScheme()"); 312 }); 313 314 it("page includes toggleColorScheme script that sets cookie and reloads", async () => { 315 const res = await app.request("/"); 316 const html = await res.text(); 317 expect(html).toContain("atbb-color-scheme"); 318 expect(html).toContain("location.reload"); 319 expect(html).toContain("max-age=31536000"); 320 expect(html).toContain("SameSite=Lax"); 321 expect(html).toContain("path=/"); 322 }); 323 324 it("toggleColorScheme script defaults to 'light' when no cookie present (first toggle must produce 'dark')", async () => { 325 // Regression: the old script used `m&&m[1]==='light'` which evaluates to `null` 326 // (not `false`) when no cookie exists, causing the first toggle to always produce 327 // 'light' instead of 'dark'. The fix introduces a `current` variable that defaults 328 // to 'light', ensuring `next` is always the opposite of the current scheme. 329 const res = await app.request("/"); 330 const html = await res.text(); 331 // Verify the corrected pattern is present: `current` defaults to 'light' when no cookie 332 expect(html).toContain("var current=m?m[1]:'light'"); 333 // Verify `next` is derived from `current`, not from the raw regex match 334 expect(html).toContain("current==='light'?'dark':'light'"); 335 }); 336 }); 337 338 describe("favicon", () => { 339 it("includes favicon link in head", async () => { 340 const res = await app.request("/"); 341 const html = await res.text(); 342 expect(html).toContain('rel="icon"'); 343 expect(html).toContain("favicon.svg"); 344 }); 345 }); 346 347 describe("mobile navigation", () => { 348 it("renders details/summary hamburger menu for mobile", async () => { 349 const res = await app.request("/"); 350 const html = await res.text(); 351 expect(html).toContain("mobile-nav"); 352 expect(html).toContain("mobile-nav__toggle"); 353 }); 354 355 it("renders desktop nav separately from mobile nav", async () => { 356 const res = await app.request("/"); 357 const html = await res.text(); 358 expect(html).toContain("desktop-nav"); 359 }); 360 361 it("hamburger has aria-label for accessibility", async () => { 362 const res = await app.request("/"); 363 const html = await res.text(); 364 expect(html).toContain('aria-label="Menu"'); 365 }); 366 367 it("mobile nav contains login link when not authenticated", async () => { 368 const res = await app.request("/"); 369 const html = await res.text(); 370 // Both mobile and desktop nav should have "Log in" 371 const loginMatches = html.match(/Log in/g); 372 expect(loginMatches!.length).toBe(2); 373 }); 374 375 it("mobile nav contains auth state when logged in", async () => { 376 const auth: WebSession = { 377 authenticated: true, 378 did: "did:plc:abc123", 379 handle: "alice.bsky.social", 380 }; 381 const authApp = new Hono().get("/", (c) => 382 c.html( 383 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 384 content 385 </BaseLayout> 386 ) 387 ); 388 const res = await authApp.request("/"); 389 const html = await res.text(); 390 // Both mobile and desktop nav should have "Log out" 391 const logoutMatches = html.match(/Log out/g); 392 expect(logoutMatches!.length).toBe(2); 393 }); 394 395 it("renders Settings link in both desktop and mobile nav when authenticated", async () => { 396 const auth: WebSession = { 397 authenticated: true, 398 did: "did:plc:abc123", 399 handle: "alice.bsky.social", 400 }; 401 const authApp = new Hono().get("/", (c) => 402 c.html( 403 <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 404 content 405 </BaseLayout> 406 ) 407 ); 408 const res = await authApp.request("/"); 409 const html = await res.text(); 410 // NavContent is rendered twice (desktop + mobile), so the link appears twice 411 const settingsMatches = [...html.matchAll(/href="\/settings"/g)]; 412 expect(settingsMatches).toHaveLength(2); 413 }); 414 }); 415});