WIP: AI desktop sections, bridge slot, desktop page check
This commit is contained in:
1 parent
8951ac05a5
commit
472e18de0d
8 files changed
+465
-16
No files matched your search
@@ -0,0 +1,123 @@
|
||||
"""Seam 1 page check for the AI desktop page: the no-AI dashboard.html with the plain-HTML
|
||||
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 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 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
|
||||
review state on the backend directly (the `stub` fixture). Light, dark and narrow for every case.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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")
|
||||
def site(tmp_path_factory):
|
||||
"""The dashboard folder under test: dashboard.html, server_stub.py and bands.py."""
|
||||
if os.environ.get("PAGE_CHECK_AI_DESKTOP_DIR"):
|
||||
return Path(os.environ["PAGE_CHECK_AI_DESKTOP_DIR"]).resolve()
|
||||
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], NO_AI.read_text(encoding="utf-8"))
|
||||
if WIRED:
|
||||
html = html.replace("const SAMPLE_DATA = true", "const SAMPLE_DATA = false")
|
||||
sections = (AI / "desktop" / "ai_sections.html").read_text(encoding="utf-8")
|
||||
out = tmp_path_factory.mktemp("desktop")
|
||||
(out / "dashboard.html").write_text(html.replace("</body>", sections + "\n</body>"), encoding="utf-8")
|
||||
for f in ["server_stub.py", "bands.py"]:
|
||||
(out / f).write_bytes((AI / "web" / f).read_bytes())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def page_url(site):
|
||||
return (site / "dashboard.html").as_uri()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub(stub_module):
|
||||
"""The review backend the bridge talks to, reset to its sample data."""
|
||||
stub_module.reset()
|
||||
return stub_module
|
||||
|
||||
|
||||
def ai_request(stub, req_id, method, path, body):
|
||||
"""What ai_bridge.py's ai_request slot does: dispatch, then answer with one envelope."""
|
||||
status, payload = stub.dispatch(method, path, json.loads(body or "{}"))
|
||||
return json.dumps({"event": "ai_response", "data": {"id": req_id, "status": status, "body": payload}})
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The AI web page's review cases, run unchanged against the desktop page.
|
||||
|
||||
They use this folder's `page` and `stub` fixtures: the page talks to the review backend
|
||||
over the fake Qt bridge instead of HTTP.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check_ai"))
|
||||
|
||||
from test_ai_review import ( # noqa: E402,F401
|
||||
test_approve_stays_blocked_while_rows_are_unverified,
|
||||
test_band_counts_and_legend_match,
|
||||
test_band_filters_show_the_right_rows,
|
||||
test_draft_edit_is_not_saved_until_save,
|
||||
test_page_x_of_y_updates_while_running,
|
||||
)
|
||||
from test_ai_review import FIELDS, FLAGGED, open_review # noqa: E402
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
TAMPER = """async (path) => {
|
||||
const reply = new Promise(done => { const base = window.__pyMsg;
|
||||
window.__pyMsg = m => { base(m); if (JSON.parse(m).event === 'ai_response') done(JSON.parse(m).data); }; });
|
||||
bridge.ai_request(999, 'POST', path, '{}');
|
||||
return await reply;
|
||||
}"""
|
||||
|
||||
|
||||
def test_a_changed_page_cannot_approve_over_the_bridge(page, stub):
|
||||
"""The gate is Python's: a page that skips its own buttons and calls the slot is still refused."""
|
||||
stub.put_run("job-t", "bands.pdf", FIELDS)
|
||||
open_review(page, "job-t")
|
||||
reply = page.evaluate(TAMPER, "/runs/job-t/approve")
|
||||
assert reply["id"] == 999 and reply["status"] == 409 and str(FLAGGED) in reply["body"]["detail"]
|
||||
assert stub.RUNS["job-t"]["approved"] is False
|
||||
@@ -11,7 +11,8 @@ HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py",
|
||||
"rpa-dashboard-no-ai/templates/bot_runner.py"])
|
||||
"rpa-dashboard-no-ai/templates/bot_runner.py",
|
||||
"rpa-dashboard-ai/templates/desktop/ai_bridge.py"])
|
||||
def test_template_passes_lint_hook_unfilled(template):
|
||||
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
||||
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
||||
|
||||
Reference in new issue
Block a user