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 atb-52-css-token-extraction 211 lines 7.6 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"; 5 6const app = new Hono().get("/", (c) => 7 c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) 8); 9 10describe("BaseLayout", () => { 11 it("injects neobrutal tokens as :root CSS custom properties", async () => { 12 const res = await app.request("/"); 13 const html = await res.text(); 14 expect(html).toContain(":root {"); 15 expect(html).toContain("--color-bg:"); 16 expect(html).toContain("--color-primary:"); 17 }); 18 19 it("loads reset.css and theme.css stylesheets", async () => { 20 const res = await app.request("/"); 21 const html = await res.text(); 22 expect(html).toContain('href="/static/css/reset.css"'); 23 expect(html).toContain('href="/static/css/theme.css"'); 24 }); 25 26 it("loads Space Grotesk from Google Fonts", async () => { 27 const res = await app.request("/"); 28 const html = await res.text(); 29 expect(html).toContain("fonts.googleapis.com"); 30 expect(html).toContain("Space+Grotesk"); 31 }); 32 33 it("renders semantic site-header, content-container, and site-footer", async () => { 34 const res = await app.request("/"); 35 const html = await res.text(); 36 expect(html).toContain('class="site-header"'); 37 expect(html).toContain('class="content-container"'); 38 expect(html).toContain('class="site-footer"'); 39 }); 40 41 it("renders provided page title", async () => { 42 const res = await app.request("/"); 43 const html = await res.text(); 44 expect(html).toContain("<title>Test Page</title>"); 45 }); 46 47 it("falls back to default title when none provided", async () => { 48 const defaultApp = new Hono().get("/", (c) => 49 c.html(<BaseLayout>content</BaseLayout>) 50 ); 51 const res = await defaultApp.request("/"); 52 const html = await res.text(); 53 expect(html).toContain("<title>atBB Forum</title>"); 54 }); 55 56 it("renders children inside content-container", async () => { 57 const res = await app.request("/"); 58 const html = await res.text(); 59 expect(html).toContain("Page content"); 60 }); 61 62 it("renders header title link pointing to /", async () => { 63 const res = await app.request("/"); 64 const html = await res.text(); 65 expect(html).toContain('href="/"'); 66 expect(html).toContain('class="site-header__title"'); 67 }); 68 69 describe("auth-aware navigation", () => { 70 it("shows Log in link when auth is not provided (default unauthenticated)", async () => { 71 const unauthApp = new Hono().get("/", (c) => 72 c.html(<BaseLayout>content</BaseLayout>) 73 ); 74 const res = await unauthApp.request("/"); 75 const html = await res.text(); 76 expect(html).toContain('href="/login"'); 77 expect(html).toContain("Log in"); 78 }); 79 80 it("shows Log in link when auth is explicitly unauthenticated", async () => { 81 const auth: WebSession = { authenticated: false }; 82 const unauthApp = new Hono().get("/", (c) => 83 c.html(<BaseLayout auth={auth}>content</BaseLayout>) 84 ); 85 const res = await unauthApp.request("/"); 86 const html = await res.text(); 87 expect(html).toContain('href="/login"'); 88 expect(html).toContain("Log in"); 89 expect(html).not.toContain("Log out"); 90 }); 91 92 it("shows handle and Log out button when authenticated", async () => { 93 const auth: WebSession = { 94 authenticated: true, 95 did: "did:plc:abc123", 96 handle: "alice.bsky.social", 97 }; 98 const authApp = new Hono().get("/", (c) => 99 c.html(<BaseLayout auth={auth}>content</BaseLayout>) 100 ); 101 const res = await authApp.request("/"); 102 const html = await res.text(); 103 expect(html).toContain("alice.bsky.social"); 104 expect(html).toContain("Log out"); 105 expect(html).not.toContain('href="/login"'); 106 }); 107 108 it("renders logout as a form POST (not a link)", async () => { 109 const auth: WebSession = { 110 authenticated: true, 111 did: "did:plc:abc123", 112 handle: "alice.bsky.social", 113 }; 114 const authApp = new Hono().get("/", (c) => 115 c.html(<BaseLayout auth={auth}>content</BaseLayout>) 116 ); 117 const res = await authApp.request("/"); 118 const html = await res.text(); 119 // Logout must be a form POST for CSRF protection, not a plain link 120 expect(html).toContain('action="/logout"'); 121 expect(html).toContain('method="post"'); 122 expect(html).toContain("Log out"); 123 }); 124 }); 125 126 describe("accessibility", () => { 127 it("renders skip-to-content link before the site header", async () => { 128 const res = await app.request("/"); 129 const html = await res.text(); 130 expect(html).toContain('class="skip-link"'); 131 expect(html).toContain('href="#main-content"'); 132 expect(html).toContain("Skip to main content"); 133 // Skip link must come before header in DOM order 134 const skipLinkPos = html.indexOf("skip-link"); 135 const headerPos = html.indexOf("site-header"); 136 expect(skipLinkPos).toBeLessThan(headerPos); 137 }); 138 139 it("renders main element with id for skip link target", async () => { 140 const res = await app.request("/"); 141 const html = await res.text(); 142 expect(html).toContain('id="main-content"'); 143 }); 144 145 it("desktop nav has aria-label for Main navigation", async () => { 146 const res = await app.request("/"); 147 const html = await res.text(); 148 expect(html).toContain('aria-label="Main navigation"'); 149 }); 150 151 it("mobile nav has distinct aria-label", async () => { 152 const res = await app.request("/"); 153 const html = await res.text(); 154 expect(html).toContain('aria-label="Mobile navigation"'); 155 }); 156 }); 157 158 describe("favicon", () => { 159 it("includes favicon link in head", async () => { 160 const res = await app.request("/"); 161 const html = await res.text(); 162 expect(html).toContain('rel="icon"'); 163 expect(html).toContain("favicon.svg"); 164 }); 165 }); 166 167 describe("mobile navigation", () => { 168 it("renders details/summary hamburger menu for mobile", async () => { 169 const res = await app.request("/"); 170 const html = await res.text(); 171 expect(html).toContain("mobile-nav"); 172 expect(html).toContain("mobile-nav__toggle"); 173 }); 174 175 it("renders desktop nav separately from mobile nav", async () => { 176 const res = await app.request("/"); 177 const html = await res.text(); 178 expect(html).toContain("desktop-nav"); 179 }); 180 181 it("hamburger has aria-label for accessibility", async () => { 182 const res = await app.request("/"); 183 const html = await res.text(); 184 expect(html).toContain('aria-label="Menu"'); 185 }); 186 187 it("mobile nav contains login link when not authenticated", async () => { 188 const res = await app.request("/"); 189 const html = await res.text(); 190 // Both mobile and desktop nav should have "Log in" 191 const loginMatches = html.match(/Log in/g); 192 expect(loginMatches!.length).toBe(2); 193 }); 194 195 it("mobile nav contains auth state when logged in", async () => { 196 const auth: WebSession = { 197 authenticated: true, 198 did: "did:plc:abc123", 199 handle: "alice.bsky.social", 200 }; 201 const authApp = new Hono().get("/", (c) => 202 c.html(<BaseLayout auth={auth}>content</BaseLayout>) 203 ); 204 const res = await authApp.request("/"); 205 const html = await res.text(); 206 // Both mobile and desktop nav should have "Log out" 207 const logoutMatches = html.match(/Log out/g); 208 expect(logoutMatches!.length).toBe(2); 209 }); 210 }); 211});