diff --git a/.gitignore b/.gitignore
index 19d53b5..234ab4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,16 @@ dist/
# Local Docker build cache
.docker/
+# JS / test tooling
+node_modules/
+test-results/
+playwright-report/
+
+# Phased-execution runtime artifacts (the .agents/ planning tree itself is
+# tracked and committed — only its per-run scratch is ignored)
+.agents/phase-sessions/
+.agents/pipeline.log
+
# OS files
.DS_Store
Thumbs.db
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1201d61
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "homepage",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "homepage",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@playwright/test": "1.62.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
+ "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
+ "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..850b3e4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "homepage",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Static portfolio site — build (build.sh) + E2E tests (Playwright)",
+ "scripts": {
+ "build": "./build.sh",
+ "test": "playwright test",
+ "test:headed": "playwright test --headed"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.62.0"
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..a633d39
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,28 @@
+// @ts-check
+const { defineConfig } = require('@playwright/test');
+
+/**
+ * E2E config: serves the production build (dist/) on a local static server.
+ * Always run `./build.sh` (npm run build) before `npm test` so the tests
+ * exercise the exact artifact that gets deployed.
+ */
+module.exports = defineConfig({
+ testDir: './tests/e2e',
+ timeout: 20000,
+ retries: 0,
+ workers: 1,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://127.0.0.1:8123',
+ viewport: { width: 1440, height: 900 },
+ },
+ webServer: {
+ command: 'python3 -m http.server 8123 --bind 127.0.0.1 --directory dist',
+ url: 'http://127.0.0.1:8123/index.html',
+ reuseExistingServer: !process.env.CI,
+ timeout: 15000,
+ },
+ projects: [
+ { name: 'chromium', use: { browserName: 'chromium' } },
+ ],
+});
diff --git a/tests/e2e/01-page.spec.js b/tests/e2e/01-page.spec.js
new file mode 100644
index 0000000..d4e3b90
--- /dev/null
+++ b/tests/e2e/01-page.spec.js
@@ -0,0 +1,77 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+
+test.describe('Page structure', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ });
+
+ test('loads with correct title and lang', async ({ page }) => {
+ await expect(page).toHaveTitle(/Reese Wells/);
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ });
+
+ test('nav has all seven section links pointing at existing targets', async ({ page }) => {
+ const links = page.locator('#navLinks a');
+ await expect(links).toHaveCount(7);
+ const hrefs = await links.evaluateAll(as => as.map(a => a.getAttribute('href')));
+ expect(hrefs).toEqual(['#hero', '#about', '#experience', '#skills', '#projects', '#contact', '#gpg']);
+ for (const href of hrefs) {
+ await expect(page.locator(href)).toBeAttached();
+ }
+ });
+
+ test('all major sections exist and are in the DOM', async ({ page }) => {
+ for (const id of ['hero', 'about', 'experience', 'skills', 'projects', 'contact', 'gpg']) {
+ await expect(page.locator(`#${id}`)).toBeAttached();
+ }
+ });
+
+ test('profile image loads', async ({ page }) => {
+ const img = page.locator('#about img');
+ await expect(img).toBeVisible();
+ await expect.poll(async () =>
+ page.evaluate(() => document.querySelector('#about img').naturalWidth)
+ ).toBeGreaterThan(0);
+ });
+
+ test('server rack background is generated with a terminal unit', async ({ page }) => {
+ const rack = page.locator('.server-rack-bg .rack-container');
+ await expect(rack).toBeAttached();
+ await expect(rack.locator('.rack-unit')).not.toHaveCount(0);
+ await expect(page.locator('.terminal-display')).toBeAttached();
+ });
+
+ test('GPG section shows both fingerprints and key blocks', async ({ page }) => {
+ await expect(page.locator('#gpg .gpg-key-card')).toHaveCount(2);
+ await expect(page.locator('#gpg .gpg-key-fingerprint code').first()).toHaveText(
+ '7FC1 B297 0011 4F4F C589 E706 5FDD CFA5 44D7 7B8C');
+ await expect(page.locator('#gpg .gpg-key-fingerprint code').last()).toHaveText(
+ '2FF3 619F A6CA 2A4C FA2D 3532 816E 5FE7 8271 602B');
+ const blocks = page.locator('#gpg .gpg-key-block');
+ await expect(blocks).toHaveCount(2);
+ for (const block of [blocks.first(), blocks.last()]) {
+ await expect(block).toContainText('BEGIN PGP PUBLIC KEY BLOCK');
+ await expect(block).toContainText('END PGP PUBLIC KEY BLOCK');
+ }
+ });
+
+ test('external links all carry rel="noopener"', async ({ page }) => {
+ const links = page.locator('a[target="_blank"]');
+ const count = await links.count();
+ expect(count).toBeGreaterThan(0);
+ for (let i = 0; i < count; i++) {
+ await expect(links.nth(i)).toHaveAttribute('rel', /noopener/);
+ }
+ });
+
+ test('hashed assets are referenced and served', async ({ page }) => {
+ const html = await page.content();
+ expect(html).toMatch(//);
+ expect(html).toMatch(/