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>
This commit is contained in:
Christopher Mühl 2026-02-26 20:53:08 +01:00
parent e2603ca479
commit ba68fb456a
54 changed files with 2145 additions and 1 deletions

56
.claude/forge.json Normal file
View file

@ -0,0 +1,56 @@
{
"_comment": "Copy to .claude/forge.json in your project and customise.",
"labels": [
{ "name": "feat", "color": "#0075ca", "description": "New feature or request" },
{ "name": "fix", "color": "#d73a4a", "description": "Something isn't working" },
{ "name": "chore", "color": "#e4e669", "description": "Maintenance, deps, config" },
{ "name": "refactor", "color": "#f9a825", "description": "Code improvement, no behaviour change" },
{ "name": "docs", "color": "#0052cc", "description": "Documentation only" },
{ "name": "test", "color": "#bfd4f2", "description": "Adding or fixing tests" },
{ "name": "perf", "color": "#84b6eb", "description": "Performance improvement" },
{ "name": "security", "color": "#ee0701", "description": "Security vulnerability or hardening" },
{ "name": "breaking", "color": "#b60205", "description": "Breaking change" },
{ "name": "ai-generated", "color": "#7b61ff", "description": "AI-assisted code (Claude Code)" },
{ "name": "needs-review", "color": "#fbca04", "description": "Waiting for human review" },
{ "name": "blocked", "color": "#e4e4e4", "description": "Blocked by another issue or PR" },
{ "name": "wontfix", "color": "#ffffff", "description": "This will not be worked on" }
],
"branch_protection": [
{
"branch_name": "main",
"enable_push": false,
"enable_push_whitelist": false,
"push_whitelist_usernames": [],
"enable_merge_whitelist": false,
"enable_status_check": false,
"required_approvals": 0,
"dismiss_stale_approvals": false,
"require_signed_commits": false,
"block_on_rejected_reviews": false,
"block_on_official_review_requests": false,
"block_on_outdated_branch": false
}
],
"webhooks": [],
"templates": {
"issue": true,
"pull_request": true
},
"settings": {
"has_issues": true,
"has_wiki": false,
"has_projects": false,
"default_branch": "main",
"default_merge_style": "squash",
"default_delete_branch_after_merge": true,
"allow_squash_merge": true,
"allow_rebase": false,
"allow_rebase_explicit": false,
"allow_merge_commits": false
}
}

139
HANDOFF.md Normal file
View file

@ -0,0 +1,139 @@
# SolidHaus — Project Handoff
## What This Is
Local-first household inventory app. Scan barcodes, track where things are, check items in/out, generate QR label sheets. Runs as a web app (SPA) or native mobile app via Capacitor.
## Tech Stack
- **Framework**: SvelteKit 2, Svelte 5 (runes), TypeScript
- **Styling**: Tailwind CSS 4 (dark slate theme)
- **Data**: IndexedDB via `idb` (local-first, no server required)
- **Runtime**: Bun (no Node.js — bun is the only runtime used)
- **Build**: Vite 7, `@sveltejs/adapter-static` (SPA mode, outputs to `build/`)
- **Testing**: Vitest 4 (unit) + Vitest + playwright-core (E2E)
- **Future**: Automerge (multi-device sync), Solid Protocol (linked data), Capacitor (native mobile)
## Current State (Feb 2026)
### What works
- All routes functional: dashboard, items list, item detail, new item, scan, locations, labels, settings
- Item CRUD with full field set (name, category, type, barcode, location, custody state, quantity tracking)
- Check-out / check-in flow with reasons (in-use, lent, in-transit, in-repair, temporary)
- Location tree with expand/collapse, item counts per location
- QR label sheet PDF generation (A4, 5x10 grid) with batch pre-generation
- Barcode scanning (Capacitor ML Kit on native, Barcode Detection API on web)
- Search and filter (by name, category, type, custody state)
- Confidence decay (confirmed → likely → assumed → unknown based on last sighting age)
- Default German house locations seeded on first run
### Test coverage
- **174 unit tests**`bun run test`
- **50 E2E tests** across 13 spec files — `bun run test:e2e`
- **Build**`bun run build` (outputs static SPA to `build/`)
### What's not done yet
- **Capacitor native platforms**: Config exists, deps installed, but `android/` and `ios/` dirs not created. Run `npx cap add android && npx cap sync` when ready.
- **Automerge sync**: Deps installed, not wired up. Intended for multi-device sync without a central server.
- **Solid Protocol**: Deps installed, not wired up. Intended for linked data / decentralized identity.
- **Photo support**: Schema exists (`photos` IDB store), no UI for capture/display yet.
- **Sighting history**: Schema exists (`sightings` IDB store), recorded on check-in/out but no history view UI.
## Project Structure
```
src/
routes/
+layout.svelte # App shell: loading state, bottom nav, inventory init
+page.svelte # Dashboard: stats, checked-out items, low stock, recent
items/
+page.svelte # Item list with search/filter/sort
new/+page.svelte # New item form
[id]/+page.svelte # Item detail with check-out/in, edit, delete
scan/+page.svelte # Barcode scanner
locations/+page.svelte # Location tree + items at location
labels/+page.svelte # Batch ID generation + PDF label sheets
settings/+page.svelte # DB stats, about
lib/
components/ # Svelte components (ItemCard, ItemForm, LocationTree, etc.)
data/ # IndexedDB CRUD (db.ts, items.ts, locations.ts, sightings.ts, labels.ts)
stores/ # Svelte 5 rune stores (inventory.svelte.ts)
scanning/ # Barcode detector + ID parser
printing/ # Label sheet PDF generation
utils/ # ID generation, confidence decay
types.ts # All TypeScript types
e2e/
features/ # Gherkin .feature files (living documentation, not executable)
pages/ # One per route (8 files)
flows/ # One per user journey (5 files)
specs/ # Executable vitest + playwright-core tests
pages/ # Page-level tests (8 files)
flows/ # Flow-level tests (5 files)
steps/ # Step definitions (leftover from playwright-bdd attempt, unused)
support/
browser.ts # Chromium lifecycle for vitest
seed.ts # IDB seeding via page.evaluate()
item-factory.ts # Test data builders
expect.ts # Playwright locator assertions for vitest
layout.ts # DOM measurement helpers (touch targets, spacing, viewport)
vitest.config.ts # E2E-specific vitest config
playwright.config.ts # Shared Playwright settings (viewport, baseURL, etc.)
```
## Commands
```bash
bun install # install deps
bun run dev # dev server at localhost:5173
bun run build # production build → build/
bun run preview # serve production build at localhost:4173
bun run test # 174 unit tests
bun run test:e2e # 50 E2E tests (needs build + preview running, or run build first)
bun run check # svelte-check type checking
```
## Deployment
The app is a static SPA. `bun run build` outputs to `build/`. Serve it with any static file server that supports SPA fallback (all routes → `index.html`).
Example with Caddy:
```
:8080 {
root * /srv/solidhaus
try_files {path} /index.html
file_server
}
```
Example with nginx:
```nginx
server {
listen 8080;
root /srv/solidhaus;
location / {
try_files $uri $uri/ /index.html;
}
}
```
No backend required — all data lives in the browser's IndexedDB.
## Design Decisions Under Discussion
These are captured but not yet implemented:
- **Item name optional**: UID (pre-generated 7-char nanoid) is the only required field. Need at least one of name, description, or image.
- **Item types rework**: Current `itemType` enum mixes behavior (consumable, perishable) with category (clothing, document). Plan to split into behavioral traits (has quantity, has expiry, is container) + freeform categories.
- **Acquisition model**: Replace `purchaseDate` with acquisition events (purchased, gifted, loaned, rented, found, inherited) with date.
- **Container nesting**: A fridge is both an item and a container. Items can be "contained in" other items, separate from location tracking.
See `CLAUDE.md` for full implementation guide and data schemas.
## Known Quirks
- **Bun, not Node**: The project uses bun exclusively. Playwright's test runner (`@playwright/test`) doesn't work with bun (worker thread incompatibility), so E2E tests use vitest as the runner with playwright-core for browser automation.
- **E2E needs a running server**: The E2E tests connect to `localhost:4173`. Run `bun run build && bun run preview` in a separate terminal before running `bun run test:e2e`, or the webServer in the vitest config handles it.
- **Gherkin features are documentation only**: The `.feature` files describe behavior in plain English but aren't wired to the test runner (playwright-bdd was abandoned due to bun compat). The `.spec.ts` files are the executable tests, each referencing their `.feature` file via `@see` JSDoc.
- **Step files unused**: `e2e/steps/` contains step definition files from the playwright-bdd approach. They're documentation artifacts, not executed.
- **Location tree interaction**: Clicking a location name selects it (shows items). To expand/collapse, click the arrow toggle button (`aria-label="Expand"/"Collapse"`), not the name.

