Commit initial
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
"""E2E test fixtures: live uvicorn server for Playwright."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def live_server(tmp_path_factory):
|
||||
"""Start the FastAPI app on a random port in a background thread."""
|
||||
# Isolated data dir for E2E session
|
||||
data_dir = tmp_path_factory.mktemp("imptune_e2e_data")
|
||||
|
||||
# Patch config module so the app uses the temp dir
|
||||
import imptune.config as cfg
|
||||
|
||||
cfg.DATA_DIR = str(data_dir)
|
||||
cfg.DB_PATH = str(data_dir / "imptune.db")
|
||||
cfg.DRIVERS_DIR = str(data_dir / "drivers")
|
||||
cfg.ICONS_DIR = str(data_dir / "icons")
|
||||
|
||||
os.makedirs(cfg.DRIVERS_DIR, exist_ok=True)
|
||||
os.makedirs(cfg.ICONS_DIR, exist_ok=True)
|
||||
|
||||
# Also set env var so lifespan handler picks up correct dirs
|
||||
os.environ["DATA_DIR"] = str(data_dir)
|
||||
|
||||
# Initialize DB against the tmp dir
|
||||
from imptune.db.database import init_db
|
||||
|
||||
init_db()
|
||||
|
||||
from imptune.main import app
|
||||
|
||||
port = _free_port()
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
# Readiness poll via /health (up to 5 s)
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
r = httpx.get(f"{base_url}/health", timeout=0.5)
|
||||
if r.status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("live_server did not become ready within 5 s")
|
||||
|
||||
yield base_url
|
||||
|
||||
server.should_exit = True
|
||||
thread.join(timeout=2.0)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""UIE-05: E2E tests for language toggle — FR/EN switching and localStorage persistence."""
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 1 (Phase 12-01): navigator.language auto-detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_navigator_language_en_sets_lang_en(browser, live_server: str) -> None:
|
||||
"""When localStorage is empty and navigator.language is 'en-US', lang defaults to 'en'."""
|
||||
context = browser.new_context(locale="en-US")
|
||||
page = context.new_page()
|
||||
|
||||
# Clear any saved preference so localStorage fallback is not used
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
page.evaluate("localStorage.removeItem('imptune_lang')")
|
||||
|
||||
# Reload so the alpine:init IIFE runs with no localStorage and en-US locale
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
lang = page.evaluate("Alpine.store('i18n').lang")
|
||||
assert lang == "en", f"Expected 'en' when navigator.language is 'en-US', got '{lang}'"
|
||||
|
||||
context.close()
|
||||
|
||||
|
||||
def test_navigator_language_fr_sets_lang_fr(browser, live_server: str) -> None:
|
||||
"""When localStorage is empty and navigator.language is 'fr-FR', lang defaults to 'fr'."""
|
||||
context = browser.new_context(locale="fr-FR")
|
||||
page = context.new_page()
|
||||
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
page.evaluate("localStorage.removeItem('imptune_lang')")
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
lang = page.evaluate("Alpine.store('i18n').lang")
|
||||
assert lang == "fr", f"Expected 'fr' when navigator.language is 'fr-FR', got '{lang}'"
|
||||
|
||||
context.close()
|
||||
|
||||
|
||||
def test_localstorage_wins_over_navigator_language(browser, live_server: str) -> None:
|
||||
"""localStorage preference wins over navigator.language when a value is saved."""
|
||||
# Browser locale is 'en-US' but localStorage says 'fr' → should use 'fr'
|
||||
context = browser.new_context(locale="en-US")
|
||||
page = context.new_page()
|
||||
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
lang = page.evaluate("Alpine.store('i18n').lang")
|
||||
assert lang == "fr", f"Expected localStorage 'fr' to win over navigator.language 'en-US', got '{lang}'"
|
||||
|
||||
# Cleanup
|
||||
page.evaluate("localStorage.removeItem('imptune_lang')")
|
||||
context.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original toggle and persistence tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_language_toggle_switches_nav_label(page, live_server: str) -> None:
|
||||
"""Clicking FR/EN button switches nav label from French to English."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
|
||||
# Default lang is 'fr' — nav should show French labels
|
||||
# Wait for Alpine to hydrate
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
# In French, printers nav label = 'Imprimantes'
|
||||
printers_label_fr = page.text_content("nav a[href='/printers']").strip()
|
||||
assert printers_label_fr == "Imprimantes", f"Expected 'Imprimantes', got '{printers_label_fr}'"
|
||||
|
||||
# Click the language toggle button
|
||||
page.click("button[title='Toggle language']")
|
||||
|
||||
# Wait for label to update
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
printers_label_en = page.text_content("nav a[href='/printers']").strip()
|
||||
assert printers_label_en == "Printers"
|
||||
|
||||
# Cleanup: reset to fr
|
||||
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
|
||||
|
||||
|
||||
def test_language_persists_across_reload(page, live_server: str) -> None:
|
||||
"""After switching to EN, language is preserved on page reload."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
|
||||
# Switch to English
|
||||
page.click("button[title='Toggle language']")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
# Reload
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
|
||||
timeout=3000,
|
||||
)
|
||||
|
||||
label_after_reload = page.text_content("nav a[href='/printers']").strip()
|
||||
assert label_after_reload == "Printers"
|
||||
|
||||
# Cleanup: reset to fr
|
||||
page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""UX-02: live-browser verification of PRNT-03 Alpine IP->port auto-derivation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_port_autofill(page, live_server: str) -> None:
|
||||
"""Typing an IP address into the printer form auto-populates port_name.
|
||||
|
||||
Evidence for UX-02: tests/e2e/test_port_autofill.py
|
||||
Closes: PRNT-03 Alpine.js port auto-derivation requirement.
|
||||
"""
|
||||
# /printers/new renders a full page (extends base.html) with Alpine.js loaded
|
||||
# and the add-printer form (moved from /printers to /printers/new in Plan 11-01)
|
||||
page.goto(f"{live_server}/printers/new", wait_until="domcontentloaded")
|
||||
|
||||
# Wait for Alpine to initialise (x-data hydration on the form wrapper)
|
||||
page.wait_for_selector("input[name='ip_address']")
|
||||
|
||||
# Fill triggers the 'input' event that Alpine @input listens to
|
||||
page.fill("input[name='ip_address']", "192.168.1.100")
|
||||
|
||||
# Alpine @input reacts synchronously; wait_for_function keeps the test stable
|
||||
page.wait_for_function(
|
||||
"document.querySelector(\"input[name='port_name']\").value === 'IP_192_168_1_100'",
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
assert page.input_value("input[name='port_name']") == "IP_192_168_1_100"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""UIE-01: E2E test for printer edit modal — open, pre-fill, submit, list update."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_printer_edit_modal_open_and_prefill(page, live_server: str) -> None:
|
||||
"""Edit button opens modal with printer's current name pre-filled."""
|
||||
import httpx
|
||||
|
||||
# Create a printer via API
|
||||
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
|
||||
api.post(
|
||||
"/printers",
|
||||
data={
|
||||
"name": "EditTest Printer",
|
||||
"ip_address": "10.0.5.1",
|
||||
"port_name": "IP_10_0_5_1",
|
||||
},
|
||||
)
|
||||
|
||||
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("button:has-text('Edit')")
|
||||
page.click("button:has-text('Edit')")
|
||||
|
||||
# Dialog should be open
|
||||
page.wait_for_selector("dialog[open]")
|
||||
# Name input should be pre-filled
|
||||
name_val = page.input_value("dialog[open] input[name='name']")
|
||||
assert name_val == "EditTest Printer"
|
||||
|
||||
|
||||
def test_printer_edit_submit_updates_list(page, live_server: str) -> None:
|
||||
"""Submitting the edit form updates the printer name in the list (no page reload)."""
|
||||
import httpx
|
||||
|
||||
with httpx.Client(base_url=live_server, follow_redirects=True) as api:
|
||||
resp = api.post(
|
||||
"/printers",
|
||||
data={
|
||||
"name": "OriginalName",
|
||||
"ip_address": "10.0.5.2",
|
||||
"port_name": "IP_10_0_5_2",
|
||||
},
|
||||
)
|
||||
|
||||
# Find the printer ID from the DB via API — target the specific Edit button by printer ID
|
||||
page.goto(f"{live_server}/printers", wait_until="domcontentloaded")
|
||||
# Find the row containing "OriginalName" and click its Edit button
|
||||
row = page.locator("tr", has=page.locator("a", has_text="OriginalName"))
|
||||
row.wait_for()
|
||||
row.locator("button:has-text('Edit')").click()
|
||||
page.wait_for_selector("dialog[open]")
|
||||
|
||||
# Clear and update the name field
|
||||
page.fill("dialog[open] input[name='name']", "UpdatedName")
|
||||
page.click("dialog[open] button[type='submit']")
|
||||
|
||||
# Wait for HTMX to swap the updated list — the anchor for the printer should show new name
|
||||
page.wait_for_selector("a:has-text('UpdatedName')")
|
||||
assert page.locator("a:has-text('UpdatedName')").is_visible()
|
||||
assert page.locator("a:has-text('OriginalName')").count() == 0
|
||||
@@ -0,0 +1,50 @@
|
||||
"""UIE-04: E2E tests for theme toggle — data-theme cycling and localStorage persistence."""
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
|
||||
def test_theme_cycles_on_click(page, live_server: str) -> None:
|
||||
"""Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
|
||||
# Initial state: auto (default from base.html)
|
||||
initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||||
assert initial_theme == "auto"
|
||||
|
||||
# Click once -> light
|
||||
page.click("button[aria-label='auto']")
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-theme') === 'light'",
|
||||
timeout=2000,
|
||||
)
|
||||
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "light"
|
||||
|
||||
# Click again -> dark
|
||||
page.click("button[aria-label='light']")
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-theme') === 'dark'",
|
||||
timeout=2000,
|
||||
)
|
||||
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
|
||||
|
||||
|
||||
def test_theme_persists_across_reload(page, live_server: str) -> None:
|
||||
"""After clicking theme toggle, the chosen theme is restored on reload."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
|
||||
# Switch to light mode
|
||||
page.click("button[aria-label='auto']")
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-theme') === 'light'",
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
# Reload the page
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
|
||||
# Theme should still be light (from localStorage)
|
||||
theme_after_reload = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||||
assert theme_after_reload == "light"
|
||||
|
||||
# Cleanup: reset to auto
|
||||
page.evaluate("localStorage.setItem('imptune_theme', 'auto')")
|
||||
Reference in New Issue
Block a user