test: vitest unit + playwright e2e suites; fix a11y, contrast, no-JS reveal
- vitest: dictionary parity, slug-map bijection, content-schema validation, JSON-LD (23) - playwright: both locales, language-switch mapping (incl. no-JS), CV downloads, form happy-path + honeypot, axe a11y on 5 templates x 2 themes (23) - fix: raise ink-faint + light accent to meet WCAG AA 4.5:1 (axe-verified) - fix: gate reveal animations behind html.js so content is visible without JS (progressive enhancement - was hidden at opacity 0); scan reduced-motion path - fix: validate contact form before the anti-bot time-trap (no false success) - chore: ESLint node globals, typed diagram props, resvg fontFiles, prettier pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
/*
|
||||
Accessibility gate (TECH_SPEC §9.4): zero critical/serious violations on the main
|
||||
templates, both locales and both themes. wcag2a/2aa + best-practice rules.
|
||||
*/
|
||||
const pages = [
|
||||
{ name: 'home EN', url: '/' },
|
||||
{ name: 'home NO', url: '/no/' },
|
||||
{ name: 'case study', url: '/projects/inboxintel/' },
|
||||
{ name: 'contact', url: '/contact/' },
|
||||
{ name: 'experience', url: '/experience/' },
|
||||
];
|
||||
|
||||
for (const p of pages) {
|
||||
for (const theme of ['dark', 'light'] as const) {
|
||||
test(`${p.name} (${theme}) has no serious a11y violations`, async ({ page }) => {
|
||||
// Scan the reduced-motion rendering: it's the accessible path and shows all
|
||||
// reveal content immediately (entrance animations are otherwise mid-flight).
|
||||
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
|
||||
await page.addInitScript((t) => {
|
||||
try {
|
||||
localStorage.setItem('theme', t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, theme);
|
||||
await page.goto(p.url);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze();
|
||||
const serious = results.violations.filter(
|
||||
(v) => v.impact === 'serious' || v.impact === 'critical',
|
||||
);
|
||||
expect(serious, JSON.stringify(serious.map((v) => ({ id: v.id, nodes: v.nodes.length })))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('CV downloads (J1)', () => {
|
||||
test('both CV PDFs resolve with the right filenames', async ({ request }) => {
|
||||
const en = await request.get('/cv/connor-babbington-cv-en.pdf');
|
||||
const no = await request.get('/cv/connor-babbington-cv-no.pdf');
|
||||
expect(en.status()).toBe(200);
|
||||
expect(no.status()).toBe(200);
|
||||
expect(en.headers()['content-type']).toContain('pdf');
|
||||
});
|
||||
|
||||
test('header CV button defaults to the current locale', async ({ page }) => {
|
||||
await page.goto('/no/');
|
||||
const primary = page.locator('.cv-split a[download]').first();
|
||||
await expect(primary).toHaveAttribute('href', /cv-no\.pdf$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('contact form (A9)', () => {
|
||||
test('client validation blocks an empty submit', async ({ page }) => {
|
||||
await page.goto('/contact/');
|
||||
await page.locator('#contact-form button[type="submit"]').click();
|
||||
await expect(page.locator('[data-error-for="name"]')).toBeVisible();
|
||||
await expect(page.locator('[data-error-for="email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('happy path shows the success panel', async ({ page }) => {
|
||||
await page.route('**/api/contact', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' }),
|
||||
);
|
||||
await page.goto('/contact/');
|
||||
await page.fill('#cf-name', 'Martin');
|
||||
await page.fill('#cf-email', 'martin@example.com');
|
||||
await page.fill('#cf-message', 'We would like to talk to you about a role.');
|
||||
await page.waitForTimeout(2100); // pass the time-trap
|
||||
await page.locator('#contact-form button[type="submit"]').click();
|
||||
await expect(page.locator('#form-success')).toBeVisible();
|
||||
});
|
||||
|
||||
test('honeypot submission never calls the relay', async ({ page }) => {
|
||||
let called = false;
|
||||
await page.route('**/api/contact', (route) => {
|
||||
called = true;
|
||||
return route.fulfill({ status: 200, body: '{"ok":true}' });
|
||||
});
|
||||
await page.goto('/contact/');
|
||||
await page.fill('#cf-name', 'Bot');
|
||||
await page.fill('#cf-email', 'bot@spam.com');
|
||||
await page.fill('#cf-message', 'spam');
|
||||
await page.fill('#cf-company', 'ACME'); // honeypot
|
||||
await page.locator('#contact-form button[type="submit"]').click();
|
||||
await expect(page.locator('#form-success')).toBeVisible();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('language switch maps page↔page (J5, ROUTING_SPEC §3)', () => {
|
||||
test('deep NO case-study URL → EN twin', async ({ page }) => {
|
||||
await page.goto('/no/prosjekter/jobtrack/');
|
||||
await page.locator('header [role="group"] a', { hasText: 'EN' }).first().click();
|
||||
await expect(page).toHaveURL(/\/projects\/jobtrack\/$/);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en-GB');
|
||||
});
|
||||
|
||||
test('EN about → NO twin with localised slug', async ({ page }) => {
|
||||
await page.goto('/about/');
|
||||
await page.locator('header [role="group"] a', { hasText: 'NO' }).first().click();
|
||||
await expect(page).toHaveURL(/\/no\/om-meg\/$/);
|
||||
});
|
||||
|
||||
test('switch works without JavaScript (links, not scripts)', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/no/erfaring/');
|
||||
await page.locator('header [role="group"] a', { hasText: 'EN' }).first().click();
|
||||
await expect(page).toHaveURL(/\/experience\/$/);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('smoke — both locales render (TECH_SPEC §9.3)', () => {
|
||||
test('English home', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Systems Developer/);
|
||||
await expect(page.locator('h1')).toHaveText('Connor Babbington');
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en-GB');
|
||||
// decision facts present in the first view (chip)
|
||||
await expect(page.getByText('Tønsberg, Norway', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Norwegian home', async ({ page }) => {
|
||||
await page.goto('/no/');
|
||||
await expect(page).toHaveTitle(/Systemutvikler/);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await expect(page.getByText('Gyldig oppholdstillatelse')).toBeVisible();
|
||||
});
|
||||
|
||||
test('hreflang alternates emitted', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('link[hreflang="en"]')).toHaveCount(1);
|
||||
await expect(page.locator('link[hreflang="nb"]')).toHaveCount(1);
|
||||
await expect(page.locator('link[hreflang="x-default"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('case study shows architecture and decisions', async ({ page }) => {
|
||||
await page.goto('/projects/jobtrack/');
|
||||
await expect(page.locator('h1')).toHaveText('JobTrack');
|
||||
await expect(page.locator('#architecture svg[role="img"]')).toBeVisible();
|
||||
await expect(page.locator('#decisions')).toBeVisible();
|
||||
});
|
||||
|
||||
test('skip link is the first focusable element', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
await expect(focused).toHaveText(/Skip to content/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "resumesite-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"install-browsers": "playwright install --with-deps chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.1",
|
||||
"@playwright/test": "^1.50.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/*
|
||||
E2E runs against the built static output served by `astro preview` (TECH_SPEC §9.3).
|
||||
The relay is not required — the form spec mocks /api/contact.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4321',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
command: 'pnpm -C ../site build && pnpm -C ../site preview --port 4321 --host',
|
||||
url: 'http://localhost:4321/',
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+71
@@ -0,0 +1,71 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@axe-core/playwright':
|
||||
specifier: ^4.10.1
|
||||
version: 4.12.1(playwright-core@1.61.1)
|
||||
'@playwright/test':
|
||||
specifier: ^1.50.1
|
||||
version: 1.61.1
|
||||
|
||||
packages:
|
||||
|
||||
'@axe-core/playwright@4.12.1':
|
||||
resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==}
|
||||
peerDependencies:
|
||||
playwright-core: '>= 1.0.0'
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
axe-core@4.12.1:
|
||||
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@axe-core/playwright@4.12.1(playwright-core@1.61.1)':
|
||||
dependencies:
|
||||
axe-core: 4.12.1
|
||||
playwright-core: 1.61.1
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
axe-core@4.12.1: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
playwright-core@1.61.1: {}
|
||||
|
||||
playwright@1.61.1:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
Reference in New Issue
Block a user