import { AxeBuilder } from '@axe-core/playwright'; import { test as base, expect } from '@playwright/test'; import { apps } from '../src/apps'; const test = base.extend<{ consoleErrors: string[] }>({ consoleErrors: [ async ({ page }, use) => { const errors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); page.on('pageerror', (err) => errors.push(err.message)); await use(errors); expect(errors).toEqual([]); }, { auto: true }, ], }); test.beforeEach(async ({ page }) => { await page.goto('/'); }); test('has the title and heading', async ({ page }) => { await expect(page).toHaveTitle('pumppi.dev'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('pumppi.dev'); }); test('links to every app', async ({ page }) => { await expect(page.getByRole('link')).toHaveCount(apps.length); for (const app of apps) { await expect(page.locator(`a[href="${app.url}"]`)).toBeVisible(); } }); test('clicking anywhere on a card follows its link', async ({ page }) => { await page.route('https://meal.pumppi.dev/**', (route) => route.fulfill({ contentType: 'text/html', body: 'mealie stub' }), ); // the description sits under the link's ::after overlay, so click by coordinates const box = await page.getByText('Recipes and meal plans').boundingBox(); if (!box) throw new Error('description not rendered'); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); await expect(page).toHaveURL('https://meal.pumppi.dev/'); }); test('search filters and Escape resets', async ({ page }) => { const search = page.getByRole('searchbox', { name: 'Search apps' }); // the shortcut is registered in an effect that can land after the load event; // only press again while unfocused, a "/" typed into the box would fail the next check await expect(async () => { if (!(await search.evaluate((el) => el === document.activeElement))) { await page.keyboard.press('/'); } await expect(search).toBeFocused({ timeout: 500 }); }).toPass(); await expect(search).toHaveValue(''); await search.fill('grafana'); await expect(page.getByRole('link')).toHaveCount(1); await expect(page.getByRole('link')).toHaveAttribute('href', 'https://grafana.pumppi.dev'); await search.fill('zzz'); await expect(page.getByRole('status')).toHaveText('No apps match “zzz”.'); await search.press('Escape'); await expect(page.getByRole('link')).toHaveCount(apps.length); }); test('fits every app on one screen without scrolling', async ({ page }) => { const { scrollHeight, clientHeight } = await page.evaluate(() => ({ scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight, })); expect(scrollHeight).toBeLessThanOrEqual(clientHeight); for (const link of await page.getByRole('link').all()) { await expect(link).toBeInViewport({ ratio: 1 }); } }); test('has no horizontal scroll', async ({ page }) => { const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(overflow).toBe(0); }); for (const colorScheme of ['light', 'dark'] as const) { test(`has no accessibility violations in ${colorScheme} mode`, async ({ page }) => { await page.emulateMedia({ colorScheme }); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); }