View file

@ -31,6 +31,7 @@
"@testing-library/svelte": "^5.3.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright-core": "^1.58.2",
"svelte": "^5.51.0",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.2.1",
@ -637,6 +638,8 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],

2
e2e/bin/node Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
exec bun "$@"

View file

@ -0,0 +1,19 @@
Feature: Check Out / Check In Flow
As a user I want to check items in and out to track custody.
Scenario: Check out an item
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
Then I should see "Home"
When I click the "Check Out" button
And I select "lent" from "Reason"
And I click the "Confirm" button
Then I should see "Out"
Scenario: Check in a checked-out item
Given there is a checked-out item "Drill"
And I am on the "items" page
When I click "Drill"
Then I should see "Out"
And I should see "Check In"

View file

@ -0,0 +1,35 @@
Feature: Create Item Flow
As a user I want to create a new item and see it in my inventory.
Scenario: Create a basic durable item
Given the inventory is empty
And I am on the "new item" page
When I fill in "Name *" with "Electric Drill"
And I fill in "Category" with "Tools"
And I fill in "Brand" with "Bosch"
And I click the "Create Item" button
Then I should be on "/items/"
And I should see "Electric Drill"
And I should see "Tools"
Scenario: Created item appears in items list
Given the inventory is empty
And I am on the "new item" page
When I fill in "Name *" with "Hammer"
And I click the "Create Item" button
Then I should be on "/items/"
When I click "Back"
And I am on the "items" page
Then I should see "Hammer"
Scenario: Create a consumable with quantity tracking
Given the inventory is empty
And I am on the "new item" page
When I fill in "Name *" with "Printer Paper"
And I select "consumable" from "Type"
And I fill in "Current" with "8"
And I fill in "Original" with "10"
And I fill in "Unit" with "reams"
And I click the "Create Item" button
Then I should be on "/items/"
And I should see "Printer Paper"

View file

@ -0,0 +1,15 @@
Feature: Label Generation Flow
As a user I want to generate batches of IDs and download label PDFs.
Scenario: Generate a batch of IDs
Given I am on the "labels" page
And I should see "Available IDs"
When I select "10 IDs" from "Batch Size"
And I click the "Generate" button
Then I should not see "Available IDs"
# After generation, the count should have increased
Scenario: Labels page shows batch options
Given I am on the "labels" page
Then I should see "Generate ID Batch"
And I should see "Print Label Sheet"

View file

@ -0,0 +1,11 @@
Feature: Scan and Action Flow
As a user I want to scan items and take quick actions.
Scenario: Scan page shows scanner controls
Given I am on the "scan" page
Then I should see "Scan Barcode"
And I should see "Point your camera"
Scenario: Scan page visual appearance
Given I am on the "scan" page
Then the page should match the screenshot "scan-flow"

View file

@ -0,0 +1,26 @@
Feature: Search and Filter Flow
As a user I want to find items quickly through search and filters.
Scenario: Search by item name
Given there is an item "Laptop" in category "Electronics"
And there is an item "Toaster" in category "Kitchen"
And I am on the "items" page
When I type "Laptop" into the search field
Then I should see "Laptop"
And I should not see "Toaster"
Scenario: Clear search shows all items
Given there is an item "Laptop" in category "Electronics"
And there is an item "Toaster" in category "Kitchen"
And I am on the "items" page
When I type "Laptop" into the search field
Then I should not see "Toaster"
When I type "" into the search field
Then I should see "Laptop"
And I should see "Toaster"
Scenario: No results message for empty search
Given there is an item "Laptop" in category "Electronics"
And I am on the "items" page
When I type "xyznotfound" into the search field
Then I should see "No items match your filters"

View file

@ -0,0 +1,46 @@
Feature: Dashboard
As a user I want to see an overview of my inventory at a glance.
Background:
Given I am on the "dashboard" page
Scenario: Empty state shows call-to-action
Given the inventory is empty
And I am on the "dashboard" page
Then I should see "No items yet"
And I should see "Add First Item"
And the page title should be "SolidHaus"
Scenario: Stats grid shows inventory summary
Given there are 5 items in the inventory
And I am on the "dashboard" page
Then I should see "Total Items"
And I should see "Checked Out"
And I should see "Overdue"
And I should see "Low Stock"
Scenario: Checked-out section appears when items are out
Given there is a checked-out item "Drill"
And I am on the "dashboard" page
Then I should see "Checked Out"
And I should see "Drill"
Scenario: Low-stock section appears for depleted consumables
Given there is a consumable item "Batteries" with 2 of 10 remaining
And I am on the "dashboard" page
Then I should see "Low Stock"
And I should see "Batteries"
Scenario: Recently updated items are shown
Given there are 3 items in the inventory
And I am on the "dashboard" page
Then I should see "Recently Updated"
Scenario: Navigation bar is visible and accessible
Then all nav buttons should be touch-target sized
And the bottom nav should be within the viewport
Scenario: Dashboard visual appearance
Given there are 3 items in the inventory
And I am on the "dashboard" page
Then the page should match the screenshot "dashboard-with-items"

