Complete testing system: pyproject.toml (pytest markers), test.sh orchestrator with auto app start/stop and colorful summary, pre-push hook, Gitea Actions workflow. New QA tests: API health (7 endpoints), responsive (3 viewports), log monitoring (ERROR/ORA-/Traceback detection), real GoMag sync, PL/SQL package validation, smoke prod (read-only). Converted test_app_basic.py and test_integration.py to pytest. Added pytestmark to all existing tests (unit/e2e/oracle). E2E conftest upgraded: console error collector, screenshot on failure, auto-detect live app on :5003. Usage: ./test.sh ci (30s) | ./test.sh full (2-3min) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
197 lines
5.8 KiB
Python
197 lines
5.8 KiB
Python
"""
|
|
Playwright E2E test fixtures.
|
|
Starts the FastAPI app on a random port with test SQLite, no Oracle.
|
|
Includes console error collector and screenshot capture.
|
|
"""
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import pytest
|
|
import subprocess
|
|
import time
|
|
import socket
|
|
from pathlib import Path
|
|
|
|
|
|
# --- Screenshots directory ---
|
|
QA_REPORTS_DIR = Path(__file__).parents[3] / "qa-reports"
|
|
SCREENSHOTS_DIR = QA_REPORTS_DIR / "screenshots"
|
|
|
|
|
|
def _free_port():
|
|
with socket.socket() as s:
|
|
s.bind(('', 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def _app_is_running(url):
|
|
"""Check if app is already running at the given URL."""
|
|
try:
|
|
import urllib.request
|
|
urllib.request.urlopen(f"{url}/health", timeout=2)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app_url(request):
|
|
"""Use a running app if available (e.g. started by test.sh), otherwise start a subprocess.
|
|
|
|
When --base-url is provided or app is already running on :5003, use the live app.
|
|
This allows E2E tests to run against the real Oracle-backed app in ./test.sh full.
|
|
"""
|
|
# Check if --base-url was provided via pytest-playwright
|
|
base_url = request.config.getoption("--base-url", default=None)
|
|
|
|
# Try live app on :5003 first
|
|
live_url = base_url or "http://localhost:5003"
|
|
if _app_is_running(live_url):
|
|
yield live_url
|
|
return
|
|
|
|
# No live app — start subprocess with dummy Oracle (structure-only tests)
|
|
port = _free_port()
|
|
tmpdir = tempfile.mkdtemp()
|
|
sqlite_path = os.path.join(tmpdir, "e2e_test.db")
|
|
|
|
env = os.environ.copy()
|
|
env.update({
|
|
"FORCE_THIN_MODE": "true",
|
|
"SQLITE_DB_PATH": sqlite_path,
|
|
"ORACLE_DSN": "dummy",
|
|
"ORACLE_USER": "dummy",
|
|
"ORACLE_PASSWORD": "dummy",
|
|
"JSON_OUTPUT_DIR": tmpdir,
|
|
})
|
|
|
|
api_dir = os.path.join(os.path.dirname(__file__), "..", "..")
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port)],
|
|
cwd=api_dir,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
# Wait for startup (up to 15 seconds)
|
|
url = f"http://127.0.0.1:{port}"
|
|
for _ in range(30):
|
|
try:
|
|
import urllib.request
|
|
urllib.request.urlopen(f"{url}/health", timeout=1)
|
|
break
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
else:
|
|
proc.kill()
|
|
stdout, stderr = proc.communicate()
|
|
raise RuntimeError(
|
|
f"App failed to start on port {port}.\n"
|
|
f"STDOUT: {stdout.decode()[-2000:]}\n"
|
|
f"STDERR: {stderr.decode()[-2000:]}"
|
|
)
|
|
|
|
yield url
|
|
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def seed_test_data(app_url):
|
|
"""
|
|
Seed SQLite with test data via API calls.
|
|
|
|
Oracle is unavailable in E2E tests — only SQLite-backed pages are
|
|
fully functional. This fixture exists as a hook for future seeding;
|
|
for now E2E tests validate UI structure on empty-state pages.
|
|
"""
|
|
return app_url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Console & Network Error Collectors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(scope="session")
|
|
def console_errors():
|
|
"""Session-scoped list collecting JS console errors across all tests."""
|
|
return []
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def network_errors():
|
|
"""Session-scoped list collecting HTTP 4xx/5xx responses across all tests."""
|
|
return []
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _attach_collectors(page, console_errors, network_errors, request):
|
|
"""Auto-attach console and network listeners to every test's page."""
|
|
test_errors = []
|
|
test_network = []
|
|
|
|
def on_console(msg):
|
|
if msg.type == "error":
|
|
entry = {"test": request.node.name, "text": msg.text, "type": "console.error"}
|
|
console_errors.append(entry)
|
|
test_errors.append(entry)
|
|
|
|
def on_pageerror(exc):
|
|
entry = {"test": request.node.name, "text": str(exc), "type": "pageerror"}
|
|
console_errors.append(entry)
|
|
test_errors.append(entry)
|
|
|
|
def on_response(response):
|
|
if response.status >= 400:
|
|
entry = {
|
|
"test": request.node.name,
|
|
"url": response.url,
|
|
"status": response.status,
|
|
"type": "network_error",
|
|
}
|
|
network_errors.append(entry)
|
|
test_network.append(entry)
|
|
|
|
page.on("console", on_console)
|
|
page.on("pageerror", on_pageerror)
|
|
page.on("response", on_response)
|
|
|
|
yield
|
|
|
|
# Remove listeners to avoid leaks
|
|
page.remove_listener("console", on_console)
|
|
page.remove_listener("pageerror", on_pageerror)
|
|
page.remove_listener("response", on_response)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Screenshot on failure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _screenshot_on_failure(page, request):
|
|
"""Take a screenshot when a test fails."""
|
|
yield
|
|
|
|
if request.node.rep_call and request.node.rep_call.failed:
|
|
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
name = request.node.name.replace("/", "_").replace("::", "_")
|
|
path = SCREENSHOTS_DIR / f"FAIL-{name}.png"
|
|
try:
|
|
page.screenshot(path=str(path))
|
|
except Exception:
|
|
pass # page may be closed
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
def pytest_runtest_makereport(item, call):
|
|
"""Store test result on the item for _screenshot_on_failure."""
|
|
outcome = yield
|
|
rep = outcome.get_result()
|
|
setattr(item, f"rep_{rep.when}", rep)
|