|
|
|
@@ -0,0 +1,273 @@
|
|
|
|
|
/**
|
|
|
|
|
* 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(/<article[^>]*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('<time'), 'Should have date element');
|
|
|
|
|
assert.ok(html.includes('min read'), 'Should have reading time');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('PostHeader is centered', () => {
|
|
|
|
|
// 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('<?xml'), 'RSS should start with XML declaration');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('contains channel with title', () => {
|
|
|
|
|
assert.ok(rss.includes('<title>Crash Test Dev</title>'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('contains at least 2 items', () => {
|
|
|
|
|
const items = rss.match(/<item>/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 = /<link>(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');
|
|
|
|
|
});
|
|
|
|
|
});
|