View file

@ -0,0 +1,53 @@
Feature: Item Detail
As a user I want to view and manage an individual item's details.
Scenario: Item details are displayed
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
Then I should see "Drill"
And I should see "Tools"
And I should see "Type"
And I should see "Storage Tier"
Scenario: Custody badge shows checked-in state
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
Then I should see "Home"
Scenario: Check out form appears on button click
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
And I click the "Check Out" button
Then I should see "Reason"
And I should see "Destination"
And I should see "Note"
Scenario: Edit button enters edit mode
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
And I click the "Edit" button
Then I should see "Edit Item"
And I should see "Save Changes"
Scenario: Delete requires confirmation
Given there is an item "Drill" in category "Tools"
And I am on the "items" page
When I click "Drill"
And I click the "Delete" button
Then I should see "Confirm Delete"
Scenario: Quantity bar shown for consumables
Given there is a consumable item "Coffee Beans" with 3 of 10 remaining
And I am on the "items" page
When I click "Coffee Beans"
Then I should see "Quantity"
And I should see "3"
Scenario: Item not found shows error
Given I navigate to "/items/nonexistent"
Then I should see "Item not found"
And I should see "Back to items"

View file

@ -0,0 +1,37 @@
Feature: New Item Form
As a user I want to create new inventory items.
Background:
Given I am on the "new item" page
Scenario: Form displays all required fields
Then I should see "New Item"
And I should see "Name *"
And I should see "Type"
And I should see "Create Item"
Scenario: Submit button disabled without name
Then the page should match the screenshot "new-item-empty"
Scenario: Cancel returns to items list
When I click the "Cancel" button
Then I should be on "/items"
Scenario: Consumable type shows quantity fields
When I select "consumable" from "Type"
Then I should see "Quantity Tracking"
And I should see "Current"
And I should see "Original"
Scenario: Perishable type shows expiry date
When I select "perishable" from "Type"
Then I should see "Expiry Date"
Scenario: Durable type hides quantity fields
When I select "durable" from "Type"
Then I should not see "Quantity Tracking"
Scenario: Storage tier buttons are visible
Then I should see "Hot"
And I should see "Warm"
And I should see "Cold"

View file

@ -0,0 +1,42 @@
Feature: Items List
As a user I want to browse, search, and filter my inventory items.
Scenario: Empty inventory shows placeholder
Given the inventory is empty
And I am on the "items" page
Then I should see "No items yet"
Scenario: Items are listed with details
Given there is an item "Laptop" in category "Electronics"
And I am on the "items" page
Then I should see "Laptop"
And I should see "Electronics"
Scenario: Search filters items by name
Given there is an item "Laptop" in category "Electronics"
And there is an item "Toaster" in category "Kitchen"
And I am on the "items" page
When I type "Laptop" into the search field
Then I should see "Laptop"
And I should not see "Toaster"
Scenario: Item count is displayed
Given there are 5 items in the inventory
And I am on the "items" page
Then I should see "5 of 5 items"
Scenario: Filtered count reflects active filters
Given there is an item "Laptop" in category "Electronics"
And there is an item "Toaster" in category "Kitchen"
And I am on the "items" page
When I type "Laptop" into the search field
Then I should see "1 of 2 items"
Scenario: New item button is visible
Given I am on the "items" page
Then I should see "+ New"
Scenario: Items list visual appearance
Given there are 3 items in the inventory
And I am on the "items" page
Then the page should match the screenshot "items-list"

View file

@ -0,0 +1,23 @@
Feature: Labels
As a user I want to generate ID batches and print label sheets.
Background:
Given I am on the "labels" page
Scenario: Label page shows stats
Then the page title should be "Labels"
And I should see "Available IDs"
And I should see "Assigned Items"
Scenario: Batch size options are available
Then I should see "10 IDs"
And I should see "50 IDs (1 sheet)"
Scenario: Generate button is present
Then I should see "Generate"
Scenario: Print button disabled when no IDs
Then I should see "Download PDF (0 labels)"
Scenario: Labels visual appearance
Then the page should match the screenshot "labels-page"

View file

@ -0,0 +1,25 @@
Feature: Locations
As a user I want to browse locations and see which items are at each place.
Background:
Given I am on the "locations" page
Scenario: Location tree shows default locations
Then the page title should be "Places"
And I should see "Home"
Scenario: Placeholder shown when no location selected
Then I should see "Select a location to view items"
Scenario: Selecting a location shows its items
Given there is an item "Blender" at location "kueche"
And I am on the "locations" page
When I click "Küche"
Then I should see "Blender"
Scenario: Empty location shows placeholder
When I click "Keller"
Then I should see "No items here"
Scenario: Locations visual appearance
Then the page should match the screenshot "locations-page"

View file

@ -0,0 +1,13 @@
Feature: Scan Page
As a user I want to scan barcodes to look up and manage items.
Background:
Given I am on the "scan" page
Scenario: Scanner is displayed
Then the page title should be "Scan"
And I should see "Scan Barcode"
And I should see "Point your camera"
Scenario: Scan page visual appearance
Then the page should match the screenshot "scan-page"

View file

@ -0,0 +1,22 @@
Feature: Settings
As a user I want to view app settings and database statistics.
Background:
Given I am on the "settings" page
Scenario: Settings page shows sections
Then the page title should be "Settings"
And I should see "Database"
And I should see "Sync"
And I should see "About"
Scenario: Database stats show counts
Then I should see "Items"
And I should see "Locations"
Scenario: About section shows version
Then I should see "SolidHaus v0.1.0"
And I should see "Data is stored locally"
Scenario: Settings visual appearance
Then the page should match the screenshot "settings-page"

15
e2e/playwright.config.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* E2E test configuration.
* Tests use vitest as runner + playwright-core for browser automation.
* Run with: bun run test:e2e
*/
export const e2eConfig = {
baseURL: 'http://localhost:4173',
viewport: { width: 390, height: 844 },
colorScheme: 'dark' as const,
screenshotDir: 'e2e/screenshots',
projects: {
'mobile-chrome': { width: 412, height: 915 },
'desktop-chrome': { width: 1280, height: 720 },
},
};

View file

