React + Vite + Tailwind starter with the no-AI shell, band legend, review split pane with drafts and verify, and a server stub that refuses approval while flagged fields are unverified. Band self-check and AI page check. Both page checks share one Chromium via tests/conftest.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""Seam 1 page check for the AI web dashboard (React + Vite), served by its server stub.
|
|
|
|
Page under test: set PAGE_CHECK_AI_DIR to a generated dashboard folder (the one with
|
|
package.json, index.html and server_stub.py). Without it, the AI web starter template
|
|
is filled with TEST_FILL and built in a cache folder. Either way the folder is built
|
|
with npm, and its server_stub.py runs in this process, so tests can set run and
|
|
review state directly through the `stub` fixture.
|
|
|
|
Contract envelopes go in through window.__pyMsg, as in the no-AI check. Each action
|
|
the page POSTs to {API_BASE}/<slot> is recorded in window.__calls as [slot, ...args].
|
|
Every page test runs in light, dark and narrow mode.
|
|
"""
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TEMPLATE = ROOT / "rpa-dashboard-ai" / "templates" / "web"
|
|
TEST_FILL = {
|
|
"APP_NAME": "Test Extractor",
|
|
"APP_SUBTITLE": "Page check",
|
|
"LOGO_SRC": "",
|
|
"API_BASE": "/api",
|
|
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
|
|
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
|
|
}
|
|
RECORD_ACTIONS = """
|
|
window.__calls = [];
|
|
const realFetch = window.fetch;
|
|
window.fetch = (url, opts = {}) => {
|
|
const m = String(url).match(/\\/api\\/([a-z-]+)$/);
|
|
if (m && opts.method === 'POST') window.__calls.push([m[1].replace(/-/g, '_'), ...JSON.parse(opts.body || '[]')]);
|
|
return realFetch(url, opts);
|
|
};
|
|
"""
|
|
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
|
SKIP_COPY = shutil.ignore_patterns("node_modules", "dist", "__pycache__")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def site():
|
|
"""The dashboard folder under test: server_stub.py, index.html and the sources."""
|
|
if os.environ.get("PAGE_CHECK_AI_DIR"):
|
|
return Path(os.environ["PAGE_CHECK_AI_DIR"]).resolve()
|
|
pkg = (TEMPLATE / "package.json").read_bytes() + (TEMPLATE / "package-lock.json").read_bytes()
|
|
out = Path(tempfile.gettempdir()) / f"rpa-dashboard-ai-web-{hashlib.sha1(pkg).hexdigest()[:10]}"
|
|
shutil.rmtree(out / "src", ignore_errors=True) # node_modules is kept between runs
|
|
shutil.copytree(TEMPLATE, out, dirs_exist_ok=True, ignore=SKIP_COPY)
|
|
index = out / "index.html"
|
|
index.write_text(re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], index.read_text(encoding="utf-8")),
|
|
encoding="utf-8")
|
|
return out
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def built(site):
|
|
npm = shutil.which("npm") or pytest.skip("npm not on PATH")
|
|
if not (site / "node_modules").exists():
|
|
subprocess.run([npm, "ci" if (site / "package-lock.json").exists() else "install"], cwd=site, check=True)
|
|
r = subprocess.run([npm, "run", "build"], cwd=site, capture_output=True, text=True)
|
|
assert r.returncode == 0, r.stdout + r.stderr
|
|
return site
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def stub_module(site):
|
|
sys.path.insert(0, str(site)) # the stub imports bands.py from its own folder
|
|
spec = importlib.util.spec_from_file_location("server_stub", site / "server_stub.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def server(stub_module):
|
|
srv = stub_module.make_server("127.0.0.1", 0)
|
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
|
srv.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def stub(stub_module, server):
|
|
"""The running stub, reset to its sample data. Tests change stub.RUNS / stub.ROWS directly."""
|
|
stub_module.reset()
|
|
return stub_module
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def page_url(built, server):
|
|
return server + "/"
|
|
|
|
|
|
@pytest.fixture(params=list(MODES))
|
|
def page(request, browser, page_url, stub):
|
|
width, height, dark = MODES[request.param]
|
|
ctx = browser.new_context(viewport={"width": width, "height": height})
|
|
ctx.add_init_script(RECORD_ACTIONS)
|
|
pg = ctx.new_page()
|
|
pg.set_default_timeout(5000)
|
|
errors = []
|
|
pg.on("pageerror", lambda e: errors.append(e))
|
|
pg.goto(page_url)
|
|
assert not errors, f"page script errors: {errors}"
|
|
if dark:
|
|
push_to(pg, "apply_dark", {"on": True})
|
|
yield pg
|
|
ctx.close()
|
|
|
|
|
|
def push_to(pg, event, data):
|
|
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
|
|
|
|
|
|
@pytest.fixture
|
|
def push(page):
|
|
"""push(event, data): send one contract envelope to the page, as the bot would."""
|
|
return lambda event, data: push_to(page, event, data)
|