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
(Playwright's sync API allows only one instance per process)."""
"""Shared by the page checks: one headless Chromium for the whole run
(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
from page_check_common import WIRED, push_to
from playwright.sync_api import sync_playwright
@@ -10,3 +12,16 @@ def browser():
b = p.chromium.launch()
yield b
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].
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).
Shared helpers (modes, fill, fake bridge, page opener): tests/page_check_common.py.
"""
import json
import os
import re
from pathlib import Path
import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, open_page
ROOT = Path(__file__).resolve().parents[2]
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")
@@ -58,28 +33,4 @@ def page_url(tmp_path_factory):
@pytest.fixture(params=list(MODES))
def page(request, browser, page_url):
width, height, dark = MODES[request.param]
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)
yield from open_page(browser, page_url, request.param, FAKE_BRIDGE)
+1 -1
View File
@@ -6,7 +6,7 @@ import re
from pathlib import Path
import pytest
from conftest import WIRED
from page_check_common import WIRED
from playwright.sync_api import expect
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
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 importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
import pytest
from page_check_common import MODES, TEST_FILL, load_stub, open_page
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"}]),
}
TEST_FILL = {**TEST_FILL, "APP_NAME": "Test Extractor", "API_BASE": "/api"}
RECORD_ACTIONS = """
window.__calls = [];
const realFetch = window.fetch;
@@ -43,17 +34,9 @@ window.fetch = (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__")
@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."""
@@ -81,11 +64,7 @@ def built(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
return load_stub(site)
@pytest.fixture(scope="session")
@@ -110,26 +89,4 @@ def page_url(built, 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)
yield from open_page(browser, page_url, request.param, RECORD_ACTIONS)
+2 -14
View File
@@ -2,18 +2,6 @@
They use this folder's `page` and `push` fixtures, so they check the React page.
"""
import sys
from pathlib import Path
from page_check_common import no_ai_cases
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check"))
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,
)
globals().update(no_ai_cases())
+8 -61
View File
@@ -2,11 +2,11 @@
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
dashboard.html and server_stub.py). Without it, the no-AI template is filled with the no-AI
check's TEST_FILL with no optional sections (sections.py) and the AI sections are pasted in,
the way the skill does it.
dashboard.html and server_stub.py). Without it, the no-AI template is filled with the shared
TEST_FILL (tests/page_check_common.py) with no optional sections (sections.py) and the AI
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
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
@@ -16,41 +16,14 @@ import importlib.util
import json
import os
import re
import sys
from pathlib import Path
import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, load_stub, open_page
ROOT = Path(__file__).resolve().parents[2]
NO_AI = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
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")
@@ -80,11 +53,7 @@ def page_url(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("desktop_server_stub", site / "server_stub.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
return load_stub(site, "desktop_server_stub")
@pytest.fixture
@@ -102,27 +71,5 @@ def ai_request(stub, req_id, method, path, body):
@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.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)
expose = ("__aiRequest", lambda i, method, path, body: ai_request(stub, i, method, path, body))
yield from open_page(browser, page_url, request.param, FAKE_BRIDGE, expose)
+3 -11
View File
@@ -25,17 +25,9 @@ from test_ai_cost import ( # noqa: E402,F401
test_spend_tiles_sum_the_priced_runs,
)
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check"))
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,
)
from page_check_common import no_ai_cases # noqa: E402
globals().update(no_ai_cases())
TAMPER = """async (path) => {
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()