@ -0,0 +1,50 @@
/**
* @feature Check Out / Check In Flow
* @see e2e/features/flows/check-out-check-in.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems } from '../../support/seed';
import { buildItem, buildCheckedOutItem } from '../../support/item-factory';
import { expectVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Check Out / Check In Flow', () => {
test('check out an item', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
// 'Home' badge on item detail (not the location tree 'Home')
await expectVisible(page.locator('main').getByText('Home').first());
await page.getByRole('button', { name: 'Check Out' }).click();
await page.locator('#co-reason').selectOption('lent');
await page.getByRole('button', { name: 'Confirm' }).click();
// Badge shows reason label "Lent" after checkout
await expectVisible(page.locator('main').getByText('Lent').first());
});
test('check in a checked-out item shows check-in form', async () => {
const page = getPage();
const item = buildCheckedOutItem({ name: 'Drill' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
// buildCheckedOutItem sets reason 'in-use' → badge shows "In Use"
await expectVisible(page.locator('main').getByText('In Use').first());
await expectVisible(page.getByRole('heading', { name: 'Check In' }));
await expectVisible(page.getByText('Return to'));
});
});

View file

@ -0,0 +1,70 @@
/**
* @feature Create Item Flow
* @see e2e/features/flows/create-item.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, clearItems } from '../../support/seed';
import { expectVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Create Item Flow', () => {
test('create a basic durable item', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Name *').fill('Electric Drill');
await page.getByLabel('Category').fill('Tools');
await page.getByLabel('Brand').fill('Bosch');
await page.getByRole('button', { name: 'Create Item' }).click();
await page.waitForURL(/\/items\//);
await expectVisible(page.getByText('Electric Drill'));
await expectVisible(page.getByText('Tools'));
});
test('created item appears in items list', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Name *').fill('Hammer');
await page.getByRole('button', { name: 'Create Item' }).click();
await page.waitForURL(/\/items\//);
await page.goto('/items');
await waitForAppReady(page);
await expectVisible(page.getByText('Hammer'));
});
test('create a consumable with quantity tracking', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Name *').fill('Printer Paper');
await page.getByLabel('Type').selectOption('consumable');
await page.getByLabel('Current').fill('8');
await page.getByLabel('Original').fill('10');
await page.getByLabel('Unit').fill('reams');
await page.getByRole('button', { name: 'Create Item' }).click();
await page.waitForURL(/\/items\//);
await expectVisible(page.getByText('Printer Paper'));
});
});

View file

@ -0,0 +1,38 @@
/**
* @feature Label Generation Flow
* @see e2e/features/flows/label-generation.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Label Generation Flow', () => {
test('generate a batch of IDs', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
await expectVisible(page.locator('.text-xs').filter({ hasText: 'Available IDs' }));
await page.locator('#batchSize').selectOption('10');
await page.getByRole('button', { name: 'Generate' }).click();
// Wait for generation to complete
await page.waitForFunction(() => {
const el = document.querySelector('.text-blue-400.text-2xl');
return el && el.textContent !== '0';
}, { timeout: 10000 });
});
test('labels page shows batch options', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
await expectVisible(page.getByText('Generate ID Batch'));
await expectVisible(page.getByText('Print Label Sheet'));
});
});

View file

@ -0,0 +1,21 @@
/**
* @feature Scan and Action Flow
* @see e2e/features/flows/scan-and-action.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Scan and Action Flow', () => {
test('scan page shows scanner controls', async () => {
const page = getPage();
await page.goto('/scan');
await waitForAppReady(page);
await expectVisible(page.getByText('Scan Barcode'));
await expectVisible(page.getByText('Point your camera'));
});
});

View file

@ -0,0 +1,60 @@
/**
* @feature Search and Filter Flow
* @see e2e/features/flows/search-and-filter.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems } from '../../support/seed';
import { buildItem } from '../../support/item-factory';
import { expectVisible, expectNotVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Search and Filter Flow', () => {
test('search by item name', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [
buildItem({ name: 'Laptop', category: 'Electronics' }),
buildItem({ name: 'Toaster', category: 'Kitchen' }),
]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByPlaceholder('Search items...').fill('Laptop');
await expectVisible(page.getByText('Laptop'));
await expectNotVisible(page.getByText('Toaster'));
});
test('clear search shows all items', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [
buildItem({ name: 'Laptop', category: 'Electronics' }),
buildItem({ name: 'Toaster', category: 'Kitchen' }),
]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByPlaceholder('Search items...').fill('Laptop');
await expectNotVisible(page.getByText('Toaster'));
await page.getByPlaceholder('Search items...').clear();
await expectVisible(page.getByText('Laptop'));
await expectVisible(page.getByText('Toaster'));
});
test('no results message for empty search', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [buildItem({ name: 'Laptop', category: 'Electronics' })]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByPlaceholder('Search items...').fill('xyznotfound');
await expectVisible(page.getByText('No items match your filters'));
});
});

View file

@ -0,0 +1,121 @@
/**
* @feature Dashboard
* @see e2e/features/pages/dashboard.feature
*/
import { describe, test, expect } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems, clearItems } from '../../support/seed';
import { buildItem, buildCheckedOutItem, buildConsumableItem, resetCounter } from '../../support/item-factory';
import { assertMinTouchTarget, assertWithinViewport } from '../../support/layout';
import { expectVisible, expectText } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Dashboard', () => {
test('empty state shows call-to-action', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.reload();
await waitForAppReady(page);
await expectVisible(page.getByText('No items yet'));
await expectVisible(page.getByText('Add First Item'));
await expectText(page.getByRole('heading', { level: 1 }), 'SolidHaus');
});
test('stats grid shows inventory summary', async () => {
const page = getPage();
resetCounter();
await page.goto('/');
await waitForAppReady(page);
const items = Array.from({ length: 5 }, (_, i) =>
buildItem({ name: `Item ${i + 1}`, category: i % 2 === 0 ? 'Electronics' : 'Kitchen' })
);
await seedItems(page, items);
await page.reload();
await waitForAppReady(page);
await expectVisible(page.getByText('Total Items'));
await expectVisible(page.getByText('Checked Out'));
await expectVisible(page.getByText('Overdue'));
await expectVisible(page.getByText('Low Stock'));
});
test('checked-out section appears when items are out', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
const item = buildCheckedOutItem({ name: 'Drill' });
await seedItems(page, [item]);
await page.reload();
await waitForAppReady(page);
await expectVisible(page.getByText('Drill').first());
});
test('low-stock section appears for depleted consumables', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
const item = buildConsumableItem({
name: 'Batteries',
currentQuantity: 2,
originalQuantity: 10,
lowThreshold: 5,
});
await seedItems(page, [item]);
await page.reload();
await waitForAppReady(page);
await expectVisible(page.getByText('Batteries').first());
});
test('recently updated items are shown', async () => {
const page = getPage();
resetCounter();
await page.goto('/');
await waitForAppReady(page);
const items = Array.from({ length: 3 }, (_, i) => buildItem({ name: `Item ${i + 1}` }));
await seedItems(page, items);
await page.reload();
await waitForAppReady(page);
await expectVisible(page.getByText('Recently Updated'));
});
test('navigation bar is visible and accessible', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
const navLinks = page.locator('nav a');
const count = await navLinks.count();
expect(count).toBe(5);
for (let i = 0; i < count; i++) {
await assertMinTouchTarget(navLinks.nth(i));
}
await assertWithinViewport(page.locator('nav'), page);
});
test('dashboard screenshot', async () => {
const page = getPage();
resetCounter();
await page.goto('/');
await waitForAppReady(page);
const items = Array.from({ length: 3 }, (_, i) => buildItem({ name: `Item ${i + 1}` }));
await seedItems(page, items);
await page.reload();
await waitForAppReady(page);
const screenshot = await page.screenshot();
expect(screenshot).toBeInstanceOf(Buffer);
});
});

