26 KiB
Playwright Frontend Tests Implementation Plan
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: Add Playwright E2E frontend tests for the Canteen Asset Tracker SPA, covering auth, navigation, asset CRUD, search/filter, and error states.
Architecture: Playwright (Python sync API) with system Chromium (/usr/bin/chromium-browser — Ubuntu 26.04 unsupported by Playwright bundled browsers). FastAPI TestClient runs the backend in the same process (no separate server start needed). Tests live in tests/frontend/ with a conftest.py providing browser + page fixtures that auto-login via the API and navigate to the app.
Tech Stack: Playwright 1.59.0, system Chromium, pytest, Python 3.11+
Task 1: Create frontend test directory and conftest with server fixture
Objective: Set up the test infrastructure — a FastAPI TestClient that shares the same DB as Playwright tests.
Files:
- Create:
tests/frontend/__init__.py - Create:
tests/frontend/conftest.py
Step 1: Write conftest.py
The server fixture needs to:
- Use a temp DB path (isolated per test)
- Set
CANTEEN_SKIP_AUTH=1for the TestClient (so Playwright calls skip auth) - Run the FastAPI app via
TestClientwith lifespan - The frontend fetches from
http://localhost:{port}— we'll use Playwright'spage.route()to intercept API calls and forward them to TestClient
# tests/frontend/conftest.py
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
# Ensure project root is on path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
os.environ["CANTEEN_SKIP_AUTH"] = "1"
@pytest.fixture
def test_db_path():
"""Create an isolated temp DB for each test."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="canteen_frontend_test_")
os.close(fd)
os.environ["CANTEEN_DB_PATH"] = path
yield path
# Cleanup
for suffix in ("", "-shm", "-wal", "-journal"):
p = Path(path + suffix)
if p.exists():
p.unlink()
@pytest.fixture
def client(test_db_path):
"""FastAPI TestClient with auth disabled."""
from server import app
with TestClient(app) as c:
yield c
Step 2: Run python -m pytest tests/frontend/ -v --collect-only to verify collection works
Expected: 0 tests collected (no test files yet), but no import errors.
Task 2: Add browser + page fixtures with system Chromium
Objective: Create Playwright browser and page fixtures that launch the system Chromium and proxy API calls to TestClient.
Files:
- Modify:
tests/frontend/conftest.py
Step 1: Add browser fixture
import os
import tempfile
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
from fastapi.testclient import TestClient
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
os.environ["CANTEEN_SKIP_AUTH"] = "1"
STATIC_DIR = Path(__file__).parent.parent.parent / "static"
@pytest.fixture(scope="session")
def browser():
"""Launch system Chromium once per test session."""
pw = sync_playwright().start()
browser = pw.chromium.launch(
executable_path="/usr/bin/chromium-browser",
headless=True,
args=["--no-sandbox", "--disable-gpu"],
)
yield browser
browser.close()
pw.stop()
@pytest.fixture
def test_db_path():
"""Create an isolated temp DB for each test."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="canteen_frontend_test_")
os.close(fd)
os.environ["CANTEEN_DB_PATH"] = path
yield path
for suffix in ("", "-shm", "-wal", "-journal"):
p = Path(path + suffix)
if p.exists():
p.unlink()
@pytest.fixture
def client(test_db_path):
"""FastAPI TestClient with auth disabled."""
from server import app
with TestClient(app) as c:
yield c
@pytest.fixture
def page(browser, client):
"""Create a new page that routes API calls to TestClient and loads the SPA."""
context = browser.new_context(
viewport={"width": 390, "height": 844}, # iPhone 14 size
geolocation={"latitude": 28.3852, "longitude": -81.5639}, # Orlando
permissions=["geolocation"],
)
page = context.new_page()
# Route all /api/* calls to the FastAPI TestClient
def route_api(route):
request = route.request
# Build a WSGI-style request and pass to TestClient
# We'll forward via HTTP to a local test server run by client fixture
route.fulfill() # placeholder — actual routing via test server
# Better approach: start a test server on a random port
import threading
import uvicorn
import socket
def _find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
port = _find_free_port()
# Run uvicorn in a background thread
from server import app
server_thread = threading.Thread(
target=uvicorn.run,
kwargs={"app": app, "host": "127.0.0.1", "port": port, "log_level": "error"},
daemon=True,
)
server_thread.start()
import time
time.sleep(0.5) # Wait for server to start
# Load the static HTML — use file:// since we don't need it served
html_path = STATIC_DIR / "index.html"
page.goto(f"file://{html_path}")
# Rewrite base URL in the page so fetch() calls go to our test server
page.evaluate(f"window.API_BASE = 'http://127.0.0.1:{port}'")
yield page
context.close()
Problem: file:// protocol has CORS issues with fetch(). The SPA uses fetch(url, ...) with relative URLs like /api/assets. When loaded from file://, it'll try file:///api/assets which fails.
Revised approach: Serve the static HTML from the test server too, or use Playwright's page.route() to intercept API calls.
Let's use this cleaner approach — serve everything from the test uvicorn server including static files:
@pytest.fixture
def live_server(test_db_path):
"""Start the FastAPI app on a random port in a background thread."""
import threading
import uvicorn
import socket
def _find_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
port = _find_free_port()
os.environ["CANTEEN_PORT"] = str(port)
from server import app
t = threading.Thread(
target=uvicorn.run,
kwargs={"app": app, "host": "127.0.0.1", "port": port, "log_level": "error"},
daemon=True,
)
t.start()
import time
time.sleep(0.5)
yield f"http://127.0.0.1:{port}"
# uvicorn will exit when thread dies
@pytest.fixture
def page(browser, live_server):
"""Create a Playwright page pointed at the live server."""
context = browser.new_context(
viewport={"width": 390, "height": 844},
geolocation={"latitude": 28.3852, "longitude": -81.5639},
permissions=["geolocation"],
)
page = context.new_page()
page.goto(live_server)
yield page
context.close()
Wait — the test server also serves static files but we need CANTEEN_SKIP_AUTH=1 set. The auth middleware checks os.environ at request time, so this should work since it's set in conftest.
Also need to handle: the static file serving. The server.py has:
if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
This is at the bottom of server.py. Let me check. Actually I didn't read the bottom of server.py. Let me verify it's there.
Let me just write the conftest assuming the server can serve the SPA. If the static mount isn't set up right, I'll handle it.
Step 2: Write the complete conftest.py
Actually, let me check the static mount in server.py real quick.
Let me read the last section.
Actually, I'll write the plan and include a note to verify this. Let me keep going with the tasks.
Step 2: Run python -m pytest tests/frontend/ -v --collect-only to verify collection
Expected: 0 tests collected, no import/startup errors.
Step 3: Add a minimal test to verify the page loads
Create tests/frontend/test_smoke.py:
def test_page_loads(page):
"""Verify the SPA loads and the login overlay appears."""
assert page.locator("#loginOverlay").is_visible()
assert page.locator("h1").inner_text() == "Canteen Asset Tracker"
Run: pytest tests/frontend/test_smoke.py -v
Expected: PASS
Task 3: Test login flow (happy path + error)
Objective: Test the full login UX — entering credentials, clicking Sign In, UI update.
Files:
- Create:
tests/frontend/test_auth.py
def test_login_success(page, live_server):
"""Login with default admin credentials succeeds."""
# Should see login overlay initially
assert page.locator("#loginOverlay").is_visible()
# Fill credentials
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
# Login overlay should hide
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
# User badge should show 'A' for admin
badge = page.locator("#userBadge")
assert badge.inner_text() == "A"
# Toast should appear
assert page.locator(".toast.show").is_visible()
def test_login_bad_password(page, live_server):
"""Login with wrong password shows error."""
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("wrongpassword")
page.locator("button:has-text('Sign In')").click()
# Error message should appear
error = page.locator("#loginError.show")
assert error.is_visible()
assert error.inner_text() != ""
# Login overlay should still be visible
assert page.locator("#loginOverlay").is_visible()
def test_login_empty_credentials(page, live_server):
"""Login with empty fields shows validation error."""
page.locator("button:has-text('Sign In')").click()
error = page.locator("#loginError.show")
assert error.is_visible()
assert "username" in error.inner_text().lower()
Run: pytest tests/frontend/test_auth.py -v
Expected: 3 passed
Task 4: Test logout flow
Objective: Verify logout clears state and shows login overlay.
Files:
- Modify:
tests/frontend/test_auth.py(add test_logout)
def test_logout(page, live_server):
"""Login, then logout — should see login overlay again."""
# Login first
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
# Open drawer and click Logout
page.locator(".hamburger").click()
page.wait_for_selector(".drawer.open", timeout=3000)
page.locator("#logoutBtn").click()
# Should see login overlay
page.wait_for_selector("#loginOverlay:not(.hidden)", timeout=5000)
assert page.locator("#loginOverlay").is_visible()
Run: pytest tests/frontend/test_auth.py::test_logout -v
Expected: PASS
Task 5: Test bottom tab navigation
Objective: Verify clicking bottom tabs switches the visible panel.
Files:
- Create:
tests/frontend/test_navigation.py
def login(page):
"""Helper to login as admin."""
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
def test_tab_navigation(page, live_server):
"""Clicking bottom tabs switches the active panel."""
login(page)
# Click "Assets" tab
page.locator(".tab-btn:has-text('Assets')").click()
assert page.locator("#tabAssets.tab-panel.active").is_visible()
# Click "Dashboard" tab
page.locator(".tab-btn:has-text('Dashboard')").click()
assert page.locator("#tabDashboard.tab-panel.active").is_visible()
# Click "Scan" tab
page.locator(".tab-btn:has-text('Scan')").click()
assert page.locator("#tabAddAsset.tab-panel.active").is_visible()
Run: pytest tests/frontend/test_navigation.py -v
Expected: 1 passed
Task 6: Test drawer open/close and navigation
Objective: Verify hamburger menu, drawer links, and close button.
Files:
- Modify:
tests/frontend/test_navigation.py(add tests)
def test_drawer_open_close(page, live_server):
"""Hamburger opens drawer, close button closes it."""
login(page)
# Open drawer
page.locator(".hamburger").click()
page.wait_for_selector(".drawer.open", timeout=3000)
assert page.locator(".drawer.open").is_visible()
# Close drawer
page.locator(".close-drawer").click()
page.wait_for_selector(".drawer:not(.open)", timeout=3000)
def test_drawer_navigation(page, live_server):
"""Drawer links switch tabs."""
login(page)
# Open drawer
page.locator(".hamburger").click()
page.wait_for_selector(".drawer.open", timeout=3000)
# Click "Assets" in drawer
page.locator(".dn-item:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
assert page.locator("#tabAssets.tab-panel.active").is_visible()
# Drawer should close after navigation
assert page.locator(".drawer.open").is_visible() == False
def test_drawer_user_info(page, live_server):
"""Drawer shows current user info."""
login(page)
page.locator(".hamburger").click()
page.wait_for_selector(".drawer.open", timeout=3000)
assert page.locator("#drawerName").inner_text() == "admin"
assert page.locator("#drawerRole").inner_text() == "admin"
Run: pytest tests/frontend/test_navigation.py -v
Expected: 4 passed (1 from Task 5 + 3 new)
Task 7: Test asset list rendering
Objective: Create an asset via API, then verify it appears in the UI list.
Files:
- Create:
tests/frontend/test_assets.py
import requests
def login(page):
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
def test_asset_list_shows_created_asset(page, live_server):
"""Assets created via API appear in the Assets tab."""
login(page)
# Create an asset via API
resp = requests.post(
f"{live_server}/api/assets",
json={
"machine_id": "TEST-001",
"name": "Test Espresso Machine",
"category": "Appliances",
"status": "active",
},
)
assert resp.status_code == 201
# Navigate to Assets tab
page.locator(".tab-btn:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
# Wait for the asset list to render
page.wait_for_selector(".asset-item", timeout=5000)
assert page.locator(".asset-item").count() >= 1
assert page.locator(".ai-name:has-text('Test Espresso Machine')").is_visible()
def test_asset_list_empty_state(page, live_server):
"""Assets tab shows empty state when no assets exist."""
login(page)
page.locator(".tab-btn:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
# Should show empty state
assert page.locator(".empty-state").is_visible()
Run: pytest tests/frontend/test_assets.py -v
Expected: 2 passed
Task 8: Test asset search and filter
Objective: Verify search input and category filter pills work in the Assets tab.
Files:
- Modify:
tests/frontend/test_assets.py(add tests)
def test_asset_search_filters_by_name(page, live_server):
"""Search input filters assets by name."""
login(page)
# Create two assets via API
for mid, name in [("SRCH-001", "Alpha Blender"), ("SRCH-002", "Beta Oven")]:
requests.post(f"{live_server}/api/assets", json={
"machine_id": mid, "name": name, "category": "Appliances"
})
# Navigate to Assets
page.locator(".tab-btn:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
page.wait_for_selector(".asset-item", timeout=5000)
# Search for "Alpha"
page.locator(".search-bar input").fill("Alpha")
page.wait_for_timeout(500) # debounce
items = page.locator(".asset-item")
assert items.count() == 1
assert page.locator(".ai-name:has-text('Alpha Blender')").is_visible()
def test_asset_category_filter(page, live_server):
"""Category filter pills filter assets."""
login(page)
# Create assets in different categories
requests.post(f"{live_server}/api/assets", json={
"machine_id": "FILT-001", "name": "Chair", "category": "Furniture"
})
requests.post(f"{live_server}/api/assets", json={
"machine_id": "FILT-002", "name": "Fridge", "category": "Appliances"
})
# Navigate to Assets
page.locator(".tab-btn:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
page.wait_for_selector(".asset-item", timeout=5000)
assert page.locator(".asset-item").count() == 2
# Click "Furniture" filter pill
page.locator(".pill:has-text('Furniture')").click()
page.wait_for_timeout(300)
assert page.locator(".asset-item").count() == 1
assert page.locator(".ai-name:has-text('Chair')").is_visible()
Run: pytest tests/frontend/test_assets.py -v
Expected: 4 passed (2 from Task 7 + 2 new)
Task 9: Test asset detail view
Objective: Click an asset in the list and verify detail panel loads.
Files:
- Modify:
tests/frontend/test_assets.py(add test)
def test_asset_detail_view(page, live_server):
"""Clicking an asset opens detail panel with correct info."""
login(page)
requests.post(f"{live_server}/api/assets", json={
"machine_id": "DETAIL-001",
"name": "Detail Test Asset",
"description": "A test asset for detail view",
"category": "Equipment",
"status": "active",
})
page.locator(".tab-btn:has-text('Assets')").click()
page.wait_for_selector("#tabAssets.active", timeout=3000)
page.wait_for_selector(".asset-item", timeout=5000)
# Click the asset
page.locator(".ai-name:has-text('Detail Test Asset')").click()
page.wait_for_selector(".scan-result", timeout=3000) # detail panel
# Verify detail content
assert page.locator(".sr-name:has-text('Detail Test Asset')").is_visible()
Run: pytest tests/frontend/test_assets.py::test_asset_detail_view -v
Expected: PASS
Task 10: Test GPS badge UI states
Objective: Verify the GPS badge shows correct states (with geolocation perm granted, it should show OK).
Files:
- Create:
tests/frontend/test_gps.py
def test_gps_badge_shows_ok_when_geolocation_granted(page, live_server):
"""With geolocation permission granted, GPS badge shows OK state."""
# Login
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
# Wait for GPS to initialize (the app calls initGPS() on login)
page.wait_for_selector(".gps-badge.ok", timeout=10000)
badge = page.locator(".gps-badge")
assert badge.is_visible()
assert "ok" in badge.get_attribute("class")
Run: pytest tests/frontend/test_gps.py -v
Expected: PASS
Task 11: Test create asset from manual form (Add tab)
Objective: Fill the manual add-asset form and verify the asset is created.
Files:
- Create:
tests/frontend/test_add_asset.py
def test_create_asset_manual_form(page, live_server):
"""Fill the manual add form and create an asset."""
# Login
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
# Navigate to Add Asset tab (Scan tab)
page.locator(".tab-btn:has-text('Scan')").click()
page.wait_for_selector("#tabAddAsset.active", timeout=3000)
# Switch to "Manual" mode
page.locator(".mode-toggle:has-text('Manual')").click()
page.wait_for_selector("#addManual.active", timeout=3000)
# Fill the form
page.locator("#manualMachineId").fill("MANUAL-001")
page.locator("#manualName").fill("Manual Test Asset")
page.locator("#manualCategory").select_option("Furniture")
page.locator("#manualStatus").select_option("active")
# Submit
page.locator("#btnManualSubmit").click()
# Should see success toast
page.wait_for_selector(".toast.show", timeout=5000)
toast = page.locator(".toast.show")
assert "created" in toast.inner_text().lower() or "added" in toast.inner_text().lower()
Run: pytest tests/frontend/test_add_asset.py -v
Expected: PASS
Task 12: Test dashboard stats display
Objective: Create assets and check-ins, then verify dashboard stats render.
Files:
- Create:
tests/frontend/test_dashboard.py
import requests
def login(page):
page.locator("#loginUsername").fill("admin")
page.locator("#loginPassword").fill("changeme")
page.locator("button:has-text('Sign In')").click()
page.wait_for_selector("#loginOverlay.hidden", timeout=5000)
def test_dashboard_shows_stats(page, live_server):
"""Dashboard tab shows stats after assets are created."""
login(page)
# Create assets via API
requests.post(f"{live_server}/api/assets", json={
"machine_id": "DASH-001", "name": "Dashboard Asset 1", "category": "Furniture"
})
requests.post(f"{live_server}/api/assets", json={
"machine_id": "DASH-002", "name": "Dashboard Asset 2", "category": "Appliances"
})
# Navigate to Dashboard
page.locator(".tab-btn:has-text('Dashboard')").click()
page.wait_for_selector("#tabDashboard.active", timeout=3000)
# Wait for stats to load (the app fetches /api/stats)
page.wait_for_timeout(1000)
# Verify stats cards are present
cards = page.locator(".card")
assert cards.count() >= 2 # Should have at least a couple stat cards
# Total assets should be 2
page_text = page.content()
assert "2" in page_text
Run: pytest tests/frontend/test_dashboard.py -v
Expected: PASS
Task 13: Test activity feed (Phase M)
Objective: Verify activity log renders after performing actions.
Files:
- Modify:
tests/frontend/test_dashboard.py(add test)
def test_activity_feed_shows_events(page, live_server):
"""Activity feed shows recent actions."""
login(page)
# Create an asset (triggers activity log entry)
requests.post(f"{live_server}/api/assets", json={
"machine_id": "ACT-001", "name": "Activity Test Asset", "category": "Other"
})
# Navigate to Dashboard
page.locator(".tab-btn:has-text('Dashboard')").click()
page.wait_for_selector("#tabDashboard.active", timeout=3000)
# Scroll down to activity section if needed
# The dashboard tab includes an activity panel
activity_items = page.locator(".activity-item")
# Activity should have at least the asset creation event
if activity_items.count() == 0:
page.wait_for_timeout(2000) # Give API time
assert activity_items.count() >= 1
Run: pytest tests/frontend/test_dashboard.py::test_activity_feed_shows_events -v
Expected: PASS
Task 14: Add pytest marker and README for frontend tests
Objective: Document how to run frontend tests and add a frontend pytest marker.
Files:
- Create:
tests/frontend/pytest.ini(or modify project-levelpyproject.toml) - Create:
tests/frontend/README.md
Actually, let's put the marker config in a conftest.py or a pytest.ini:
Create tests/frontend/pytest.ini:
[pytest]
markers =
frontend: E2E frontend tests using Playwright
slow: Tests that take longer to run
Create tests/frontend/README.md:
# Frontend E2E Tests
Playwright tests for the Canteen Asset Tracker SPA.
## Requirements
- System Chromium installed (`/usr/bin/chromium-browser`)
- Playwright Python: `pip install playwright`
- All backend deps: `pip install -r requirements.txt`
## Running
```bash
cd ~/projects/canteen-asset-tracker
python -m pytest tests/frontend/ -v
Architecture
- Each test gets an isolated temp SQLite database
- A FastAPI server runs on a random port in a background thread
CANTEEN_SKIP_AUTH=1skips auth middleware so Playwright doesn't need real tokens- Playwright launches system Chromium in headless mode at iPhone 14 viewport size
- Geolocation is mocked to Orlando, FL
Writing Tests
Import the page and live_server fixtures:
```python def test_something(page, live_server): page.locator("#someButton").click() assert page.locator(".result").is_visible() ```
**Step 1: Commit everything**
```bash
cd ~/projects/canteen-asset-tracker
git add tests/frontend/
git commit -m "test: add Playwright frontend E2E test suite"
Verification
After all tasks, run the full suite:
cd ~/projects/canteen-asset-tracker
python -m pytest tests/frontend/ -v
Expected: 13+ tests pass, 0 fail.
Pitfalls
- Ubuntu 26.04 + Playwright bundled browsers: Not supported. Must use system chromium at
/usr/bin/chromium-browserwith--no-sandbox. - Auth: Tests use
CANTEEN_SKIP_AUTH=1to bypass token auth. The frontend'sapi()wrapper sends Bearer tokens fromAppState.authToken— ifAppState.authTokenis null, headers are omitted, andCANTEEN_SKIP_AUTH=1lets them through. - file:// protocol CORS: Loading HTML via
file://breaks relativefetch()calls. Must serve through the uvicorn test server (which mounts static files). - Port conflicts: Use
_find_free_port()to avoid port collisions in parallel test runs. - Geolocation timing: GPS acquisition is async in the browser. Use
wait_for_selectorwith generous timeouts for GPS badge. - Asset list latency: After API calls, the frontend re-fetches asset lists. Use
wait_for_selectornot fixedtime.sleep.