kammer/e2e/steps/common.steps.ts
Christopher Mühl 307ef24b78 test: E2E test suite + handoff documentation
50 Playwright E2E tests across 13 spec files covering all routes and
user flows (items CRUD, check-out/in, locations, labels, scanning,
search/filter). Uses vitest as runner with playwright-core for browser
automation (bun-compatible alternative to @playwright/test).

Includes Gherkin .feature files as living documentation, test support
infrastructure (IDB seeding, item factories, assertion helpers, layout
measurement), and HANDOFF.md covering project state, deployment, and
open design decisions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:53:08 +01:00

143 lines
4.7 KiB
TypeScript

import { expect } from '@playwright/test';
import { Given, When, Then } from 'playwright-bdd/decorators';
import { createBdd } from 'playwright-bdd';
import { waitForAppReady, seedItems, seedAndNavigate, clearItems } from '../support/seed';
import { buildItem, buildCheckedOutItem, buildConsumableItem, buildItemAtLocation, resetCounter } from '../support/item-factory';
import { assertMinTouchTarget, assertWithinViewport } from '../support/layout';
const { Given: given, When: when, Then: then } = createBdd();
// --- Navigation ---
given('I am on the {string} page', async ({ page }, path: string) => {
const routeMap: Record<string, string> = {
dashboard: '/',
items: '/items',
'new item': '/items/new',
scan: '/scan',
locations: '/locations',
labels: '/labels',
settings: '/settings',
};
const url = routeMap[path] ?? path;
await page.goto(url);
await waitForAppReady(page);
});
given('I navigate to {string}', async ({ page }, path: string) => {
await page.goto(path);
await waitForAppReady(page);
});
// --- Seeding ---
given('the inventory is empty', async ({ page }) => {
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.reload();
await waitForAppReady(page);
});
given('there are {int} items in the inventory', async ({ page }, count: number) => {
resetCounter();
const items = Array.from({ length: count }, (_, i) =>
buildItem({ name: `Item ${i + 1}`, category: i % 2 === 0 ? 'Electronics' : 'Kitchen' })
);
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, items);
});
given('there is an item {string} in category {string}', async ({ page }, name: string, category: string) => {
const item = buildItem({ name, category });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
});
given('there is a checked-out item {string}', async ({ page }, name: string) => {
const item = buildCheckedOutItem({ name });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
});
given('there is a consumable item {string} with {int} of {int} remaining', async ({ page }, name: string, current: number, original: number) => {
const item = buildConsumableItem({ name, currentQuantity: current, originalQuantity: original, lowThreshold: Math.floor(original * 0.3) });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
});
given('there is an item {string} at location {string}', async ({ page }, name: string, locationId: string) => {
const item = buildItemAtLocation(locationId, { name });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
});
// --- Interaction ---
when('I click {string}', async ({ page }, text: string) => {
await page.getByRole('button', { name: text }).or(page.getByRole('link', { name: text })).click();
});
when('I click the {string} button', async ({ page }, text: string) => {
await page.getByRole('button', { name: text }).click();
});
when('I click the {string} link', async ({ page }, text: string) => {
await page.getByRole('link', { name: text }).click();
});
when('I fill in {string} with {string}', async ({ page }, label: string, value: string) => {
await page.getByLabel(label).fill(value);
});
when('I select {string} from {string}', async ({ page }, value: string, label: string) => {
await page.getByLabel(label).selectOption(value);
});
when('I type {string} into the search field', async ({ page }, text: string) => {
await page.getByPlaceholder('Search items...').fill(text);
});
// --- Assertions ---
then('I should see {string}', async ({ page }, text: string) => {
await expect(page.getByText(text, { exact: false })).toBeVisible();
});
then('I should not see {string}', async ({ page }, text: string) => {
await expect(page.getByText(text, { exact: false })).not.toBeVisible();
});
then('I should be on {string}', async ({ page }, path: string) => {
await expect(page).toHaveURL(new RegExp(path));
});
then('the page title should be {string}', async ({ page }, title: string) => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title);
});
// --- Visual ---
then('the page should match the screenshot {string}', async ({ page }, name: string) => {
await expect(page).toHaveScreenshot(`${name}.png`);
});
// --- Layout ---
then('all nav buttons should be touch-target sized', async ({ page }) => {
const navLinks = page.locator('nav a');
const count = await navLinks.count();
for (let i = 0; i < count; i++) {
await assertMinTouchTarget(navLinks.nth(i));
}
});
then('the bottom nav should be within the viewport', async ({ page }) => {
const nav = page.locator('nav');
await assertWithinViewport(nav, page);
});