View file

@ -0,0 +1,113 @@
/**
* @feature Item Detail
* @see e2e/features/pages/item-detail.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems } from '../../support/seed';
import { buildItem, buildConsumableItem } from '../../support/item-factory';
import { expectVisible, expectText } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Item Detail', () => {
test('item details are displayed', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
await expectText(page.getByRole('heading', { level: 1 }), 'Drill');
await expectVisible(page.getByText('Tools'));
await expectVisible(page.getByText('Type'));
await expectVisible(page.getByText('Storage Tier'));
});
test('custody badge shows checked-in state', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
await expectVisible(page.getByText('Home'));
});
test('check out form appears on button click', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
await page.getByRole('button', { name: 'Check Out' }).click();
await expectVisible(page.getByText('Reason'));
await expectVisible(page.getByText('Destination'));
});
test('edit button enters edit mode', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
await page.getByRole('button', { name: 'Edit' }).click();
await expectVisible(page.getByText('Edit Item'));
await expectVisible(page.getByText('Save Changes'));
});
test('delete requires confirmation', async () => {
const page = getPage();
const item = buildItem({ name: 'Drill', category: 'Tools' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Drill').click();
await page.getByRole('button', { name: 'Delete' }).click();
await expectVisible(page.getByText('Confirm Delete'));
});
test('quantity bar shown for consumables', async () => {
const page = getPage();
const item = buildConsumableItem({ name: 'Coffee Beans', currentQuantity: 3, originalQuantity: 10 });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByText('Coffee Beans').click();
await expectVisible(page.getByText('Quantity'));
});
test('item not found shows error', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await page.goto('/items/nonexistent');
await waitForAppReady(page);
await expectVisible(page.getByText('Item not found'));
await expectVisible(page.getByText('Back to items'));
});
});

View file

@ -0,0 +1,79 @@
/**
* @feature New Item Form
* @see e2e/features/pages/item-new.feature
*/
import { describe, test, expect } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible, expectNotVisible, expectText, expectDisabled } from '../../support/expect';
const { getPage } = setupBrowser();
describe('New Item Form', () => {
test('form displays all required fields', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await expectText(page.getByRole('heading', { level: 1 }), 'New Item');
await expectVisible(page.getByText('Name *'));
await expectVisible(page.getByText('Type'));
await expectVisible(page.getByText('Create Item'));
});
test('submit button disabled without name', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await expectDisabled(page.getByRole('button', { name: 'Create Item' }));
});
test('cancel returns to items list', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByRole('button', { name: 'Cancel' }).click();
expect(page.url()).toContain('/items');
});
test('consumable type shows quantity fields', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Type').selectOption('consumable');
await expectVisible(page.getByText('Quantity Tracking'));
await expectVisible(page.getByText('Current'));
await expectVisible(page.getByText('Original'));
});
test('perishable type shows expiry date', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Type').selectOption('perishable');
await expectVisible(page.getByText('Expiry Date'));
});
test('durable type hides quantity fields', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await page.getByLabel('Type').selectOption('durable');
await expectNotVisible(page.getByText('Quantity Tracking'));
});
test('storage tier buttons are visible', async () => {
const page = getPage();
await page.goto('/items/new');
await waitForAppReady(page);
await expectVisible(page.getByText('Hot'));
await expectVisible(page.getByText('Warm'));
await expectVisible(page.getByText('Cold'));
});
});

View file

@ -0,0 +1,89 @@
/**
* @feature Items List
* @see e2e/features/pages/items-list.feature
*/
import { describe, test, expect } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems, clearItems } from '../../support/seed';
import { buildItem, resetCounter } from '../../support/item-factory';
import { expectVisible, expectNotVisible } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Items List', () => {
test('empty inventory shows placeholder', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await clearItems(page);
await page.goto('/items');
await waitForAppReady(page);
await expectVisible(page.getByText('No items yet'));
});
test('items are listed with details', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [buildItem({ name: 'Laptop', category: 'Electronics' })]);
await page.goto('/items');
await waitForAppReady(page);
await expectVisible(page.getByText('Laptop'));
// 'Electronics' appears in both the filter dropdown and the item card
await expectVisible(page.locator('button').filter({ hasText: 'Electronics' }));
});
test('search filters items by name', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [
buildItem({ name: 'Laptop', category: 'Electronics' }),
buildItem({ name: 'Toaster', category: 'Kitchen' }),
]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByPlaceholder('Search items...').fill('Laptop');
await expectVisible(page.getByText('Laptop'));
await expectNotVisible(page.getByText('Toaster'));
});
test('item count is displayed', async () => {
const page = getPage();
resetCounter();
await page.goto('/');
await waitForAppReady(page);
const items = Array.from({ length: 5 }, (_, i) => buildItem({ name: `Item ${i + 1}` }));
await seedItems(page, items);
await page.goto('/items');
await waitForAppReady(page);
await expectVisible(page.getByText('5 of 5 items'));
});
test('filtered count reflects active filters', async () => {
const page = getPage();
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [
buildItem({ name: 'Laptop', category: 'Electronics' }),
buildItem({ name: 'Toaster', category: 'Kitchen' }),
]);
await page.goto('/items');
await waitForAppReady(page);
await page.getByPlaceholder('Search items...').fill('Laptop');
await expectVisible(page.getByText('1 of 2 items'));
});
test('new item button is visible', async () => {
const page = getPage();
await page.goto('/items');
await waitForAppReady(page);
await expectVisible(page.getByText('+ New'));
});
});

View file

@ -0,0 +1,51 @@
/**
* @feature Labels
* @see e2e/features/pages/labels.feature
*/
import { describe, test, expect } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible, expectText, expectDisabled } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Labels', () => {
test('label page shows stats', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
await expectText(page.getByRole('heading', { level: 1 }), 'Labels');
await expectVisible(page.getByText('Available IDs'));
await expectVisible(page.getByText('Assigned Items'));
});
test('batch size options are available', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
// Options inside <select> are not "visible" in the DOM sense — check they exist
const select = page.locator('#batchSize');
await select.waitFor({ state: 'visible', timeout: 5000 });
const options = await select.locator('option').allTextContents();
expect(options).toContain('10 IDs');
expect(options).toContain('50 IDs (1 sheet)');
});
test('generate button is present', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
await expectVisible(page.getByRole('button', { name: 'Generate' }));
});
test('print button disabled when no IDs', async () => {
const page = getPage();
await page.goto('/labels');
await waitForAppReady(page);
await expectDisabled(page.getByRole('button', { name: /Download PDF/ }));
});
});

