diff --git a/tests/conftest.py b/tests/conftest.py index a906d46..b3de0ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/page_check/conftest.py b/tests/page_check/conftest.py index 6e469c5..ba6811e 100644 --- a/tests/page_check/conftest.py +++ b/tests/page_check/conftest.py @@ -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) diff --git a/tests/page_check/test_bot_run.py b/tests/page_check/test_bot_run.py index 72552bc..29b5a09 100644 --- a/tests/page_check/test_bot_run.py +++ b/tests/page_check/test_bot_run.py @@ -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 diff --git a/tests/page_check_ai/conftest.py b/tests/page_check_ai/conftest.py index 148acf2..44a5645 100644 --- a/tests/page_check_ai/conftest.py +++ b/tests/page_check_ai/conftest.py @@ -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}/ 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) diff --git a/tests/page_check_ai/test_ai_shared.py b/tests/page_check_ai/test_ai_shared.py index 29a25c6..28eca81 100644 --- a/tests/page_check_ai/test_ai_shared.py +++ b/tests/page_check_ai/test_ai_shared.py @@ -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()) diff --git a/tests/page_check_ai_desktop/conftest.py b/tests/page_check_ai_desktop/conftest.py index fe6e4b0..d661cf5 100644 --- a/tests/page_check_ai_desktop/conftest.py +++ b/tests/page_check_ai_desktop/conftest.py @@ -2,11 +2,11 @@ AI sections (rpa-dashboard-ai/templates/desktop/ai_sections.html) pasted in before . 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) diff --git a/tests/page_check_ai_desktop/test_ai_desktop.py b/tests/page_check_ai_desktop/test_ai_desktop.py index 8ffef30..7afbc67 100644 --- a/tests/page_check_ai_desktop/test_ai_desktop.py +++ b/tests/page_check_ai_desktop/test_ai_desktop.py @@ -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; diff --git a/tests/page_check_common.py b/tests/page_check_common.py new file mode 100644 index 0000000..de088ff --- /dev/null +++ b/tests/page_check_common.py @@ -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()