"""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}/ 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 def sample_data(): """Shared no-AI cases that need the page's own sample data skip on a bot-wired page.""" if os.environ.get("PAGE_CHECK_WIRED") == "1": pytest.skip("page is wired to a bot, so it has no sample data") @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)