View file

@ -0,0 +1,56 @@
/**
* @feature Locations
* @see e2e/features/pages/locations.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady, seedItems } from '../../support/seed';
import { buildItemAtLocation } from '../../support/item-factory';
import { expectVisible, expectText } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Locations', () => {
test('location tree shows default locations', async () => {
const page = getPage();
await page.goto('/locations');
await waitForAppReady(page);
await expectText(page.getByRole('heading', { level: 1 }), 'Places');
await expectVisible(page.getByText('Home'));
});
test('placeholder shown when no location selected', async () => {
const page = getPage();
await page.goto('/locations');
await waitForAppReady(page);
await expectVisible(page.getByText('Select a location to view items'));
});
test('selecting a location shows its items', async () => {
const page = getPage();
const item = buildItemAtLocation('kueche', { name: 'Blender' });
await page.goto('/');
await waitForAppReady(page);
await seedItems(page, [item]);
await page.goto('/locations');
await waitForAppReady(page);
// Expand Erdgeschoss (click toggle, not the location name) to reveal Küche
await page.locator('.select-none').filter({ hasText: 'Erdgeschoss' }).getByLabel('Expand').click();
await page.getByText('Küche').click();
await expectVisible(page.getByText('Blender'));
});
test('empty location shows placeholder', async () => {
const page = getPage();
await page.goto('/locations');
await waitForAppReady(page);
// Expand Keller (click toggle, not the location name) to reveal Werkstatt
await page.locator('.select-none').filter({ hasText: 'Keller' }).getByLabel('Expand').click();
await page.getByText('Werkstatt').click();
await expectVisible(page.getByText('No items here'));
});
});

View file

@ -0,0 +1,22 @@
/**
* @feature Scan Page
* @see e2e/features/pages/scan.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible, expectText } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Scan Page', () => {
test('scanner is displayed', async () => {
const page = getPage();
await page.goto('/scan');
await waitForAppReady(page);
await expectText(page.getByRole('heading', { level: 1 }), 'Scan');
await expectVisible(page.getByText('Scan Barcode'));
await expectVisible(page.getByText('Point your camera'));
});
});

View file

@ -0,0 +1,41 @@
/**
* @feature Settings
* @see e2e/features/pages/settings.feature
*/
import { describe, test } from 'vitest';
import { setupBrowser } from '../../support/browser';
import { waitForAppReady } from '../../support/seed';
import { expectVisible, expectText } from '../../support/expect';
const { getPage } = setupBrowser();
describe('Settings', () => {
test('settings page shows sections', async () => {
const page = getPage();
await page.goto('/settings');
await waitForAppReady(page);
await expectText(page.getByRole('heading', { level: 1 }), 'Settings');
await expectVisible(page.getByText('Database'));
await expectVisible(page.getByRole('heading', { name: 'Sync' }));
await expectVisible(page.getByRole('heading', { name: 'About' }));
});
test('database stats show counts', async () => {
const page = getPage();
await page.goto('/settings');
await waitForAppReady(page);
await expectVisible(page.locator('main').getByText('Items'));
await expectVisible(page.locator('main').getByText('Locations'));
});
test('about section shows version', async () => {
const page = getPage();
await page.goto('/settings');
await waitForAppReady(page);
await expectVisible(page.getByText('SolidHaus v0.1.0'));
await expectVisible(page.getByText('Data is stored locally'));
});
});

143
e2e/steps/common.steps.ts Normal file
View file

@ -0,0 +1,143 @@
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);
});

View file

@ -0,0 +1,17 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the item should show as checked out', async ({ page }) => {
await expect(page.getByText('Out')).toBeVisible();
});
then('the item should show as checked in', async ({ page }) => {
await expect(page.getByText('Home')).toBeVisible();
});
then('the check-in form should be visible', async ({ page }) => {
await expect(page.getByText('Return to')).toBeVisible();
await expect(page.getByRole('button', { name: 'Check In' })).toBeVisible();
});

View file

@ -0,0 +1,15 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the item should have a 7-character ID', async ({ page }) => {
const idEl = page.locator('.font-mono.text-lg');
const text = await idEl.textContent();
expect(text?.trim()).toMatch(/^[23456789a-hjkmnp-z]{7}$/);
});
then('the barcode URI should contain the ID', async ({ page }) => {
const uri = page.locator('.font-mono').filter({ hasText: 'haus.toph.so' });
await expect(uri).toBeVisible();
});

View file

@ -0,0 +1,14 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the ID grid should show generated IDs', async ({ page }) => {
const idGrid = page.locator('.font-mono.text-xs');
const count = await idGrid.count();
expect(count).toBeGreaterThan(0);
});
then('the download button should show the correct count', async ({ page }, count: string) => {
await expect(page.getByRole('button', { name: /Download PDF/ })).toContainText(count);
});

View file

@ -0,0 +1,17 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
// E2E scan injection is handled by common steps and Scanner component hook
// These are additional scan-flow-specific steps
then('the scanned item card should be visible', async ({ page }) => {
const card = page.locator('button.bg-slate-800').first();
await expect(card).toBeVisible();
});
then('the action buttons should be visible', async ({ page }) => {
const buttons = page.getByRole('button');
await expect(buttons.first()).toBeVisible();
});

View file

@ -0,0 +1,12 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the item count should show {string}', async ({ page }, text: string) => {
await expect(page.getByText(text)).toBeVisible();
});
when('I clear the search field', async ({ page }) => {
await page.getByPlaceholder('Search items...').clear();
});

View file

@ -0,0 +1,16 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
import { buildItem, buildCheckedOutItem, buildConsumableItem } from '../../support/item-factory';
import { seedAndNavigate } from '../../support/seed';
const { Given: given, When: when, Then: then } = createBdd();
then('I should see {int} stat cards', async ({ page }, count: number) => {
const cards = page.locator('.grid .bg-slate-800');
await expect(cards).toHaveCount(count);
});
then('the {string} stat should show {string}', async ({ page }, label: string, value: string) => {
const card = page.locator('.bg-slate-800').filter({ hasText: label });
await expect(card.locator('.text-2xl')).toHaveText(value);
});

View file

@ -0,0 +1,18 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the custody badge should show {string}', async ({ page }, text: string) => {
const badge = page.locator('[class*="bg-emerald"], [class*="bg-amber"]').filter({ hasText: text });
await expect(badge).toBeVisible();
});
then('I should see the quantity bar', async ({ page }) => {
const bar = page.locator('.bg-slate-700 .rounded-full');
await expect(bar).toBeVisible();
});
then('the quantity should show {string}', async ({ page }, text: string) => {
await expect(page.getByText(text)).toBeVisible();
});

