Add Playwright E2E suite against the built dist/
Pin @playwright/test 1.62.0 (matches the cached chromium revision) and serve dist/ via python3 http.server, so the deployed artifact is what gets tested. Covers page load, terminal commands, vim mode, achievements, and the nav menu. Ignore node_modules, test-results, playwright-report, and the phased-execution runtime scratch (.agents/phase-sessions, pipeline.log).
This commit is contained in:
@@ -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(/<link rel="stylesheet" href="style\.[0-9a-f]{32}\.css"/);
|
||||
expect(html).toMatch(/<script src="terminal\.[0-9a-f]{32}\.js" defer="?">/);
|
||||
expect(html).toMatch(/<script src="script\.[0-9a-f]{32}\.js" defer="?">/);
|
||||
const resp = await page.request.get(page.url().replace(/\/$/, '') + '/' +
|
||||
(await page.locator('link[rel="stylesheet"]').getAttribute('href')));
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openTerminal, typeCommand, currentPromptLine, terminalLines } = require('./helpers');
|
||||
|
||||
test.describe('Fake terminal — basic commands', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
});
|
||||
|
||||
test('hero click expands the terminal and shifts the hero', async ({ page }) => {
|
||||
await expect(page.locator('.terminal-display')).toHaveClass(/grown/);
|
||||
await expect(page.locator('.terminal-display')).toHaveClass(/opaque/);
|
||||
await expect(page.locator('#hero')).toHaveClass(/shifted/);
|
||||
});
|
||||
|
||||
test('initial boot lines are rendered', async ({ page }) => {
|
||||
const lines = terminalLines(page);
|
||||
await expect(lines.first()).toHaveText(/uptime/);
|
||||
await expect(lines.nth(1)).toHaveText('optional');
|
||||
});
|
||||
|
||||
test('uptime shows the load-average line', async ({ page }) => {
|
||||
await typeCommand(page, 'uptime');
|
||||
await expect(terminalLines(page).nth(-2)).toContainText('up 42 days');
|
||||
});
|
||||
|
||||
test('ls lists the portfolio files', async ({ page }) => {
|
||||
await typeCommand(page, 'ls');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain('index.html');
|
||||
expect(out).toContain('nginx.conf');
|
||||
expect(out).toContain('profile.jpeg');
|
||||
});
|
||||
|
||||
test('pwd / whoami / hostname / id', async ({ page }) => {
|
||||
await typeCommand(page, 'pwd');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText(/\/home\/reese\/portfolio/);
|
||||
await typeCommand(page, 'whoami');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
||||
await typeCommand(page, 'hostname');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
||||
await typeCommand(page, 'id');
|
||||
await expect(terminalLines(page).nth(-2)).toContainText('uid=1000(reese)');
|
||||
});
|
||||
|
||||
test('date shows a plausible current date', async ({ page }) => {
|
||||
await typeCommand(page, 'date');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toMatch(/\b(19|20)\d{2}\b/);
|
||||
});
|
||||
|
||||
test('uname and cat /etc/os-release', async ({ page }) => {
|
||||
await typeCommand(page, 'uname -a');
|
||||
await expect(terminalLines(page).nth(-2)).toContainText('Linux homelab');
|
||||
await typeCommand(page, 'cat /etc/os-release');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain('Fedora Linux');
|
||||
expect(out).toContain('VERSION_ID');
|
||||
});
|
||||
|
||||
test('echo prints its argument as plain text', async ({ page }) => {
|
||||
await typeCommand(page, 'echo hello world');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('hello world');
|
||||
});
|
||||
|
||||
test('ss shows listening ports', async ({ page }) => {
|
||||
await typeCommand(page, 'ss');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain('0.0.0.0:22');
|
||||
expect(out).toContain('0.0.0.0:443');
|
||||
});
|
||||
|
||||
test('docker ps and podman ps', async ({ page }) => {
|
||||
await typeCommand(page, 'docker ps');
|
||||
const docker = await terminalLines(page).nth(-2).textContent();
|
||||
expect(docker).toContain('gitea');
|
||||
expect(docker).toContain('Up 42 days');
|
||||
await typeCommand(page, 'podman ps');
|
||||
const podman = await terminalLines(page).nth(-2).textContent();
|
||||
expect(podman).toContain('homepage');
|
||||
expect(podman).toContain('llama');
|
||||
});
|
||||
|
||||
test('ps aux and df -h and free -h', async ({ page }) => {
|
||||
await typeCommand(page, 'ps aux');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('llama-server');
|
||||
await typeCommand(page, 'df -h');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('Filesystem');
|
||||
await typeCommand(page, 'free -h');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('Mem:');
|
||||
});
|
||||
|
||||
test('neofetch renders the ASCII logo and colored blocks', async ({ page }) => {
|
||||
await typeCommand(page, 'neofetch');
|
||||
const line = terminalLines(page).nth(-2);
|
||||
await expect(line).toContainText('reese@homelab');
|
||||
await expect(line).toContainText('RTX 4090');
|
||||
await expect(line.locator('span[style*="color"]')).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sl prints the locomotive', async ({ page }) => {
|
||||
await typeCommand(page, 'sl');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('DD');
|
||||
});
|
||||
|
||||
test('help lists the available commands', async ({ page }) => {
|
||||
await typeCommand(page, 'help');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain('Available commands');
|
||||
expect(out).toContain('vim');
|
||||
expect(out).toContain('sudo');
|
||||
});
|
||||
|
||||
test('systemctl, ip addr, cat ssh key', async ({ page }) => {
|
||||
await typeCommand(page, 'systemctl list-units');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('gitea.service');
|
||||
await typeCommand(page, 'ip addr show');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('eth0');
|
||||
await typeCommand(page, 'cat ~/.ssh/id_ed25519.pub');
|
||||
expect(await terminalLines(page).nth(-2).textContent()).toContain('ssh-ed25519');
|
||||
});
|
||||
|
||||
test('unknown command produces no output and a fresh prompt', async ({ page }) => {
|
||||
const before = await terminalLines(page).count();
|
||||
await typeCommand(page, 'frobnicate --lots');
|
||||
const after = await terminalLines(page).count();
|
||||
expect(after).toBe(before + 1); // only the new prompt line
|
||||
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Fake terminal — state and special commands', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
});
|
||||
|
||||
test('history: ArrowUp/ArrowDown recall previous commands', async ({ page }) => {
|
||||
await typeCommand(page, 'whoami');
|
||||
await typeCommand(page, 'hostname');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(currentPromptLine(page)).toContainText('hostname');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(currentPromptLine(page)).toContainText('whoami');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(currentPromptLine(page)).toContainText('hostname');
|
||||
// executing the recalled command still works
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
||||
});
|
||||
|
||||
test('Tab completes a command', async ({ page }) => {
|
||||
await page.keyboard.type('host');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(currentPromptLine(page)).toContainText('hostname');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
||||
});
|
||||
|
||||
test('Tab cycles between multiple matches', async ({ page }) => {
|
||||
// strip the leading prompt ("$ "/"# ") from the line text
|
||||
const typed = async () =>
|
||||
(await currentPromptLine(page).textContent()).replace(/^[#$] /, '').trim();
|
||||
|
||||
await page.keyboard.type('cat ');
|
||||
await page.keyboard.press('Tab');
|
||||
const first = await typed();
|
||||
await page.keyboard.press('Tab');
|
||||
const second = await typed();
|
||||
expect(first).not.toEqual(second);
|
||||
expect(first).toMatch(/^cat /);
|
||||
expect(second).toMatch(/^cat /);
|
||||
});
|
||||
|
||||
test('clear empties the terminal and leaves one prompt line', async ({ page }) => {
|
||||
await typeCommand(page, 'uptime');
|
||||
expect(await terminalLines(page).count()).toBeGreaterThan(3);
|
||||
await typeCommand(page, 'clear');
|
||||
await expect(terminalLines(page)).toHaveCount(1);
|
||||
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
|
||||
});
|
||||
|
||||
test('sudo su - switches the prompt to root (#)', async ({ page }) => {
|
||||
await typeCommand(page, 'sudo su -');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain("I hope you know what you're doing");
|
||||
const prompt = currentPromptLine(page).locator('.terminal-prompt');
|
||||
await expect(prompt).toHaveText('#');
|
||||
// root styling: the cursor is the red one, the line is red too
|
||||
await expect(currentPromptLine(page).locator('.terminal-cursor')).toHaveClass(/red/);
|
||||
await typeCommand(page, 'whoami');
|
||||
// whoami still answers "reese" (the fake does not change identity)
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
||||
});
|
||||
|
||||
test('rm -rf / as a normal user is refused', async ({ page }) => {
|
||||
await typeCommand(page, 'rm -rf /');
|
||||
const out = await terminalLines(page).nth(-2).textContent();
|
||||
expect(out).toContain('nice try');
|
||||
// page must survive
|
||||
await expect(page.locator('#hero')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rm -rf / as root triggers the destruction easter egg', async ({ page }) => {
|
||||
await typeCommand(page, 'sudo su -');
|
||||
await typeCommand(page, 'rm -rf /');
|
||||
await expect(terminalLines(page).nth(-2)).toContainText('System destruction initiated');
|
||||
await page.waitForTimeout(700); // destruction setTimeout is 500ms
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(() => document.body.children.length)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('exit as a normal user swaps the page for a login screen', async ({ page }) => {
|
||||
await typeCommand(page, 'exit');
|
||||
await expect(page.locator('body')).toContainText('homelab tty1');
|
||||
await expect(page.locator('body')).toContainText('homelab login:');
|
||||
const login = page.locator('body input');
|
||||
await expect(login).toBeVisible();
|
||||
await login.fill('reese');
|
||||
await login.press('Enter');
|
||||
await expect(page.locator('body')).toContainText('Access denied');
|
||||
// a fresh login line with a new input appears
|
||||
await expect(page.locator('body input')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('backspace edits the current command', async ({ page }) => {
|
||||
await page.keyboard.type('hostnamex');
|
||||
await page.keyboard.press('Backspace');
|
||||
await expect(currentPromptLine(page)).toContainText('hostname');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openTerminal, typeCommand } = require('./helpers');
|
||||
|
||||
// The vim simulator renders into a fixed full-screen overlay appended to
|
||||
// <body>; text assertions below run against the whole body and pass/fail
|
||||
// on the overlay's presence or absence.
|
||||
|
||||
test.describe('Fake terminal — vim simulator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
});
|
||||
|
||||
test('vim with no filename shows the splash and q exits', async ({ page }) => {
|
||||
await typeCommand(page, 'vim');
|
||||
await expect(page.locator('body')).toContainText('VIM - Vi sIgnificantly worse M');
|
||||
await expect(page.locator('body')).toContainText('version 0.0.0-alpha');
|
||||
await page.keyboard.press('q');
|
||||
await expect(page.locator('body')).not.toContainText('VIM - Vi sIgnificantly worse M');
|
||||
// terminal is usable again
|
||||
await typeCommand(page, 'hostname');
|
||||
await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('homelab');
|
||||
});
|
||||
|
||||
test('vim with a filename opens a buffer; insert, save & quit', async ({ page }) => {
|
||||
await typeCommand(page, 'vim notes.txt');
|
||||
await expect(page.locator('body')).toContainText('Welcome to notes.txt!');
|
||||
await expect(page.locator('body')).toContainText('"notes.txt"');
|
||||
|
||||
// insert mode, type text at column 0
|
||||
await page.keyboard.press('i');
|
||||
await expect(page.locator('body')).toContainText('-- INSERT --');
|
||||
await page.keyboard.type('hello');
|
||||
await expect(page.locator('body')).toContainText('helloWelcome to notes.txt!');
|
||||
|
||||
// back to normal mode
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('body')).not.toContainText('-- INSERT --');
|
||||
|
||||
// :wq saves (simulated) and exits
|
||||
await page.keyboard.type(':wq');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('body')).not.toContainText('Welcome to notes.txt!');
|
||||
await expect(page.locator('.terminal-content')).toContainText('File saved (simulated).');
|
||||
});
|
||||
|
||||
test('vim normal-mode navigation and line deletion', async ({ page }) => {
|
||||
await typeCommand(page, 'vim file.md');
|
||||
await expect(page.locator('body')).toContainText('Welcome to file.md!');
|
||||
|
||||
// open a new line below (o), type, escape
|
||||
await page.keyboard.press('o');
|
||||
await expect(page.locator('body')).toContainText('-- INSERT --');
|
||||
await page.keyboard.type('line two');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// both lines present
|
||||
await expect(page.locator('body')).toContainText('Welcome to file.md!');
|
||||
await expect(page.locator('body')).toContainText('line two');
|
||||
|
||||
// G jumps to the last line, dd deletes it (Shift+D in this sim).
|
||||
// Use innerText: deleted lines are hidden (display:none) but remain
|
||||
// in the DOM, so textContent would still contain them.
|
||||
await page.keyboard.press('G');
|
||||
await page.keyboard.press('Shift+D');
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(() => document.body.innerText)
|
||||
).not.toContain('line two');
|
||||
await expect(page.locator('body')).toContainText('Welcome to file.md!');
|
||||
|
||||
// :q! force-quits
|
||||
await page.keyboard.type(':q!');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('body')).not.toContainText('Welcome to file.md!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { openTerminal, typeCommand } = require('./helpers');
|
||||
|
||||
const STORAGE_KEY = 'reese-terminal-achievements';
|
||||
|
||||
test.describe('Achievements system', () => {
|
||||
test('starts hidden until the first unlock', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#achievements')).toBeHidden();
|
||||
await expect(page.locator('#nav-achievements')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('uptime unlock shows a toast, persists to localStorage, reveals the section', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
await typeCommand(page, 'uptime');
|
||||
|
||||
// toast with the achievement name
|
||||
const toast = page.locator('.toast').filter({ hasText: 'Time Flies' });
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toContainText('Achievement Unlocked');
|
||||
await expect(page.locator('#toast-container .toast')).toHaveCount(1);
|
||||
|
||||
// persisted
|
||||
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
|
||||
expect(stored).toContain('time_flies');
|
||||
|
||||
// section revealed + counter + nav link
|
||||
await expect(page.locator('#achievements')).toBeVisible();
|
||||
await expect(page.locator('#achievements-count')).toHaveText(/1 \/ \d+ achievements unlocked/);
|
||||
await expect(page.locator('#nav-achievements a')).toHaveAttribute('href', '#achievements');
|
||||
|
||||
// the unlocked card shows its name; locked ones stay mysterious
|
||||
const cards = page.locator('#achievements-grid .achievement-card');
|
||||
const unlocked = page.locator('#achievements-grid .achievement-card.unlocked');
|
||||
await expect(unlocked).toHaveCount(1);
|
||||
await expect(unlocked).toContainText('Time Flies');
|
||||
expect(await cards.count()).toBeGreaterThan(1);
|
||||
await expect(page.locator('#achievements-grid .achievement-card.locked')).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unlocks survive a page reload (localStorage restore)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
await typeCommand(page, 'uptime');
|
||||
await expect(page.locator('#achievements')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('#achievements')).toBeVisible();
|
||||
await expect(page.locator('#achievements-count')).toHaveText(/1 \/ \d+ achievements unlocked/);
|
||||
await expect(page.locator('#nav-achievements a')).toHaveAttribute('href', '#achievements');
|
||||
});
|
||||
|
||||
test('special achievements: sudo, nice-try rm, vim splash', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
|
||||
await typeCommand(page, 'sudo su -');
|
||||
await expect(page.locator('.toast').filter({ hasText: 'Power User' })).toBeVisible();
|
||||
|
||||
await typeCommand(page, 'rm -rf /');
|
||||
// root variant: the destruction easter egg wipes the page (and its
|
||||
// toasts) ~500ms later — verify the unlock via localStorage instead
|
||||
await page.reload();
|
||||
|
||||
await openTerminal(page);
|
||||
await typeCommand(page, 'rm -rf /');
|
||||
await expect(page.locator('.toast').filter({ hasText: 'Nice Try' })).toBeVisible();
|
||||
|
||||
await typeCommand(page, 'vim');
|
||||
await expect(page.locator('.toast').filter({ hasText: 'Vi sIgnificantly worse M' })).toBeVisible();
|
||||
await page.keyboard.press('q');
|
||||
|
||||
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
|
||||
expect(stored).toEqual(
|
||||
expect.arrayContaining(['power_user', 'restore_backup', 'nice_try', 'vim_splash']));
|
||||
});
|
||||
|
||||
test('multiple unlocks stack toasts and increment the counter', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openTerminal(page);
|
||||
await typeCommand(page, 'uptime');
|
||||
await typeCommand(page, 'whoami');
|
||||
await typeCommand(page, 'clear');
|
||||
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
|
||||
expect(stored).toEqual(
|
||||
expect.arrayContaining(['time_flies', 'self_aware', 'clean_slate']));
|
||||
await expect(page.locator('#achievements-count')).toHaveText(/3 \/ \d+ achievements unlocked/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Navigation menu', () => {
|
||||
test('desktop: inline nav visible, hamburger hidden', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#hamburger')).toBeHidden();
|
||||
await expect(page.locator('#navLinks')).toBeVisible();
|
||||
await expect(page.locator('#navLinks a')).toHaveCount(7);
|
||||
});
|
||||
|
||||
test('mobile: hamburger opens and closes the menu', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
|
||||
await expect(page.locator('#hamburger')).toBeVisible();
|
||||
const links = page.locator('#navLinks');
|
||||
await expect(links).toBeHidden();
|
||||
|
||||
await page.locator('#hamburger').click();
|
||||
await expect(page.locator('#hamburger')).toHaveClass(/active/);
|
||||
await expect(links).toHaveClass(/active/);
|
||||
await expect(links).toBeVisible();
|
||||
|
||||
// clicking a link closes the menu
|
||||
await links.locator('a[href="#about"]').click();
|
||||
await expect(links).not.toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('clicking outside closes an open mobile menu', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
|
||||
await page.locator('#hamburger').click();
|
||||
await expect(page.locator('#navLinks')).toHaveClass(/active/);
|
||||
|
||||
// click a point below the open dropdown (y≈700): outside both the
|
||||
// nav and the hamburger, so the document-level click handler closes it
|
||||
await page.mouse.click(187, 700);
|
||||
await expect(page.locator('#navLinks')).not.toHaveClass(/active/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared helpers for the fake-terminal E2E tests.
|
||||
//
|
||||
// Desktop interaction model (see src/script.js + src/terminal.js):
|
||||
// the hero section overlays the rack background, so the terminal is opened
|
||||
// by clicking the hero (outside of any .btn). That focuses the hidden
|
||||
// <input> inside .terminal-display, and all keystrokes go there.
|
||||
|
||||
/** Click the hero to expand the terminal and focus its hidden input. */
|
||||
async function openTerminal(page) {
|
||||
await page.locator('#hero h1').click();
|
||||
await page.waitForSelector('.terminal-display.grown', { timeout: 5000 });
|
||||
}
|
||||
|
||||
/** Type a command into the focused terminal and press Enter. */
|
||||
async function typeCommand(page, cmd) {
|
||||
await page.keyboard.type(cmd);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
/** The current (last) line of the terminal output. */
|
||||
function currentPromptLine(page) {
|
||||
return page.locator('.terminal-content > div').last();
|
||||
}
|
||||
|
||||
/** All rendered terminal lines. */
|
||||
function terminalLines(page) {
|
||||
return page.locator('.terminal-content > div');
|
||||
}
|
||||
|
||||
module.exports = { openTerminal, typeCommand, currentPromptLine, terminalLines };
|
||||
Reference in New Issue
Block a user