/** * Final verification test suite for the Astro rebuild. * Validates the built output against the design spec checklist. */ import { readFileSync, existsSync, readdirSync } from 'fs'; import { execSync } from 'child_process'; import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; const DIST = 'dist'; // Helper: read file as string function readDist(path) { return readFileSync(`${DIST}/${path}`, 'utf-8'); } // ============================================================ // 1. Clean build produces expected output files // ============================================================ describe('Clean build output', () => { test('dist/index.html exists', () => { assert.ok(existsSync(`${DIST}/index.html`)); }); test('dist/rss.xml exists', () => { assert.ok(existsSync(`${DIST}/rss.xml`)); }); test('post pages exist', () => { const postsDir = `${DIST}/posts`; assert.ok(existsSync(postsDir)); const slugs = readdirSync(postsDir); assert.ok(slugs.length >= 2, `Expected at least 2 post slugs, got ${slugs.length}`); }); }); // ============================================================ // 2. Index page structure // ============================================================ describe('Index page', () => { const html = readDist('index.html'); test('contains two article cards', () => { const cardMatches = html.match(/]*class="card"/g); assert.ok(cardMatches, 'No article cards found'); assert.equal(cardMatches.length, 2, `Expected 2 article cards, got ${cardMatches.length}`); }); test('card surface uses warm off-white (--surface-card)', () => { assert.ok(html.includes('var(--surface-card)'), 'Card should use --surface-card token'); }); test('card has subtle shadow (--shadow-sm)', () => { assert.ok(html.includes('var(--shadow-sm)'), 'Card should use --shadow-sm token'); }); test('card hover shows deeper shadow (--shadow-md)', () => { assert.ok(html.includes('var(--shadow-md)'), 'Card hover should use --shadow-md token'); }); test('card hover shows terracotta title (--accent-primary)', () => { assert.ok( html.includes(':hover .title') || html.includes(':hover .title{'), 'Card hover should style the title' ); assert.ok(html.includes('var(--accent-primary)'), 'Title hover should use terracotta accent'); }); test('card links have correct URL format with slash separator', () => { // The base is /crashtestdev and posts must be at /crashtestdev/posts/... const linkRegex = /href="([^"]*posts[^"]*)"/g; let match; let found = false; while ((match = linkRegex.exec(html)) !== null) { found = true; const url = match[1]; assert.ok( url.includes('/posts/'), `URL "${url}" should contain "/posts/" with proper slash separator` ); assert.ok( !url.includes('devposts'), `URL "${url}" should not concatenate base and posts without slash` ); } assert.ok(found, 'Should find article card links'); }); }); // ============================================================ // 3. Post page structure // ============================================================ describe('Post page', () => { // Find first post const postsDir = `${DIST}/posts`; const firstSlug = readdirSync(postsDir)[0]; const html = readDist(`posts/${firstSlug}/index.html`); test('has NavBar with site navigation', () => { assert.ok(html.includes('aria-label="Site navigation"'), 'Should have site navigation'); }); test('NavBar has Posts link with active state (terracotta underline)', () => { assert.ok(html.includes('nav-link'), 'Should have nav links'); // On post pages, the nav link should have the 'active' class assert.ok( html.includes('class="nav-link active"') || html.includes('active'), 'Should have active state on Posts link' ); // Verify the source NavBar component defines the terracotta underline for active links const navbar = readFileSync('src/components/NavBar.astro', 'utf-8'); assert.ok( navbar.includes('text-decoration-color: var(--accent-primary)'), 'Active link should have terracotta underline defined in source' ); }); test('has PostHeader with badge', () => { assert.ok(html.includes('post-header'), 'Should have post-header'); assert.ok(html.includes('class="badge"'), 'Should have badge'); }); test('has PostHeader with title', () => { assert.ok(html.includes('class="title"'), 'Should have title'); }); test('has PostHeader with date and reading time', () => { assert.ok(html.includes(' { // Astro scoped styles are compiled to external CSS files, not inline in HTML. // Check the source component instead. const postHeader = readFileSync('src/components/PostHeader.astro', 'utf-8'); assert.ok( postHeader.includes('text-align: center'), 'PostHeader should be centered in source component' ); }); }); // ============================================================ // 4. Typography: Three distinct voices // ============================================================ describe('Typography voices', () => { const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); test('Prose voice: Source Serif 4 via --font-prose', () => { assert.ok(tokens.includes('--font-prose')); assert.ok(tokens.includes('Source Serif 4')); }); test('Structure voice: Inter via --font-structure', () => { assert.ok(tokens.includes('--font-structure')); assert.ok(tokens.includes('Inter')); }); test('Evidence voice: JetBrains Mono via --font-evidence', () => { assert.ok(tokens.includes('--font-evidence')); assert.ok(tokens.includes('JetBrains Mono')); }); }); // ============================================================ // 5. Code blocks // ============================================================ describe('Code blocks', () => { const prose = readFileSync('src/styles/prose.css', 'utf-8'); test('break wider than prose (--width-code > --width-prose)', () => { assert.ok(prose.includes('--width-code'), 'Code blocks should use --width-code'); const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); const proseWidth = tokens.match(/--width-prose:\s*([^;]+)/)?.[1]?.trim(); const codeWidth = tokens.match(/--width-code:\s*([^;]+)/)?.[1]?.trim(); assert.ok(parseFloat(codeWidth) > parseFloat(proseWidth), `Code width (${codeWidth}) must be larger than prose width (${proseWidth})`); }); test('warm background (#EEEAE2 via --surface-code)', () => { const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); assert.ok(tokens.includes('--surface-code: #EEEAE2')); }); test('3px terracotta left border', () => { assert.ok(prose.includes('border-left: 3px solid var(--accent-primary)')); }); }); // ============================================================ // 6. Design token colors // ============================================================ describe('Design token colors', () => { const tokens = readFileSync('src/styles/tokens.css', 'utf-8'); test('page background #F5F0E8', () => { assert.ok(tokens.includes('#F5F0E8'), 'Should have #F5F0E8 as surface-base'); }); test('text color #2B2421', () => { assert.ok(tokens.includes('#2B2421'), 'Should have #2B2421 as text-primary'); }); test('link color #A0522D', () => { assert.ok(tokens.includes('#A0522D'), 'Should have #A0522D as accent-primary'); }); }); // ============================================================ // 7. Responsive design // ============================================================ describe('Responsive behavior', () => { test('NavBar stacks on mobile', () => { const navbar = readFileSync('src/components/NavBar.astro', 'utf-8'); assert.ok(navbar.includes('flex-direction: column') || navbar.includes('flex-direction:column'), 'Nav should stack on mobile'); assert.ok(navbar.includes('@media'), 'Should have responsive media query'); }); test('PostHeader title shrinks on mobile', () => { const header = readFileSync('src/components/PostHeader.astro', 'utf-8'); assert.ok(header.includes('@media'), 'PostHeader should have responsive media queries'); // Check that smaller type sizes are used at breakpoints assert.ok(header.includes('--type-2xl') || header.includes('--type-xl'), 'Title should use smaller type scale at breakpoints'); }); test('code blocks respect gutters on mobile', () => { const prose = readFileSync('src/styles/prose.css', 'utf-8'); assert.ok(prose.includes('100vw') && prose.includes('--gutter'), 'Code blocks should constrain to viewport minus gutters'); }); }); // ============================================================ // 8. RSS feed // ============================================================ describe('RSS feed', () => { const rss = readDist('rss.xml'); test('is valid XML (starts with xml declaration)', () => { assert.ok(rss.startsWith(' { assert.ok(rss.includes('Crash Test Dev')); }); test('contains at least 2 items', () => { const items = rss.match(//g); assert.ok(items && items.length >= 2, `Expected at least 2 RSS items, got ${items?.length}`); }); test('item links have correct URL format', () => { // RSS links should be full URLs with proper path const linkRegex = /(https?:\/\/[^<]+posts[^<]*)<\/link>/g; let match; let found = false; while ((match = linkRegex.exec(rss)) !== null) { found = true; const url = match[1]; assert.ok( url.includes('/posts/'), `RSS URL "${url}" should contain "/posts/" with proper slash separator` ); assert.ok( !url.includes('devposts'), `RSS URL "${url}" should not concatenate base and posts without slash` ); } assert.ok(found, 'Should find post links in RSS'); }); });