View file

@ -0,0 +1,12 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the submit button should be disabled', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Create Item' })).toBeDisabled();
});
then('the submit button should be enabled', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Create Item' })).toBeEnabled();
});

View file

@ -0,0 +1,21 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('I should see {int} item cards', async ({ page }, count: number) => {
const cards = page.locator('button.bg-slate-800');
await expect(cards).toHaveCount(count);
});
when('I select category {string}', async ({ page }, category: string) => {
await page.locator('select').filter({ hasText: 'All Categories' }).selectOption(category);
});
when('I select type {string}', async ({ page }, type: string) => {
await page.locator('select').filter({ hasText: 'All Types' }).selectOption(type);
});
when('I select status {string}', async ({ page }, status: string) => {
await page.locator('select').filter({ hasText: 'All Status' }).selectOption(status);
});

View file

@ -0,0 +1,13 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the available IDs count should be {int}', async ({ page }, count: number) => {
const countEl = page.locator('.text-blue-400.text-2xl');
await expect(countEl).toHaveText(String(count));
});
then('the print button should be disabled', async ({ page }) => {
await expect(page.getByRole('button', { name: /Download PDF/ })).toBeDisabled();
});

View file

@ -0,0 +1,15 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('I should see {int} items at the selected location', async ({ page }, count: number) => {
const items = page.locator('button.bg-slate-800');
await expect(items).toHaveCount(count);
});
then('the location tree should show item counts', async ({ page }) => {
// Item counts appear in the tree nodes
const counts = page.locator('.text-slate-500').filter({ hasText: /\d+ items?/ });
await expect(counts.first()).toBeVisible();
});

View file

@ -0,0 +1,8 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the scan button should be visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Scan Barcode' })).toBeVisible();
});

View file

@ -0,0 +1,9 @@
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
const { Given: given, When: when, Then: then } = createBdd();
then('the items count should be {string}', async ({ page }, count: string) => {
const row = page.locator('.flex.justify-between').filter({ hasText: 'Items' });
await expect(row.locator('.text-white')).toHaveText(count);
});

70
e2e/support/browser.ts Normal file
View file

@ -0,0 +1,70 @@
import { chromium, type Browser, type Page, type BrowserContext } from 'playwright-core';
import { e2eConfig } from '../playwright.config';
import { afterAll, beforeAll, beforeEach, afterEach } from 'vitest';
import { execSync } from 'child_process';
let browser: Browser;
let context: BrowserContext;
let page: Page;
/**
* Find the Chromium executable installed by Playwright.
*/
function findChromium(): string {
// Check standard Playwright cache locations
const cacheDir = process.env.PLAYWRIGHT_BROWSERS_PATH
|| `${process.env.HOME}/Library/Caches/ms-playwright`;
try {
const result = execSync(
`find "${cacheDir}" -name "headless_shell" -type f 2>/dev/null | head -1`,
{ encoding: 'utf-8' }
).trim();
if (result) return result;
} catch {}
try {
const result = execSync(
`find "${cacheDir}" -name "chromium" -type f -not -path "*/node_modules/*" 2>/dev/null | head -1`,
{ encoding: 'utf-8' }
).trim();
if (result) return result;
} catch {}
// Fall back to system Chrome
const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
return systemChrome;
}
export function setupBrowser() {
beforeAll(async () => {
const executablePath = findChromium();
browser = await chromium.launch({
executablePath,
headless: true,
});
});
beforeEach(async () => {
context = await browser.newContext({
viewport: e2eConfig.viewport,
colorScheme: e2eConfig.colorScheme,
baseURL: e2eConfig.baseURL,
});
page = await context.newPage();
});
afterEach(async () => {
await context?.close();
});
afterAll(async () => {
await browser?.close();
});
return {
getPage: () => page,
getContext: () => context,
getBrowser: () => browser,
};
}

40
e2e/support/expect.ts Normal file
View file

@ -0,0 +1,40 @@
import type { Locator } from 'playwright-core';
import { expect } from 'vitest';
/**
* Assert that a Playwright locator is visible.
* Waits up to 5 seconds for the element to become visible.
*/
export async function expectVisible(locator: Locator, timeout = 5000) {
await locator.waitFor({ state: 'visible', timeout });
expect(await locator.isVisible()).toBe(true);
}
/**
* Assert that a Playwright locator is NOT visible.
* Waits briefly to ensure it doesn't appear.
*/
export async function expectNotVisible(locator: Locator, timeout = 2000) {
try {
await locator.waitFor({ state: 'hidden', timeout });
} catch {
// Element may not exist at all, which is fine
}
expect(await locator.isVisible()).toBe(false);
}
/**
* Assert that a locator has specific text content.
*/
export async function expectText(locator: Locator, text: string) {
await locator.waitFor({ state: 'visible', timeout: 5000 });
expect(await locator.textContent()).toBe(text);
}
/**
* Assert that a locator is disabled.
*/
export async function expectDisabled(locator: Locator) {
await locator.waitFor({ state: 'visible', timeout: 5000 });
expect(await locator.isDisabled()).toBe(true);
}

31
e2e/support/fixtures.ts Normal file
View file

@ -0,0 +1,31 @@
import { test as base } from 'playwright-bdd';
import { waitForAppReady, seedItems, seedAndNavigate } from './seed';
import { assertMinTouchTarget, assertGapBetween, assertWithinViewport } from './layout';
import type { Item } from '../../src/lib/types';
export const test = base.extend<{
appReady: void;
seedData: (items: Item[], path?: string) => Promise<void>;
layout: {
assertMinTouchTarget: typeof assertMinTouchTarget;
assertGapBetween: typeof assertGapBetween;
assertWithinViewport: typeof assertWithinViewport;
};
}>({
appReady: [async ({ page }, use) => {
await page.goto('/');
await waitForAppReady(page);
await use();
}, { auto: false }],
seedData: async ({ page }, use) => {
const seed = async (items: Item[], path = '/') => {
await seedAndNavigate(page, items, path);
};
await use(seed);
},
layout: async ({}, use) => {
await use({ assertMinTouchTarget, assertGapBetween, assertWithinViewport });
},
});

102
e2e/support/item-factory.ts Normal file
View file

