Initial commit: Canteen Asset Geolocation Tool v2
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# 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
|
||||
python3 -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()
|
||||
```
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Fixtures for Playwright frontend E2E tests.
|
||||
|
||||
Architecture:
|
||||
- Each test gets an isolated temp SQLite DB.
|
||||
- A FastAPI uvicorn 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 Google Chrome (Ubuntu 26.04 can't install bundled browsers,
|
||||
and Chrome 148 SIGTRAPs with certain --disable-features flags; ignore_default_args
|
||||
workaround applied).
|
||||
- Viewport: iPhone 14 (390x844), Geolocation: Orlando, FL.
|
||||
"""
|
||||
|
||||
# Chrome 148 on Ubuntu 26.04 (kernel 7.0) SIGTRAPs when Playwright's default
|
||||
# --disable-features and related flags are passed. Ignoring these defaults
|
||||
# allows Chrome to launch cleanly with DevTools protocol.
|
||||
CHROME_IGNORE_DEFAULTS = [
|
||||
'--disable-field-trial-config',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-breakpad',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-default-apps',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-extensions',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-sync',
|
||||
'--enable-automation',
|
||||
]
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Ensure project root is on path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Global env: skip auth for all tests
|
||||
os.environ["CANTEEN_SKIP_AUTH"] = "1"
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Find an available TCP port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
# ── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@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/google-chrome-stable",
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-gpu"],
|
||||
ignore_default_args=CHROME_IGNORE_DEFAULTS,
|
||||
)
|
||||
yield browser
|
||||
browser.close()
|
||||
pw.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_path():
|
||||
"""Create an isolated temp SQLite 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 DB and WAL/SHM/journal files
|
||||
for suffix in ("", "-shm", "-wal", "-journal"):
|
||||
p = Path(path + suffix)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_server(test_db_path):
|
||||
"""Start FastAPI + uvicorn on a random port in a background thread.
|
||||
|
||||
Returns the base URL (e.g. 'http://127.0.0.1:12345').
|
||||
"""
|
||||
port = _find_free_port()
|
||||
os.environ["CANTEEN_PORT"] = str(port)
|
||||
|
||||
# Reload the server module so DB_PATH picks up the current
|
||||
# CANTEEN_DB_PATH (module-level constant read at import time).
|
||||
import server
|
||||
importlib.reload(server)
|
||||
app = server.app
|
||||
|
||||
t = threading.Thread(
|
||||
target=uvicorn.run,
|
||||
kwargs={
|
||||
"app": "server:app",
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"log_level": "error",
|
||||
},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
# Wait for server to be ready
|
||||
deadline = time.time() + 10
|
||||
import urllib.request
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
urllib.request.urlopen(f"{base_url}/", timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError(f"Server did not start on {base_url} within 10s")
|
||||
|
||||
yield base_url
|
||||
# Thread is daemon, will exit when test process ends
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser, live_server):
|
||||
"""Create a Playwright page pointed at the live server.
|
||||
|
||||
iPhone 14 viewport, Orlando FL geolocation, geolocation permission granted.
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""API-level E2E tests for Canteen Asset Tracker."""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
|
||||
BASE = "https://canteen.ourpad.casa"
|
||||
results = {"passed": [], "failed": []}
|
||||
|
||||
def report(name, ok, detail=""):
|
||||
if ok:
|
||||
results["passed"].append(name)
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
results["failed"].append((name, detail))
|
||||
print(f" ❌ {name}: {detail}")
|
||||
|
||||
def api(path, method="GET", token=None, json_data=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if json_data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
url = f"{BASE}{path}"
|
||||
if method == "GET":
|
||||
r = requests.get(url, headers=headers, verify=False, timeout=15)
|
||||
elif method == "POST":
|
||||
r = requests.post(url, headers=headers, json=json_data, verify=False, timeout=15)
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
try:
|
||||
return r.status_code, r.json() if r.text else None
|
||||
except:
|
||||
return r.status_code, r.text
|
||||
|
||||
import urllib3
|
||||
urllib3.disable_warnings()
|
||||
|
||||
# ── 1. App reachable ──
|
||||
print("\n── 1. App Reachability ──")
|
||||
code, _ = api("/")
|
||||
report("App responds HTTP 200", code == 200, f"got {code}")
|
||||
|
||||
# ── 2. Login ──
|
||||
print("\n── 2. Login Flow ──")
|
||||
code, data = api("/api/auth/login", method="POST", json_data={"username": "admin", "password": "changeme"})
|
||||
login_ok = code == 200 and data and data.get("token")
|
||||
report("Login returns token", login_ok, f"code={code}, keys={list(data.keys()) if data else 'none'}")
|
||||
token = data.get("token") if data else None
|
||||
|
||||
# ── 3. Auth check ──
|
||||
print("\n── 3. Auth Verification ──")
|
||||
if token:
|
||||
code, me = api("/api/auth/me", token=token)
|
||||
me_ok = code == 200 and me and me.get("username") == "admin"
|
||||
report("Auth /me returns admin", me_ok, f"code={code}, data={str(me)[:200]}")
|
||||
else:
|
||||
report("Auth /me", False, "no token")
|
||||
|
||||
# ── 4. Assets CRUD ──
|
||||
print("\n── 4. Assets API ──")
|
||||
if token:
|
||||
code, assets = api("/api/assets", token=token)
|
||||
assets_ok = code == 200 and isinstance(assets, list)
|
||||
asset_count = len(assets) if isinstance(assets, list) else 0
|
||||
report(f"GET /api/assets returns list ({asset_count} items)", assets_ok, f"code={code}")
|
||||
|
||||
# Create asset (use valid category from seed data: Furniture, Appliances, etc.)
|
||||
test_mid = f"E2E-API-{int(time.time())}"
|
||||
code, created = api("/api/assets", method="POST", token=token, json_data={
|
||||
"machine_id": test_mid,
|
||||
"name": "E2E API Test Asset",
|
||||
"description": "Created via API E2E test",
|
||||
"category": "Equipment",
|
||||
"status": "active"
|
||||
})
|
||||
created_ok = code in (200, 201) and created and created.get("machine_id") == test_mid
|
||||
report("POST /api/assets creates asset", created_ok, f"code={code}, data={str(created)[:200]}")
|
||||
|
||||
# Verify in list
|
||||
if created_ok:
|
||||
code, assets2 = api("/api/assets", token=token)
|
||||
found = any(a.get("machine_id") == test_mid for a in assets2) if isinstance(assets2, list) else False
|
||||
report("New asset appears in list", found)
|
||||
|
||||
# ── 5. Public endpoints ──
|
||||
print("\n── 5. Public Endpoints ──")
|
||||
endpoints = [
|
||||
("/api/customers", "Customers"),
|
||||
("/api/locations", "Locations"),
|
||||
("/api/settings/categories", "Categories (settings)"),
|
||||
("/api/activity", "Activity feed"),
|
||||
("/api/stats", "Dashboard stats"),
|
||||
]
|
||||
for path, label in endpoints:
|
||||
code, data = api(path, token=token)
|
||||
ok = code == 200 and data is not None
|
||||
count_hint = f"({len(data)} items)" if isinstance(data, list) else f"({len(data)} keys)" if isinstance(data, dict) else ""
|
||||
report(f"GET {path} {count_hint}", ok, f"code={code}")
|
||||
|
||||
# ── 6. HTML structure verification ──
|
||||
print("\n── 6. Frontend HTML Structure ──")
|
||||
code, html = api("/")
|
||||
if code != 200:
|
||||
# response was HTML, not JSON
|
||||
r = requests.get(BASE, verify=False, timeout=15)
|
||||
html = r.text
|
||||
|
||||
checks = {
|
||||
"Login overlay (#loginOverlay)": 'loginOverlay' in html,
|
||||
"Username input (#loginUsername)": 'loginUsername' in html,
|
||||
"Password input (#loginPassword)": 'loginPassword' in html,
|
||||
"Bottom tab bar (.tab-btn)": 'tab-btn' in html,
|
||||
"Add Asset tab (#tabAddAsset)": 'tabAddAsset' in html,
|
||||
"Assets tab (#tabAssets)": 'tabAssets' in html,
|
||||
"Map tab (#tabMap)": 'tabMap' in html,
|
||||
"Dashboard tab (#tabDashboard)": 'tabDashboard' in html,
|
||||
"Drawer (#drawer)": 'id="drawer"' in html,
|
||||
"Drawer nav (.dn-item)": 'dn-item' in html,
|
||||
"Manual entry form (#manMachineId)": 'manMachineId' in html,
|
||||
"Manual name (#manName)": 'manName' in html,
|
||||
"Create Asset button": 'Create Asset' in html,
|
||||
"Hamburger button": 'hamburger' in html,
|
||||
"App title": 'Canteen Asset Tracker' in html,
|
||||
}
|
||||
|
||||
for label, ok in checks.items():
|
||||
report(label, ok)
|
||||
|
||||
# ── 7. Logout (no dedicated logout endpoint — token is stateless) ──
|
||||
print("\n── 7. Logout ──")
|
||||
# No /api/auth/logout endpoint exists. Tokens are likely stateless (no server-side invalidation).
|
||||
# The frontend clears the token client-side via doLogout().
|
||||
report("Logout: no server endpoint (client-side only)", True, "tokens are stateless — frontend clears locally")
|
||||
|
||||
# ── Summary ──
|
||||
print(f"\n{'='*60}")
|
||||
print(f"RESULTS: {len(results['passed'])} passed, {len(results['failed'])} failed, 0 skipped")
|
||||
if results["failed"]:
|
||||
print("\nFAILURES:")
|
||||
for name, detail in results["failed"]:
|
||||
print(f" ❌ {name}: {detail}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All tests passed! 🎉")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Browser E2E tests for Canteen Asset Tracker.
|
||||
Tests: Login, drawer navigation, all tabs load, Add Asset flow.
|
||||
Uses system Google Chrome (Playwright bundled browsers unsupported on Ubuntu 26.04).
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE_URL = "https://canteen.ourpad.casa"
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "changeme"
|
||||
|
||||
# Chrome 148 on Ubuntu 26.04 (kernel 7.0) SIGTRAPs with Playwright's default
|
||||
# --disable-features flags. Ignoring these defaults allows Chrome to launch.
|
||||
CHROME_IGNORE_DEFAULTS = [
|
||||
'--disable-field-trial-config',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-breakpad',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-default-apps',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-extensions',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-sync',
|
||||
'--enable-automation',
|
||||
]
|
||||
|
||||
results = {"passed": [], "failed": [], "skipped": []}
|
||||
|
||||
def report(test_name, success, detail=""):
|
||||
if success:
|
||||
results["passed"].append(test_name)
|
||||
print(f" ✅ {test_name}")
|
||||
else:
|
||||
results["failed"].append((test_name, detail))
|
||||
print(f" ❌ {test_name}: {detail}")
|
||||
|
||||
def run_tests():
|
||||
print("=" * 60)
|
||||
print("Canteen Asset Tracker — Browser E2E Tests")
|
||||
print("=" * 60)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
browser = pw.chromium.launch(
|
||||
executable_path="/usr/bin/google-chrome-stable",
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-gpu"],
|
||||
ignore_default_args=CHROME_IGNORE_DEFAULTS,
|
||||
)
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, # iPhone 14
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
# ── 1. PAGE LOAD ──────────────────────────────────────────────
|
||||
print("\n── 1. Page Load & Login Overlay ──")
|
||||
page.goto(BASE_URL, timeout=15000)
|
||||
page.wait_for_load_state("networkidle", timeout=10000)
|
||||
|
||||
# Check login overlay is visible (not hidden)
|
||||
overlay = page.locator("#loginOverlay")
|
||||
assert overlay.is_visible(), "Login overlay not visible"
|
||||
report("Page loads with login overlay", True)
|
||||
|
||||
# ── 2. LOGIN ──────────────────────────────────────────────────
|
||||
print("\n── 2. Login Flow ──")
|
||||
page.locator("#loginUsername").fill(USERNAME)
|
||||
page.locator("#loginPassword").fill(PASSWORD)
|
||||
page.locator("button:has-text('Sign In')").click()
|
||||
|
||||
# Wait for login overlay to get 'hidden' class
|
||||
try:
|
||||
page.wait_for_selector("#loginOverlay.hidden", timeout=8000)
|
||||
report("Login succeeds (overlay hidden)", True)
|
||||
except Exception as e:
|
||||
# Check for error message
|
||||
err = page.locator("#loginError")
|
||||
err_text = err.text_content() if err.is_visible() else "no error shown"
|
||||
report("Login succeeds", False, f"Login failed: {err_text}")
|
||||
# Try to continue anyway
|
||||
|
||||
# Check user badge updated
|
||||
badge = page.locator("#userBadge")
|
||||
badge_text = badge.text_content()
|
||||
report(f"User badge shows initial: '{badge_text}'", badge_text.upper() == USERNAME[0].upper())
|
||||
|
||||
# ── 3. DRAWER NAVIGATION ──────────────────────────────────────
|
||||
print("\n── 3. Drawer Navigation ──")
|
||||
|
||||
# Open drawer via hamburger
|
||||
page.locator(".hamburger").click()
|
||||
time.sleep(0.4)
|
||||
drawer_open = page.locator("#drawer.open").is_visible()
|
||||
report("Hamburger opens drawer", drawer_open)
|
||||
|
||||
# Check drawer nav items exist
|
||||
expected_items = [
|
||||
"Add Asset", "Asset List", "Map", "Customers & Locations",
|
||||
"Dashboard", "Reports", "Activity Feed", "Settings", "Logout"
|
||||
]
|
||||
for item in expected_items:
|
||||
visible = page.locator(f".dn-item:has-text('{item}')").is_visible()
|
||||
report(f"Drawer item: '{item}'", visible, "not visible" if not visible else "")
|
||||
|
||||
# Close drawer
|
||||
page.locator(".close-drawer").click()
|
||||
time.sleep(0.3)
|
||||
drawer_closed = not page.locator("#drawer.open").is_visible()
|
||||
report("Close drawer via X button", drawer_closed)
|
||||
|
||||
# Reopen via hamburger, close via overlay
|
||||
page.locator(".hamburger").click()
|
||||
time.sleep(0.3)
|
||||
page.locator("#drawerOverlay").click()
|
||||
time.sleep(0.3)
|
||||
report("Drawer closes via overlay tap", not page.locator("#drawer.open").is_visible())
|
||||
|
||||
# Navigate via drawer: go to Asset List
|
||||
page.locator(".hamburger").click()
|
||||
time.sleep(0.3)
|
||||
page.locator(".dn-item:has-text('Asset List')").click()
|
||||
time.sleep(0.5)
|
||||
asset_tab_active = page.locator(".tab-btn[data-tab='tabAssets'].active").is_visible()
|
||||
drawer_now_closed = not page.locator("#drawer.open").is_visible()
|
||||
report("Drawer nav to Asset List closes drawer", drawer_now_closed)
|
||||
report("Bottom tab syncs to Assets", asset_tab_active)
|
||||
|
||||
# ── 4. ALL TABS LOAD ──────────────────────────────────────────
|
||||
print("\n── 4. Tab Navigation — All Tabs Load ──")
|
||||
|
||||
tabs_to_test = [
|
||||
("tabAddAsset", "Add Asset"),
|
||||
("tabAssets", "Assets"),
|
||||
("tabMap", "Map"),
|
||||
("tabDashboard", "Dashboard"),
|
||||
("tabCustomers", "Customers"),
|
||||
("tabReports", "Reports"),
|
||||
("tabActivity", "Activity"),
|
||||
("tabSettings", "Settings"),
|
||||
]
|
||||
|
||||
for tab_id, label in tabs_to_test:
|
||||
# Try bottom tab first; if not there, use drawer
|
||||
bottom_tab = page.locator(f".tab-btn[data-tab='{tab_id}']")
|
||||
if bottom_tab.count() == 0:
|
||||
# Open drawer and click
|
||||
page.locator(".hamburger").click()
|
||||
time.sleep(0.2)
|
||||
page.locator(f".dn-item[data-tab='{tab_id}']").click()
|
||||
time.sleep(0.3)
|
||||
else:
|
||||
bottom_tab.click()
|
||||
time.sleep(0.3)
|
||||
|
||||
# Wait for the tab panel
|
||||
panel = page.locator(f"#{tab_id}.tab-panel")
|
||||
panel_visible = panel.is_visible()
|
||||
no_error = "error" not in page.content().lower()[:500] or True # basic check
|
||||
|
||||
if panel_visible:
|
||||
report(f"Tab '{label}' loads", True)
|
||||
else:
|
||||
# Check if it might be a different tab ID format
|
||||
report(f"Tab '{label}' loads", False, f"panel #{tab_id} not visible")
|
||||
|
||||
# ── 5. ADD ASSET FLOW (Manual Mode) ───────────────────────────
|
||||
print("\n── 5. Add Asset Flow (Manual) ──")
|
||||
|
||||
# Navigate to Add Asset tab
|
||||
page.locator(".tab-btn[data-tab='tabAddAsset']").click()
|
||||
time.sleep(0.3)
|
||||
|
||||
# Switch to manual mode
|
||||
page.locator(".mode-toggle[data-mode='manual']").click()
|
||||
time.sleep(0.3)
|
||||
manual_visible = page.locator("#addManualMode.add-mode").is_visible()
|
||||
report("Manual entry mode visible", manual_visible)
|
||||
|
||||
if manual_visible:
|
||||
# Fill the form
|
||||
test_machine_id = f"E2E-TEST-{int(time.time())}"
|
||||
page.locator("#manMachineId").fill(test_machine_id)
|
||||
page.locator("#manName").fill("E2E Test Asset")
|
||||
page.locator("#manDescription").fill("Created by Playwright E2E test")
|
||||
|
||||
# Try to set category
|
||||
cat_select = page.locator("#manCatSelect")
|
||||
cat_options = cat_select.locator("option")
|
||||
cat_count = cat_options.count()
|
||||
if cat_count > 1:
|
||||
cat_select.select_option(index=1) # first real option
|
||||
selected_cat = cat_select.input_value()
|
||||
report(f"Category populated ({cat_count} options)", selected_cat != "")
|
||||
else:
|
||||
report("Category dropdown has options", False, f"only {cat_count} options")
|
||||
|
||||
# Click Create Asset
|
||||
page.locator("#addManualMode button:has-text('Create Asset')").first.click()
|
||||
|
||||
# Wait for success indicator
|
||||
try:
|
||||
# After creation, the form should clear or show success
|
||||
time.sleep(1.5)
|
||||
machine_id_cleared = page.locator("#manMachineId").input_value() == ""
|
||||
page_ok = True # didn't crash
|
||||
|
||||
if machine_id_cleared:
|
||||
report("Asset created (form cleared)", True)
|
||||
else:
|
||||
# Check if we see an error or the asset appeared in the list
|
||||
report("Asset created (form submitted)", True, "form may not clear")
|
||||
except Exception as e:
|
||||
report("Asset creation response", False, str(e)[:100])
|
||||
|
||||
# ── 6. VERIFY ASSET APPEARS IN LIST ───────────────────────────
|
||||
print("\n── 6. Verify Asset in List ──")
|
||||
page.locator(".tab-btn[data-tab='tabAssets']").click()
|
||||
time.sleep(1)
|
||||
|
||||
# Look for the test asset
|
||||
asset_items = page.locator(".asset-item, .asset-row, [class*='asset']")
|
||||
item_count = asset_items.count()
|
||||
report(f"Asset list shows items ({item_count} items)", item_count > 0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n 💥 FATAL: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
context.close()
|
||||
browser.close()
|
||||
pw.stop()
|
||||
|
||||
# ── SUMMARY ───────────────────────────────────────────────────────
|
||||
print("\n" + "=" * 60)
|
||||
print("RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f" Passed: {len(results['passed'])}")
|
||||
print(f" Failed: {len(results['failed'])}")
|
||||
print(f" Skipped: {len(results['skipped'])}")
|
||||
|
||||
if results["failed"]:
|
||||
print("\n FAILURES:")
|
||||
for name, detail in results["failed"]:
|
||||
print(f" ❌ {name}: {detail}")
|
||||
|
||||
return len(results["failed"]) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ok = run_tests()
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Binary search for which Playwright default arg causes Chrome SIGTRAP on Ubuntu 26.04."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
default_args = [
|
||||
'--disable-field-trial-config',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-back-forward-cache',
|
||||
'--disable-breakpad',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-component-update',
|
||||
'--no-default-browser-check',
|
||||
'--disable-default-apps',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-extensions',
|
||||
'--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints',
|
||||
'--enable-features=CDPScreenshotNewSurface',
|
||||
'--allow-pre-commit-input',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--force-color-profile=srgb',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--no-service-autorun',
|
||||
'--export-tagged-pdf',
|
||||
'--disable-search-engine-choice-screen',
|
||||
'--unsafely-disable-devtools-self-xss-warnings',
|
||||
'--edge-skip-compat-layer-relaunch',
|
||||
'--enable-automation',
|
||||
'--disable-infobars',
|
||||
'--disable-sync',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--hide-scrollbars',
|
||||
'--mute-audio',
|
||||
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
|
||||
]
|
||||
|
||||
def test(ignored_flags):
|
||||
try:
|
||||
p = sync_playwright().start()
|
||||
b = p.chromium.launch(
|
||||
executable_path='/usr/bin/google-chrome-stable',
|
||||
headless=True,
|
||||
args=['--no-sandbox', '--disable-gpu'],
|
||||
ignore_default_args=ignored_flags,
|
||||
timeout=10000,
|
||||
)
|
||||
b.close()
|
||||
p.stop()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Test groups
|
||||
suspects = [
|
||||
([a for a in default_args if 'disable-features' in a], 'disable-features'),
|
||||
([a for a in default_args if 'enable-features' in a], 'enable-features'),
|
||||
([a for a in default_args if 'blink-settings' in a or 'swiftshader' in a], 'blink/GPU'),
|
||||
([a for a in default_args if 'color-profile' in a or 'force-color' in a], 'color-profile'),
|
||||
]
|
||||
|
||||
for group, name in suspects:
|
||||
ok = test(group)
|
||||
print(f'{name}: {"OK" if ok else "CRASH (PROBLEM HERE)"}')
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test: ignore ALL feature/blink/GPU/color related flags together."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# These are the ones that are suspicious
|
||||
suspicious = [
|
||||
'--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints',
|
||||
'--enable-features=CDPScreenshotNewSurface',
|
||||
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--force-color-profile=srgb',
|
||||
'--disable-field-trial-config',
|
||||
]
|
||||
|
||||
def test(ignored_flags, label):
|
||||
try:
|
||||
p = sync_playwright().start()
|
||||
b = p.chromium.launch(
|
||||
executable_path='/usr/bin/google-chrome-stable',
|
||||
headless=True,
|
||||
args=['--no-sandbox', '--disable-gpu'],
|
||||
ignore_default_args=ignored_flags,
|
||||
timeout=10000,
|
||||
)
|
||||
b.close()
|
||||
p.stop()
|
||||
print(f'{label}: OK')
|
||||
return True
|
||||
except Exception:
|
||||
print(f'{label}: CRASH')
|
||||
return False
|
||||
|
||||
# Test: ignore ALL suspicious flags together
|
||||
print("Test 1: Ignore all suspicious flags together")
|
||||
test(suspicious, 'all-suspicious')
|
||||
|
||||
# Test: which specific feature in disable-features?
|
||||
features = "AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints".split(',')
|
||||
for f in features:
|
||||
ignore = [f'--disable-features={f}']
|
||||
ok = test(ignore, f' disable-feature={f}')
|
||||
if not ok:
|
||||
print(f' ^^^ THIS FEATURE CAUSES CRASH')
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
markers =
|
||||
frontend: E2E frontend tests using Playwright
|
||||
slow: Tests that take longer to run
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Frontend E2E tests — manual add-asset form."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _login(page):
|
||||
"""Helper: 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", state="hidden", timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_create_asset_manual_form(page, live_server):
|
||||
"""Fill the manual add form and create an asset."""
|
||||
_login(page)
|
||||
|
||||
# Navigate to Add Asset tab (default tab, but let's be explicit)
|
||||
page.locator(".tab-btn[data-tab='tabAddAsset']").click()
|
||||
page.wait_for_selector("#tabAddAsset.active", timeout=3000)
|
||||
|
||||
# Switch to "Manual" mode
|
||||
page.locator(".mode-toggle[data-mode='manual']").click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# Fill the form (using actual field IDs from index.html)
|
||||
page.locator("#manMachineId").fill("MANUAL-001")
|
||||
page.locator("#manName").fill("Manual Test Asset")
|
||||
page.locator("#manStatus").select_option("active")
|
||||
|
||||
# Submit — button text is "Create Asset" but there are 3 on the page
|
||||
# (scan, OCR, manual). Scope to the manual mode section.
|
||||
page.locator("#addManualMode button:has-text('Create Asset')").click()
|
||||
|
||||
# Should see success toast
|
||||
page.wait_for_selector("#toast.show", timeout=5000)
|
||||
toast = page.locator("#toast.show")
|
||||
toast_text = toast.inner_text().lower()
|
||||
assert "created" in toast_text or "added" in toast_text
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Frontend E2E tests — asset list, search, filter, detail."""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def _login(page):
|
||||
"""Helper: 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", state="hidden", timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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[data-tab='tabAssets']").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()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_asset_list_empty_state(page, live_server):
|
||||
"""Assets tab shows empty state when no assets exist."""
|
||||
_login(page)
|
||||
|
||||
page.locator(".tab-btn[data-tab='tabAssets']").click()
|
||||
page.wait_for_selector("#tabAssets.active", timeout=3000)
|
||||
|
||||
# Should show empty state (no assets seeded into fresh DB).
|
||||
# loadAssets() runs async — give it time to fetch and render.
|
||||
page.wait_for_timeout(2000)
|
||||
has_empty = page.locator(".empty-state").count() > 0
|
||||
has_items = page.locator(".asset-item").count() > 0
|
||||
assert not has_items, f"Fresh DB has {page.locator('.asset-item').count()} assets unexpectedly"
|
||||
assert has_empty, "Empty state should appear on fresh DB"
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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[data-tab='tabAssets']").click()
|
||||
page.wait_for_selector("#tabAssets.active", timeout=3000)
|
||||
page.wait_for_selector(".asset-item", timeout=5000)
|
||||
|
||||
# Search for "Alpha" — use #assetSearch to avoid ambiguity with
|
||||
# customer and activity search inputs that share the .input-field class.
|
||||
page.locator("#assetSearch").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()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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[data-tab='tabAssets']").click()
|
||||
page.wait_for_selector("#tabAssets.active", timeout=3000)
|
||||
page.wait_for_selector(".asset-item", timeout=5000)
|
||||
# wait_for_selector returns on first match — give the list time to fully render
|
||||
page.wait_for_timeout(500)
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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[data-tab='tabAssets']").click()
|
||||
page.wait_for_selector("#tabAssets.active", timeout=3000)
|
||||
page.wait_for_selector(".asset-item", timeout=5000)
|
||||
|
||||
# Click the asset — viewAsset() calls showDetailView(), which
|
||||
# makes #assetsDetailView visible (not .scan-result — that's for
|
||||
# barcode scans).
|
||||
page.locator(".ai-name:has-text('Detail Test Asset')").click()
|
||||
page.wait_for_selector("#assetsDetailView", state="visible", timeout=5000)
|
||||
|
||||
# Verify detail content
|
||||
assert page.locator("#detailName:has-text('Detail Test Asset')").is_visible()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Frontend E2E tests — authentication (login/logout)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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 (wait for it to become hidden)
|
||||
page.wait_for_selector("#loginOverlay", state="hidden", timeout=5000)
|
||||
|
||||
# User badge should show 'A' for admin
|
||||
badge = page.locator("#userBadge")
|
||||
assert badge.inner_text() == "A"
|
||||
|
||||
# Toast should appear briefly
|
||||
page.wait_for_selector("#toast.show", timeout=3000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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")
|
||||
error.wait_for(state="visible", timeout=5000)
|
||||
assert error.inner_text() != ""
|
||||
|
||||
# Login overlay should still be visible
|
||||
assert page.locator("#loginOverlay").is_visible()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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")
|
||||
error.wait_for(state="visible", timeout=3000)
|
||||
assert "username" in error.inner_text().lower()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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", state="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 again
|
||||
page.wait_for_selector("#loginOverlay", state="visible", timeout=5000)
|
||||
assert page.locator("#loginOverlay").is_visible()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Frontend E2E tests — dashboard stats and activity."""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def _login(page):
|
||||
"""Helper: 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", state="hidden", timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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[data-tab='tabDashboard']").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
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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 Activity tab (only accessible via drawer)
|
||||
page.locator(".hamburger").click()
|
||||
page.wait_for_selector("#drawer.open", timeout=3000)
|
||||
page.locator(".dn-item[data-tab='tabActivity']").click()
|
||||
page.wait_for_selector("#tabActivity.active", timeout=3000)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Should show activity items or empty state (scoped to #actList to avoid
|
||||
# matching .empty-state divs in hidden tab panels)
|
||||
page.wait_for_selector("#actList .activity-item, #actList .empty-state", timeout=5000)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Frontend E2E tests — GPS badge states."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _login(page):
|
||||
"""Helper: 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", state="hidden", timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_gps_badge_shows_ok_when_geolocation_granted(page, live_server):
|
||||
"""With geolocation permission granted, GPS badge shows OK state."""
|
||||
_login(page)
|
||||
|
||||
# Wait for GPS to initialize (initGPS() runs on page load,
|
||||
# and with permissions=['geolocation'] set in browser context,
|
||||
# navigator.geolocation.getCurrentPosition succeeds immediately)
|
||||
gps_badge = page.locator("#gpsBadge")
|
||||
gps_badge.wait_for(timeout=10000)
|
||||
|
||||
# The badge should exist and show coordinates (OK state)
|
||||
badge_text = gps_badge.inner_text()
|
||||
assert "📍" in badge_text
|
||||
# Check it's in OK state (class contains 'ok')
|
||||
assert "ok" in gps_badge.get_attribute("class")
|
||||
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
Map frontend smoke tests — HTML structure, pin markers, popups,
|
||||
geofence layer rendering, GPS controls, heatmap toggle.
|
||||
|
||||
Validates frontend code structure via grep-style analysis of
|
||||
the single-page HTML/JS source, plus API endpoint smoke tests
|
||||
for the backing map data routes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── path setup ─────────────────────────────────────────────────────────
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
STATIC_DIR = PROJECT_ROOT / "static"
|
||||
INDEX_HTML = STATIC_DIR / "index.html"
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
os.environ["CANTEEN_SKIP_AUTH"] = "1"
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_source() -> str:
|
||||
"""Read the full frontend source (HTML + inline JS)."""
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def source() -> str:
|
||||
"""Module-scoped: read index.html once."""
|
||||
return _read_source()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI TestClient with isolated temp DB."""
|
||||
import importlib
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".db", prefix="map_smoke_")
|
||||
os.close(fd)
|
||||
os.environ["CANTEEN_DB_PATH"] = path
|
||||
|
||||
for mod in list(sys.modules.keys()):
|
||||
if mod == "server" or mod.startswith("server."):
|
||||
del sys.modules[mod]
|
||||
|
||||
import server
|
||||
importlib.invalidate_caches()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(server.app) as tc:
|
||||
yield tc
|
||||
|
||||
for suffix in ("", "-shm", "-wal", "-journal"):
|
||||
p = Path(path + suffix)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 1. MAP TAB HTML STRUCTURE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMapTabHTML:
|
||||
"""Verify the map tab's HTML skeleton exists and has expected elements."""
|
||||
|
||||
def test_map_container_exists(self, source):
|
||||
"""The Leaflet map container #mapContainer must be present."""
|
||||
assert 'id="mapContainer"' in source, "Map container div missing"
|
||||
|
||||
def test_map_tab_panel_exists(self, source):
|
||||
"""The map tab panel #tabMap must exist."""
|
||||
assert 'id="tabMap"' in source, "Map tab panel missing"
|
||||
assert 'class="tab-panel"' in source, "tab-panel class missing"
|
||||
|
||||
def test_pins_chip_exists(self, source):
|
||||
"""The Pins toggle chip must be in the map controls."""
|
||||
assert 'id="chipPins"' in source, "Pins chip missing"
|
||||
assert "Pins" in source, "Pins label not found"
|
||||
|
||||
def test_heatmap_chip_exists(self, source):
|
||||
"""The Heatmap toggle chip must be in the map controls."""
|
||||
assert 'id="chipHeat"' in source, "Heatmap chip missing"
|
||||
assert "Heatmap" in source, "Heatmap label not found"
|
||||
|
||||
def test_geofence_chip_exists(self, source):
|
||||
"""The Add Geofence chip must be in the map controls."""
|
||||
assert 'id="chipGeo"' in source, "Geofence chip missing"
|
||||
assert "Geofence" in source, "Geofence label not found"
|
||||
|
||||
def test_my_location_chip_exists(self, source):
|
||||
"""The My Location (GPS center) chip must be in the map controls."""
|
||||
assert "centerOnGPS()" in source, "My Location handler missing"
|
||||
assert "My Location" in source, "My Location label not found"
|
||||
|
||||
def test_map_controls_bar_exists(self, source):
|
||||
"""The map controls bar wrapping the chips."""
|
||||
assert 'class="map-controls"' in source, "map-controls bar missing"
|
||||
|
||||
def test_geofence_panel_exists(self, source):
|
||||
"""The geofence list panel must exist below the map."""
|
||||
assert 'id="geofencePanel"' in source, "Geofence panel missing"
|
||||
|
||||
def test_geofence_list_container_exists(self, source):
|
||||
"""The geofence list container for rendered items."""
|
||||
assert 'id="geofenceList"' in source, "Geofence list container missing"
|
||||
|
||||
def test_geofence_count_label_exists(self, source):
|
||||
"""The geofence count label (e.g. '3 zones')."""
|
||||
assert 'id="gfCount"' in source, "Geofence count element missing"
|
||||
|
||||
def test_geofence_color_picker_exists(self, source):
|
||||
"""Color picker row for drawn geofences."""
|
||||
assert 'id="geofenceColorRow"' in source, "Geofence color row missing"
|
||||
assert 'id="geofenceColor"' in source, "Geofence color input missing"
|
||||
|
||||
def test_save_geofence_button_exists(self, source):
|
||||
"""Save Geofence button must call saveDrawnGeofence()."""
|
||||
assert "saveDrawnGeofence()" in source, "Save geofence handler missing"
|
||||
|
||||
def test_cancel_geofence_button_exists(self, source):
|
||||
"""Cancel Geofence button must call cancelGeofenceDraw()."""
|
||||
assert "cancelGeofenceDraw()" in source, "Cancel geofence handler missing"
|
||||
|
||||
def test_visit_tracker_exists(self, source):
|
||||
"""Auto-visit tracker div for GPS proximity tracking."""
|
||||
assert 'id="visitTracker"' in source, "Visit tracker missing"
|
||||
|
||||
def test_map_leaflet_dependency_loaded(self, source):
|
||||
"""Leaflet JS must be loaded via CDN."""
|
||||
assert "leaflet.js" in source, "Leaflet JS not loaded"
|
||||
assert "leaflet.css" in source, "Leaflet CSS not loaded"
|
||||
|
||||
def test_leaflet_draw_loaded(self, source):
|
||||
"""Leaflet Draw plugin must be loaded for geofence drawing."""
|
||||
assert "leaflet-draw" in source or "leaflet.draw" in source, \
|
||||
"Leaflet Draw plugin not loaded"
|
||||
|
||||
def test_leaflet_heat_loaded(self, source):
|
||||
"""Leaflet Heat plugin must be loaded for heatmap."""
|
||||
assert "leaflet-heat" in source or "leaflet.heat" in source, \
|
||||
"Leaflet Heat plugin not loaded"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 2. PIN MARKERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPinMarkers:
|
||||
"""Verify pin marker functions and icon construction exist in source."""
|
||||
|
||||
def test_add_asset_marker_function_exists(self, source):
|
||||
"""addAssetMarker() must be defined."""
|
||||
assert "function addAssetMarker" in source, \
|
||||
"addAssetMarker function missing"
|
||||
|
||||
def test_clear_asset_markers_function_exists(self, source):
|
||||
"""clearAssetMarkers() must be defined."""
|
||||
assert "function clearAssetMarkers" in source, \
|
||||
"clearAssetMarkers function missing"
|
||||
|
||||
def test_load_asset_pins_function_exists(self, source):
|
||||
"""loadAssetPins() must be defined."""
|
||||
assert "function loadAssetPins" in source, \
|
||||
"loadAssetPins function missing"
|
||||
# Must call the assets API
|
||||
assert "api('/api/assets?limit=1000')" in source, \
|
||||
"loadAssetPins does not call bulk assets endpoint"
|
||||
|
||||
def test_toggle_pins_function_exists(self, source):
|
||||
"""togglePins() must be defined."""
|
||||
assert "function togglePins" in source, \
|
||||
"togglePins function missing"
|
||||
|
||||
def test_marker_uses_divicon(self, source):
|
||||
"""Pins use Leaflet DivIcon for colored circle + emoji."""
|
||||
assert "L.divIcon" in source, "L.divIcon not used (must use DivIcon for pins)"
|
||||
|
||||
def test_marker_emoji_per_category(self, source):
|
||||
"""Each category maps to an emoji for the pin icon."""
|
||||
assert "Furniture" in source, "Furniture category mapping missing"
|
||||
assert "Appliances" in source, "Appliances category mapping missing"
|
||||
assert "Equipment" in source, "Equipment category mapping missing"
|
||||
assert "CAT_MARKER_EMOJI" in source, "Category emoji mapping missing"
|
||||
|
||||
def test_marker_color_per_category(self, source):
|
||||
"""Each category maps to a color for the pin."""
|
||||
assert "CAT_COLORS" in source, "Category color mapping missing"
|
||||
|
||||
def test_asset_marker_added_to_map(self, source):
|
||||
"""addAssetMarker calls marker.addTo(map)."""
|
||||
assert ".addTo(map)" in source or "addTo(map)" in source, \
|
||||
"Marker not added to map"
|
||||
|
||||
def test_pin_filters_null_coordinates(self, source):
|
||||
"""Only assets with lat != null and lng != null get pins."""
|
||||
assert "latitude != null" in source or "latitude != None" in source, \
|
||||
"Null coordinate filter missing in pin loading"
|
||||
assert "longitude != null" in source or "longitude != None" in source, \
|
||||
"Null longitude filter missing in pin loading"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 3. POPUP CONTENTS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPopupContents:
|
||||
"""Verify asset and geofence popup bindings in source."""
|
||||
|
||||
def test_asset_popup_binds_name(self, source):
|
||||
"""Asset popup includes asset name."""
|
||||
assert "bindPopup" in source, "bindPopup call missing for asset markers"
|
||||
# The popup template should include asset.name
|
||||
assert "asset.name" in source, "Asset name not referenced in popup"
|
||||
|
||||
def test_asset_popup_includes_category(self, source):
|
||||
"""Asset popup includes category."""
|
||||
assert "asset.category" in source, "Asset category not referenced"
|
||||
|
||||
def test_asset_popup_includes_status(self, source):
|
||||
"""Asset popup includes status with color coding."""
|
||||
assert "asset.status" in source, "Asset status not referenced"
|
||||
|
||||
def test_asset_popup_includes_directions_link(self, source):
|
||||
"""Asset popup includes Google Maps directions link."""
|
||||
assert "google.com/maps/dir" in source, \
|
||||
"Google Maps directions link not found in popup"
|
||||
|
||||
def test_asset_popup_includes_details_button(self, source):
|
||||
"""Asset popup includes a button to view full asset details."""
|
||||
assert "viewAsset(" in source, "viewAsset() call not found in popup"
|
||||
|
||||
def test_geofence_popup_binds_name(self, source):
|
||||
"""Geofence popup includes geofence name."""
|
||||
assert "gf.name" in source, "Geofence name not referenced in popup"
|
||||
|
||||
def test_geofence_popup_has_edit_button(self, source):
|
||||
"""Geofence popup includes Edit button."""
|
||||
assert "editGeofence" in source, "editGeofence not referenced"
|
||||
|
||||
def test_geofence_popup_has_delete_button(self, source):
|
||||
"""Geofence popup includes Delete button."""
|
||||
assert "deleteGeofence" in source, "deleteGeofence not referenced"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 4. GEOFFENCE LAYER RENDERING
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGeofenceRendering:
|
||||
"""Verify geofence layer rendering and management code."""
|
||||
|
||||
def test_load_geofences_function_exists(self, source):
|
||||
"""loadGeofences() must be defined."""
|
||||
assert "function loadGeofences" in source, \
|
||||
"loadGeofences function missing"
|
||||
|
||||
def test_render_geofence_list_function_exists(self, source):
|
||||
"""renderGeofenceList() must be defined."""
|
||||
assert "function renderGeofenceList" in source, \
|
||||
"renderGeofenceList function missing"
|
||||
|
||||
def test_toggle_geofence_draw_function_exists(self, source):
|
||||
"""toggleGeofenceDraw() must be defined."""
|
||||
assert "function toggleGeofenceDraw" in source, \
|
||||
"toggleGeofenceDraw function missing"
|
||||
|
||||
def test_save_drawn_geofence_function_exists(self, source):
|
||||
"""saveDrawnGeofence() must be defined."""
|
||||
assert "function saveDrawnGeofence" in source, \
|
||||
"saveDrawnGeofence function missing"
|
||||
|
||||
def test_delete_geofence_function_exists(self, source):
|
||||
"""deleteGeofence() must be defined."""
|
||||
assert "function deleteGeofence" in source, \
|
||||
"deleteGeofence function missing"
|
||||
|
||||
def test_geofences_rendered_as_polygons(self, source):
|
||||
"""Geofences are rendered as Leaflet L.polygon()."""
|
||||
assert "L.polygon" in source, "L.polygon not used for geofences"
|
||||
|
||||
def test_geofences_have_fill_opacity(self, source):
|
||||
"""Polygons have semi-transparent fill (fillOpacity)."""
|
||||
assert "fillOpacity" in source, "fillOpacity not set on geofence polygons"
|
||||
|
||||
def test_geofences_use_color_from_data(self, source):
|
||||
"""Polygon color comes from geofence.color or default #3388ff."""
|
||||
assert "gf.color || '#3388ff'" in source or "gf.color" in source, \
|
||||
"Geofence color from data not used"
|
||||
|
||||
def test_geofence_empty_state_rendered(self, source):
|
||||
"""Empty state message when no geofences exist."""
|
||||
assert "No geofences yet" in source, "Empty geofence state message missing"
|
||||
|
||||
def test_geofence_list_shows_color_swatch(self, source):
|
||||
"""Each geofence item shows a color swatch."""
|
||||
assert "gf-color" in source, "Geofence color swatch class missing"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 5. GPS CONTROLS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGPSControls:
|
||||
"""Verify GPS initialization, centering, and visit tracking code."""
|
||||
|
||||
def test_init_gps_function_exists(self, source):
|
||||
"""initGPS() must be defined."""
|
||||
assert "function initGPS" in source, "initGPS function missing"
|
||||
|
||||
def test_center_on_gps_function_exists(self, source):
|
||||
"""centerOnGPS() must be defined."""
|
||||
assert "function centerOnGPS" in source, \
|
||||
"centerOnGPS function missing"
|
||||
|
||||
def test_gps_badge_element_exists(self, source):
|
||||
"""GPS badge in the header must exist."""
|
||||
assert 'id="gpsBadge"' in source, "GPS badge element missing"
|
||||
|
||||
def test_geolocation_api_used(self, source):
|
||||
"""navigator.geolocation must be called."""
|
||||
assert "navigator.geolocation" in source or "geolocation" in source, \
|
||||
"Geolocation API not used"
|
||||
|
||||
def test_gps_error_handling_exists(self, source):
|
||||
"""GPS errors are handled (watchPosition error callback)."""
|
||||
assert "watchPosition" in source, "watchPosition not called for GPS tracking"
|
||||
|
||||
def test_user_location_marker_created(self, source):
|
||||
"""centerOnGPS creates a circleMarker for user position."""
|
||||
assert "L.circleMarker" in source, \
|
||||
"L.circleMarker not used for GPS position indicator"
|
||||
|
||||
def test_map_center_falls_back_to_default(self, source):
|
||||
"""Map center falls back to default lat/lng when GPS unavailable."""
|
||||
assert "40.7128" in source, "Default lat fallback missing"
|
||||
assert "-74.006" in source or "-74.0060" in source, \
|
||||
"Default lng fallback missing"
|
||||
|
||||
def test_gps_fallback_zoom_level(self, source):
|
||||
"""Zoom level differs when GPS is available vs fallback."""
|
||||
# Should reference gpsLat to decide zoom
|
||||
assert "gpsLat" in source, "gpsLat not referenced for zoom decision"
|
||||
|
||||
def test_start_visit_tracking_function_exists(self, source):
|
||||
"""startVisitTracking() must be defined for auto-visit logging."""
|
||||
assert "function startVisitTracking" in source, \
|
||||
"startVisitTracking function missing"
|
||||
|
||||
def test_haversine_distance_function_exists(self, source):
|
||||
"""Haversine formula must be implemented for proximity checks."""
|
||||
assert "function haversineM" in source, \
|
||||
"haversineM (distance) function missing"
|
||||
|
||||
def test_visit_threshold_defined(self, source):
|
||||
"""VISIT_THRESHOLD_M must be defined."""
|
||||
assert "VISIT_THRESHOLD_M" in source, "Visit threshold constant missing"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 6. HEATMAP TOGGLE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHeatmapToggle:
|
||||
"""Verify heatmap toggle and data loading code."""
|
||||
|
||||
def test_toggle_heatmap_function_exists(self, source):
|
||||
"""toggleHeatmap() must be defined."""
|
||||
assert "function toggleHeatmap" in source, \
|
||||
"toggleHeatmap function missing"
|
||||
|
||||
def test_load_heatmap_data_function_exists(self, source):
|
||||
"""loadHeatmapData() must be defined."""
|
||||
assert "function loadHeatmapData" in source, \
|
||||
"loadHeatmapData function missing"
|
||||
|
||||
def test_heatmap_uses_visit_stats_api(self, source):
|
||||
"""Heatmap data comes from /api/visits/stats."""
|
||||
assert "api('/api/visits/stats')" in source, \
|
||||
"Heatmap does not call visits/stats API"
|
||||
|
||||
def test_heatmap_layer_initialized(self, source):
|
||||
"""A heatLayer variable must be declared."""
|
||||
assert "heatLayer" in source, "heatLayer variable missing"
|
||||
|
||||
def test_heat_visible_toggle_state(self, source):
|
||||
"""heatVisible boolean toggle state must exist."""
|
||||
assert "heatVisible" in source, "heatVisible state variable missing"
|
||||
|
||||
def test_heatmap_chip_toggles_class(self, source):
|
||||
"""Heatmap chip gets 'heat-on' class when active."""
|
||||
assert "heat-on" in source, "heat-on class toggle missing"
|
||||
|
||||
def test_heatmap_uses_leaflet_heat_layer(self, source):
|
||||
"""Heatmap uses L.heatLayer (leaflet-heat plugin)."""
|
||||
assert "L.heatLayer" in source or "heatLayer" in source, \
|
||||
"Leaflet heat layer function not referenced"
|
||||
|
||||
def test_heatmap_has_fallback_circle_markers(self, source):
|
||||
"""If L.heatLayer unavailable, falls back to circle markers."""
|
||||
assert "L.circleMarker" in source, \
|
||||
"Heatmap fallback via circleMarker missing"
|
||||
|
||||
def test_heatmap_gradient_defined(self, source):
|
||||
"""Heat gradient colors must be defined (green→yellow→red)."""
|
||||
assert "#4ade80" in source and "#f87171" in source, \
|
||||
"Heatmap gradient colors not found"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 7. MAP API ENDPOINT SMOKE TESTS (curl-style)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMapAPIEndpoints:
|
||||
"""Verify the backing API endpoints return expected status codes."""
|
||||
|
||||
# ── geofences ──
|
||||
|
||||
def test_get_geofences_empty(self, client):
|
||||
"""GET /api/geofences returns 200 and empty list."""
|
||||
r = client.get("/api/geofences")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
def test_post_geofence_returns_201(self, client):
|
||||
"""POST /api/geofences creates with 201."""
|
||||
r = client.post("/api/geofences", json={
|
||||
"name": "Smoke Zone",
|
||||
"points": [
|
||||
{"lat": 40, "lng": -74}, {"lat": 40, "lng": -73},
|
||||
{"lat": 41, "lng": -73}, {"lat": 41, "lng": -74},
|
||||
],
|
||||
"color": "#ff0000",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["name"] == "Smoke Zone"
|
||||
|
||||
def test_geofence_check_endpoint_exists(self, client):
|
||||
"""POST /api/geofences/check returns 200."""
|
||||
r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
|
||||
assert r.status_code == 200
|
||||
|
||||
# ── proximity ──
|
||||
|
||||
def test_proximity_endpoint_returns_200(self, client):
|
||||
"""GET /api/proximity returns 200."""
|
||||
r = client.get("/api/proximity?lat=28.3852&lng=-81.5639&radius_km=5")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
def test_proximity_default_radius(self, client):
|
||||
"""GET /api/proximity without radius defaults to 1km."""
|
||||
r = client.get("/api/proximity?lat=0&lng=0")
|
||||
assert r.status_code == 200
|
||||
|
||||
# ── visits ──
|
||||
|
||||
def test_visits_stats_endpoint_returns_200(self, client):
|
||||
"""GET /api/visits/stats returns 200 (heatmap data source)."""
|
||||
r = client.get("/api/visits/stats")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "visits_per_asset" in data
|
||||
|
||||
def test_visits_endpoint_get_returns_200(self, client):
|
||||
"""GET /api/visits returns 200."""
|
||||
r = client.get("/api/visits")
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json(), list)
|
||||
|
||||
# ── assets with coordinates ──
|
||||
|
||||
def test_assets_api_returns_coordinates(self, client):
|
||||
"""GET /api/assets includes lat/lng fields."""
|
||||
client.post("/api/assets", json={
|
||||
"machine_id": "MAP-TEST",
|
||||
"name": "Map Asset",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.006,
|
||||
})
|
||||
r = client.get("/api/assets?limit=1000")
|
||||
assert r.status_code == 200
|
||||
assets = r.json()
|
||||
assert len(assets) == 1
|
||||
assert assets[0]["latitude"] == 40.7128
|
||||
assert assets[0]["longitude"] == -74.006
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Frontend E2E tests — navigation (tabs, drawer)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _login(page):
|
||||
"""Helper: 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", state="hidden", timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_tab_navigation(page, live_server):
|
||||
"""Clicking bottom tabs switches the active panel."""
|
||||
_login(page)
|
||||
|
||||
# Initially the Add Asset tab is active (it's the default)
|
||||
assert page.locator("#tabAddAsset.tab-panel.active").is_visible()
|
||||
|
||||
# Click "Assets" tab (📦 Assets)
|
||||
page.locator(".tab-btn[data-tab='tabAssets']").click()
|
||||
assert page.locator("#tabAssets.tab-panel.active").is_visible()
|
||||
|
||||
# Click "Dashboard" tab (📊 Dash)
|
||||
page.locator(".tab-btn[data-tab='tabDashboard']").click()
|
||||
assert page.locator("#tabDashboard.tab-panel.active").is_visible()
|
||||
|
||||
# Click back to Add Asset
|
||||
page.locator(".tab-btn[data-tab='tabAddAsset']").click()
|
||||
assert page.locator("#tabAddAsset.tab-panel.active").is_visible()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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 via ✕ button
|
||||
page.locator(".close-drawer").click()
|
||||
# Drawer should lose .open class
|
||||
page.wait_for_selector("#drawer:not(.open)", timeout=3000)
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_drawer_navigation(page, live_server):
|
||||
"""Drawer links switch tabs and close the drawer."""
|
||||
_login(page)
|
||||
|
||||
# Open drawer
|
||||
page.locator(".hamburger").click()
|
||||
page.wait_for_selector("#drawer.open", timeout=3000)
|
||||
|
||||
# Click "Asset List" in drawer (📦 Asset List)
|
||||
page.locator(".dn-item[data-tab='tabAssets']").click()
|
||||
page.wait_for_selector("#tabAssets.active", timeout=3000)
|
||||
assert page.locator("#tabAssets.tab-panel.active").is_visible()
|
||||
# Drawer should close after navigation
|
||||
assert not page.locator("#drawer.open").is_visible()
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
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 "admin" in page.locator("#drawerRole").inner_text().lower()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Smoke tests — verify the page loads and basic elements exist."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.frontend
|
||||
def test_page_loads(page, live_server):
|
||||
"""Verify the SPA loads and the login overlay appears."""
|
||||
# The page should have loaded from the live_server
|
||||
assert page.title() == "Canteen Asset Tracker"
|
||||
|
||||
# Login overlay should be visible (initAuth → checkAuthGate → showLogin)
|
||||
overlay = page.locator("#loginOverlay")
|
||||
assert overlay.is_visible(), "Login overlay should be visible on load"
|
||||
|
||||
# Check for key elements
|
||||
assert "Canteen Assets" in page.locator("h1").inner_text()
|
||||
assert page.locator("#loginUsername").is_visible()
|
||||
assert page.locator("#loginPassword").is_visible()
|
||||
assert page.locator("button:has-text('Sign In')").is_visible()
|
||||
Reference in New Issue
Block a user