855 lines
26 KiB
Markdown
855 lines
26 KiB
Markdown
# 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=1` for the TestClient (so Playwright calls skip auth)
|
|
- Run the FastAPI app via `TestClient` with lifespan
|
|
- The frontend fetches from `http://localhost:{port}` — we'll use Playwright's `page.route()` to intercept API calls and forward them to TestClient
|
|
|
|
```python
|
|
# 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**
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
@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:
|
|
|
|
```python
|
|
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`:
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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-level `pyproject.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`:
|
|
```ini
|
|
[pytest]
|
|
markers =
|
|
frontend: E2E frontend tests using Playwright
|
|
slow: Tests that take longer to run
|
|
```
|
|
|
|
Create `tests/frontend/README.md`:
|
|
```markdown
|
|
# 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=1` skips 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:
|
|
|
|
```bash
|
|
cd ~/projects/canteen-asset-tracker
|
|
python -m pytest tests/frontend/ -v
|
|
```
|
|
|
|
Expected: 13+ tests pass, 0 fail.
|
|
|
|
---
|
|
|
|
## Pitfalls
|
|
|
|
1. **Ubuntu 26.04 + Playwright bundled browsers**: Not supported. Must use system chromium at `/usr/bin/chromium-browser` with `--no-sandbox`.
|
|
2. **Auth**: Tests use `CANTEEN_SKIP_AUTH=1` to bypass token auth. The frontend's `api()` wrapper sends Bearer tokens from `AppState.authToken` — if `AppState.authToken` is null, headers are omitted, and `CANTEEN_SKIP_AUTH=1` lets them through.
|
|
3. **file:// protocol CORS**: Loading HTML via `file://` breaks relative `fetch()` calls. Must serve through the uvicorn test server (which mounts static files).
|
|
4. **Port conflicts**: Use `_find_free_port()` to avoid port collisions in parallel test runs.
|
|
5. **Geolocation timing**: GPS acquisition is async in the browser. Use `wait_for_selector` with generous timeouts for GPS badge.
|
|
6. **Asset list latency**: After API calls, the frontend re-fetches asset lists. Use `wait_for_selector` not fixed `time.sleep`.
|