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>
143 lines
4.7 KiB
TypeScript
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);
|
|
});
|