@ -0,0 +1,102 @@
import type { Item } from '../../src/lib/types';
let counter = 0;
function nextId(): string {
counter++;
const chars = '23456789abcdefghjkmnpqrstuvwxyz';
let id = '';
let n = counter;
for (let i = 0; i < 7; i++) {
id += chars[n % chars.length];
n = Math.floor(n / chars.length);
}
return id;
}
export interface ItemOverrides extends Partial<Item> {}
export function buildItem(overrides: ItemOverrides = {}): Item {
const shortId = overrides.shortId ?? nextId();
const now = new Date().toISOString();
return {
shortId,
name: `Test Item ${shortId}`,
description: '',
category: '',
brand: '',
serialNumber: '',
color: '',
purchaseDate: null,
itemType: 'durable',
currentQuantity: null,
originalQuantity: null,
quantityUnit: null,
lowThreshold: null,
expiryDate: null,
barcodeFormat: 'qr',
barcodeUri: `https://haus.toph.so/${shortId}`,
photoIds: [],
lastSeenAt: null,
lastSeenTimestamp: null,
lastUsedAt: null,
supposedToBeAt: null,
locationConfidence: 'unknown',
custodyState: 'checked-in',
checkedOutSince: null,
checkedOutReason: null,
checkedOutFrom: null,
checkedOutTo: null,
checkedOutNote: null,
storageTier: 'warm',
storageContainerId: null,
storageContainerLabel: null,
labelPrinted: false,
labelPrintedAt: null,
labelBatchId: null,
createdAt: now,
updatedAt: now,
tags: [],
createdBy: null,
...overrides,
};
}
export function buildCheckedOutItem(overrides: ItemOverrides = {}): Item {
const tenDaysAgo = new Date(Date.now() - 10 * 86400000).toISOString();
return buildItem({
custodyState: 'checked-out',
checkedOutSince: tenDaysAgo,
checkedOutReason: 'in-use',
checkedOutFrom: 'kueche',
checkedOutTo: null,
checkedOutNote: '',
...overrides,
});
}
export function buildConsumableItem(overrides: ItemOverrides = {}): Item {
return buildItem({
itemType: 'consumable',
currentQuantity: 3,
originalQuantity: 10,
quantityUnit: 'pcs',
lowThreshold: 5,
...overrides,
});
}
export function buildItemAtLocation(locationId: string, overrides: ItemOverrides = {}): Item {
const now = new Date().toISOString();
return buildItem({
lastSeenAt: locationId,
lastSeenTimestamp: now,
supposedToBeAt: locationId,
locationConfidence: 'confirmed',
...overrides,
});
}
export function resetCounter() {
counter = 0;
}

53
e2e/support/layout.ts Normal file
View file

@ -0,0 +1,53 @@
import type { Locator, Page } from 'playwright-core';
import { expect } from 'vitest';
/**
* Assert that an element meets the minimum touch target size (44x44px per WCAG).
*/
export async function assertMinTouchTarget(locator: Locator, minSize = 44) {
const box = await locator.boundingBox();
expect(box, 'Element must be visible and have a bounding box').toBeTruthy();
expect(box!.width).toBeGreaterThanOrEqual(minSize);
expect(box!.height).toBeGreaterThanOrEqual(minSize);
}
/**
* Assert the gap between two elements in a given direction.
*/
export async function assertGapBetween(
locatorA: Locator,
locatorB: Locator,
options: { min?: number; max?: number; direction?: 'vertical' | 'horizontal' },
) {
const { min = 0, max = Infinity, direction = 'vertical' } = options;
const boxA = await locatorA.boundingBox();
const boxB = await locatorB.boundingBox();
expect(boxA, 'Element A must be visible').toBeTruthy();
expect(boxB, 'Element B must be visible').toBeTruthy();
let gap: number;
if (direction === 'vertical') {
gap = boxB!.y - (boxA!.y + boxA!.height);
} else {
gap = boxB!.x - (boxA!.x + boxA!.width);
}
expect(gap).toBeGreaterThanOrEqual(min);
expect(gap).toBeLessThanOrEqual(max);
}
/**
* Assert that an element is fully within the viewport (not clipped).
*/
export async function assertWithinViewport(locator: Locator, page: Page) {
const box = await locator.boundingBox();
expect(box, 'Element must be visible').toBeTruthy();
const viewport = page.viewportSize();
expect(viewport, 'Page must have a viewport').toBeTruthy();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height);
}

78
e2e/support/seed.ts Normal file
View file

@ -0,0 +1,78 @@
import type { Page } from 'playwright-core';
import type { Item } from '../../src/lib/types';
/**
* Wait for the app to finish loading (IDB initialized, default locations seeded).
*/
export async function waitForAppReady(page: Page) {
// Wait for SvelteKit to render (nav bar is always present in the layout)
await page.waitForSelector('nav', { timeout: 15000 });
// Then wait for the loading state to finish
await page.waitForFunction(() => {
return !document.body.textContent?.includes('Loading...');
}, { timeout: 15000 });
}
/**
* Seed items into IndexedDB via page.evaluate().
*/
export async function seedItems(page: Page, items: Item[]) {
await page.evaluate(async (itemsToSeed) => {
const request = indexedDB.open('solidhaus');
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const tx = db.transaction('items', 'readwrite');
const store = tx.objectStore('items');
for (const item of itemsToSeed) {
store.put(item);
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}, items);
}
/**
* Clear all items from IDB.
* Must be called after the app has loaded (IDB schema exists).
*/
export async function clearItems(page: Page) {
await page.evaluate(async () => {
const request = indexedDB.open('solidhaus');
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
// Check if the store exists before trying to clear it
if (db.objectStoreNames.contains('items')) {
const tx = db.transaction('items', 'readwrite');
tx.objectStore('items').clear();
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
db.close();
});
}
/**
* Navigate, seed data, reload so store picks up changes.
*/
export async function seedAndNavigate(page: Page, items: Item[], targetPath: string) {
await page.goto('/');
await waitForAppReady(page);
if (items.length > 0) {
await seedItems(page, items);
}
await page.goto(targetPath);
await waitForAppReady(page);
}

18
e2e/vitest.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
$lib: path.resolve('./src/lib'),
},
},
test: {
include: ['e2e/specs/**/*.spec.ts'],
testTimeout: 30000,
hookTimeout: 30000,
// Run sequentially — tests share browser state per describe block
pool: 'forks',
fileParallelism: false,
},
});

26
flake.nix Normal file
View file

@ -0,0 +1,26 @@
{
description = "solidhaus dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
forgejo-workflow.url = "path:/nix/store/1kpd2vzj87rlraw81p4iy4ldw4dm8g6z-forgejo-workflow-setup-flake/bin";
};
outputs = { self, nixpkgs, flake-utils, forgejo-workflow }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages =
# Forgejo workflow tools: tea, jq, yq, git, curl
forgejo-workflow.packages.${system}.tools
++ (with pkgs; [
# add your project packages here
]);
};
}
);
}

View file

@ -11,7 +11,9 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "vitest run --config e2e/vitest.config.ts",
"test:e2e:watch": "vitest --config e2e/vitest.config.ts"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
@ -23,6 +25,7 @@
"@testing-library/svelte": "^5.3.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright-core": "^1.58.2",
"svelte": "^5.51.0",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.2.1",