Share page-check helpers in tests/page_check_common.py

MODES, TEST_FILL, FAKE_BRIDGE, the stub loader, the page opener and the no-AI case list now live in one place; sample_data and push move to tests/conftest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:52:35 +05:30
1 parent 7ad73c1fda
commit 344718007e
8 files changed
+125 -189

No files matched your search

+17 -2
View File
@@ -1,6 +1,8 @@
"""Shared by both page checks: one headless Chromium for the whole run """Shared by the page checks: one headless Chromium for the whole run
(Playwright's sync API allows only one instance per process).""" (Playwright's sync API allows only one instance per process), and the fixtures
that don't depend on which page is under test."""
import pytest import pytest
from page_check_common import WIRED, push_to
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
@@ -10,3 +12,16 @@ def browser():
b = p.chromium.launch() b = p.chromium.launch()
yield b yield b
b.close() b.close()
@pytest.fixture
def sample_data():
"""Use on cases that need the page's own sample data; they skip on a bot-wired page."""
if WIRED:
pytest.skip("page is wired to a bot, so it has no sample 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)
+3 -52
View File
@@ -6,42 +6,17 @@ no-AI template is filled with TEST_FILL. Every test runs in light, dark and narr
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args]. The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
PAGE_CHECK_WIRED=1: the page is wired to a bot (SAMPLE_DATA off), so sample-data cases skip. PAGE_CHECK_WIRED=1: the page is wired to a bot (SAMPLE_DATA off), so sample-data cases skip.
PAGE_CHECK_REPLAY: a JSON-lines file of envelopes a real bot run produced (bot_runner.py's dry run). PAGE_CHECK_REPLAY: a JSON-lines file of envelopes a real bot run produced (bot_runner.py's dry run).
Shared helpers (modes, fill, fake bridge, page opener): tests/page_check_common.py.
""" """
import json
import os import os
import re import re
from pathlib import Path from pathlib import Path
import pytest import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, open_page
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html" TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
TEST_FILL = {
"APP_NAME": "Test Bot",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
"CHECKPOINTS_JSON": json.dumps(["Sign in", "Open the list", "Process items", "Finish up"]),
}
FAKE_BRIDGE = """
window.__calls = [];
window.qt = {webChannelTransport: {}};
window.QWebChannel = function (_transport, ready) {
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => { window.__calls.push([slot, ...args]); }});
ready({objects: {pyBridge}});
};
"""
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
WIRED = os.environ.get("PAGE_CHECK_WIRED") == "1"
@pytest.fixture
def sample_data():
"""Use on cases that need the built-in sample data; they skip on a bot-wired page."""
if WIRED:
pytest.skip("page is wired to a bot, so it has no sample data")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -58,28 +33,4 @@ def page_url(tmp_path_factory):
@pytest.fixture(params=list(MODES)) @pytest.fixture(params=list(MODES))
def page(request, browser, page_url): def page(request, browser, page_url):
width, height, dark = MODES[request.param] yield from open_page(browser, page_url, request.param, FAKE_BRIDGE)
ctx = browser.new_context(viewport={"width": width, "height": height})
ctx.add_init_script(FAKE_BRIDGE)
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 pg.get_by_role("form", name="Sign in").is_visible(): # login page section: the host lets us in
push_to(pg, "login_result", {"ok": True})
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)
+1 -1
View File
@@ -6,7 +6,7 @@ import re
from pathlib import Path from pathlib import Path
import pytest import pytest
from conftest import WIRED from page_check_common import WIRED
from playwright.sync_api import expect from playwright.sync_api import expect
from test_contract import colour_of, kpi, open_tab from test_contract import colour_of, kpi, open_tab
+5 -48
View File
@@ -8,32 +8,23 @@ review state directly through the `stub` fixture.
Contract envelopes go in through window.__pyMsg, as in the no-AI check. Each action 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]. 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. Every page test runs in light, dark and narrow mode. Shared helpers: tests/page_check_common.py.
""" """
import hashlib import hashlib
import importlib.util
import json
import os import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys
import tempfile import tempfile
import threading import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
from page_check_common import MODES, TEST_FILL, load_stub, open_page
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-ai" / "templates" / "web" TEMPLATE = ROOT / "rpa-dashboard-ai" / "templates" / "web"
TEST_FILL = { TEST_FILL = {**TEST_FILL, "APP_NAME": "Test Extractor", "API_BASE": "/api"}
"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 = """ RECORD_ACTIONS = """
window.__calls = []; window.__calls = [];
const realFetch = window.fetch; const realFetch = window.fetch;
@@ -43,17 +34,9 @@ window.fetch = (url, opts = {}) => {
return realFetch(url, opts); 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__") 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") @pytest.fixture(scope="session")
def site(): def site():
"""The dashboard folder under test: server_stub.py, index.html and the sources.""" """The dashboard folder under test: server_stub.py, index.html and the sources."""
@@ -81,11 +64,7 @@ def built(site):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def stub_module(site): def stub_module(site):
sys.path.insert(0, str(site)) # the stub imports bands.py from its own folder return load_stub(site)
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") @pytest.fixture(scope="session")
@@ -110,26 +89,4 @@ def page_url(built, server):
@pytest.fixture(params=list(MODES)) @pytest.fixture(params=list(MODES))
def page(request, browser, page_url, stub): def page(request, browser, page_url, stub):
width, height, dark = MODES[request.param] yield from open_page(browser, page_url, request.param, RECORD_ACTIONS)
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)
+2 -14
View File
@@ -2,18 +2,6 @@
They use this folder's `page` and `push` fixtures, so they check the React page. They use this folder's `page` and `push` fixtures, so they check the React page.
""" """
import sys from page_check_common import no_ai_cases
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check")) globals().update(no_ai_cases())
from test_contract import ( # noqa: E402,F401
test_dark_toggle_flips_theme,
test_donut_and_legend_match_batch_states,
test_kpi_counts_match_batch_states,
test_log_lines_show_their_type,
test_no_bot_page_runs_on_sample_data,
test_plain_browser_first_open_shows_sample_data,
test_report_search_filters_rows_and_shows_bucket_pill,
test_step_list_follows_step_label,
)
+8 -61
View File
@@ -2,11 +2,11 @@
AI sections (rpa-dashboard-ai/templates/desktop/ai_sections.html) pasted in before </body>. AI sections (rpa-dashboard-ai/templates/desktop/ai_sections.html) pasted in before </body>.
Page under test: set PAGE_CHECK_AI_DESKTOP_DIR to a generated dashboard folder (the one with Page under test: set PAGE_CHECK_AI_DESKTOP_DIR to a generated dashboard folder (the one with
dashboard.html and server_stub.py). Without it, the no-AI template is filled with the no-AI dashboard.html and server_stub.py). Without it, the no-AI template is filled with the shared
check's TEST_FILL with no optional sections (sections.py) and the AI sections are pasted in, TEST_FILL (tests/page_check_common.py) with no optional sections (sections.py) and the AI
the way the skill does it. sections are pasted in, the way the skill does it.
The page sees a fake Qt bridge, as in the no-AI check: each slot call lands in window.__calls. The page sees the shared fake Qt bridge: each slot call lands in window.__calls.
The AI slot, ai_request(id, method, path, body), goes to the review backend's dispatch() in The AI slot, ai_request(id, method, path, body), goes to the review backend's dispatch() in
this process, and the reply comes back as an `ai_response` envelope, exactly what this process, and the reply comes back as an `ai_response` envelope, exactly what
ai_bridge.py's slot does. So the approve gate under test is the Python one. Tests set run and ai_bridge.py's slot does. So the approve gate under test is the Python one. Tests set run and
@@ -16,41 +16,14 @@ import importlib.util
import json import json
import os import os
import re import re
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, load_stub, open_page
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
NO_AI = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html" NO_AI = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
AI = ROOT / "rpa-dashboard-ai" / "templates" AI = ROOT / "rpa-dashboard-ai" / "templates"
TEST_FILL = {
"APP_NAME": "Test Extractor",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
}
FAKE_BRIDGE = """
window.__calls = [];
window.qt = {webChannelTransport: {}};
window.QWebChannel = function (_transport, ready) {
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => {
window.__calls.push([slot, ...args]);
if (slot === 'ai_request') window.__aiRequest(...args).then(window.__pyMsg);
}});
ready({objects: {pyBridge}});
};
"""
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
WIRED = os.environ.get("PAGE_CHECK_WIRED") == "1"
@pytest.fixture
def sample_data():
if WIRED:
pytest.skip("page is wired to a bot, so it has no sample data")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -80,11 +53,7 @@ def page_url(site):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def stub_module(site): def stub_module(site):
sys.path.insert(0, str(site)) # the stub imports bands.py from its own folder return load_stub(site, "desktop_server_stub")
spec = importlib.util.spec_from_file_location("desktop_server_stub", site / "server_stub.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture @pytest.fixture
@@ -102,27 +71,5 @@ def ai_request(stub, req_id, method, path, body):
@pytest.fixture(params=list(MODES)) @pytest.fixture(params=list(MODES))
def page(request, browser, page_url, stub): def page(request, browser, page_url, stub):
width, height, dark = MODES[request.param] expose = ("__aiRequest", lambda i, method, path, body: ai_request(stub, i, method, path, body))
ctx = browser.new_context(viewport={"width": width, "height": height}) yield from open_page(browser, page_url, request.param, FAKE_BRIDGE, expose)
ctx.expose_function("__aiRequest", lambda i, method, path, body: ai_request(stub, i, method, path, body))
ctx.add_init_script(FAKE_BRIDGE)
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
assert not errors, f"page script errors: {errors}"
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):
return lambda event, data: push_to(page, event, data)
+3 -11
View File
@@ -25,17 +25,9 @@ from test_ai_cost import ( # noqa: E402,F401
test_spend_tiles_sum_the_priced_runs, test_spend_tiles_sum_the_priced_runs,
) )
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check")) from page_check_common import no_ai_cases # noqa: E402
from test_contract import ( # noqa: E402,F401
test_dark_toggle_flips_theme, globals().update(no_ai_cases())
test_donut_and_legend_match_batch_states,
test_kpi_counts_match_batch_states,
test_log_lines_show_their_type,
test_no_bot_page_runs_on_sample_data,
test_plain_browser_first_open_shows_sample_data,
test_report_search_filters_rows_and_shows_bucket_pill,
test_step_list_follows_step_label,
)
TAMPER = """async (path) => { TAMPER = """async (path) => {
const reply = new Promise(done => { const base = window.__pyMsg; const reply = new Promise(done => { const base = window.__pyMsg;
+86
View File
@@ -0,0 +1,86 @@
"""Shared by the three page checks (page_check, page_check_ai, page_check_ai_desktop):
the modes, the test fill, the fake Qt bridge, the stub loader and the page opener."""
import importlib.util
import json
import os
import sys
from pathlib import Path
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
WIRED = os.environ.get("PAGE_CHECK_WIRED") == "1"
TEST_FILL = {
"APP_NAME": "Test Bot",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
"CHECKPOINTS_JSON": json.dumps(["Sign in", "Open the list", "Process items", "Finish up"]),
}
# Each slot call lands in window.__calls as [slot, ...args]. The AI desktop check exposes
# window.__aiRequest, which answers ai_request with an ai_response envelope, as ai_bridge.py does.
FAKE_BRIDGE = """
window.__calls = [];
window.qt = {webChannelTransport: {}};
window.QWebChannel = function (_transport, ready) {
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => {
window.__calls.push([slot, ...args]);
if (slot === 'ai_request' && window.__aiRequest) window.__aiRequest(...args).then(window.__pyMsg);
}});
ready({objects: {pyBridge}});
};
"""
# The no-AI contract cases every AI page must also pass, unchanged.
NO_AI_CASES = [
"test_dark_toggle_flips_theme",
"test_donut_and_legend_match_batch_states",
"test_kpi_counts_match_batch_states",
"test_log_lines_show_their_type",
"test_no_bot_page_runs_on_sample_data",
"test_plain_browser_first_open_shows_sample_data",
"test_report_search_filters_rows_and_shows_bucket_pill",
"test_step_list_follows_step_label",
]
def no_ai_cases():
"""{name: test} for NO_AI_CASES; a test module runs them with globals().update(no_ai_cases())."""
sys.path.insert(0, str(Path(__file__).resolve().parent / "page_check"))
import test_contract
return {name: getattr(test_contract, name) for name in NO_AI_CASES}
def load_stub(site, name="server_stub"):
"""Import site/server_stub.py. It imports bands.py and pricing.py from its own folder."""
sys.path.insert(0, str(site))
spec = importlib.util.spec_from_file_location(name, Path(site) / "server_stub.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def push_to(pg, event, data):
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
def open_page(browser, url, mode, init_script, expose=None):
"""Generator for a `page` fixture: opens url in one mode, signs in through a login
section if the page has one, switches to dark, and fails on any page script error."""
width, height, dark = MODES[mode]
ctx = browser.new_context(viewport={"width": width, "height": height})
if expose:
ctx.expose_function(*expose)
ctx.add_init_script(init_script)
pg = ctx.new_page()
pg.set_default_timeout(5000)
errors = []
pg.on("pageerror", lambda e: errors.append(e))
pg.goto(url)
assert not errors, f"page script errors: {errors}"
if pg.get_by_role("form", name="Sign in").is_visible(): # login page section: the host lets us in
push_to(pg, "login_result", {"ok": True})
if dark:
push_to(pg, "apply_dark", {"on": True})
yield pg
assert not errors, f"page script errors: {errors}"
ctx.close()