Add rpa-run-tab skill: Tebra-style Run tab in 5 minutes
The Run tab from Tebra Auto Post, scrubbed and themable (Tebra Sky, TVL Teal, or a custom hex checked for contrast). The skill wires the real bot through tagged log-file lines and checks itself with a fake bot (complete, partial, stuck, crash) plus light and dark screenshots. Grill decisions R1-R14 are in PLAN.md, terms in GLOSSARY.md, and ADRs 0001-0003. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bb1cf268e
commit
258bfbb500
18 files changed
+1712
-2
No files matched your search
@@ -67,3 +67,9 @@ uv run --with pytest --with playwright pytest tests
|
||||
`bot_runner.py`, saves its events to `evals/results/<fixture>.run.jsonl`, and
|
||||
replays them in the page check.
|
||||
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
||||
- `page_check_run_tab/` + `test_run_tab.py`: **Run tab skill.** The page filled with a test
|
||||
config and driven through `window.__pyMsg`: Trigger states, sheet preview with masked
|
||||
passwords, checkpoints driving steps/boxes/progress, green/amber/red endings, the no-input
|
||||
mode, the dark toggle and no horizontal scroll, each in light, dark and narrow mode. The
|
||||
Python side covers line parsing, endings, the sample workbook and `palette.py --write`.
|
||||
The end-to-end fake-bot check lives in the skill itself: `python rpa-run-tab/templates/run_tab.py --selfcheck`.
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Seam 1 page check for the Run tab: run_tab.html filled with a test config, opened with
|
||||
the fake Qt bridge, driven through window.__pyMsg. Every case runs in light, dark and narrow.
|
||||
PAGE_CHECK_RUN_TAB_HTML: check a generated page instead (its config must already be filled)."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from page_check_common import FAKE_BRIDGE, MODES, open_page
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TEMPLATE = ROOT / "rpa-run-tab" / "templates" / "run_tab.html"
|
||||
CFG = {"title": "Test Bot", "sub": "Page check", "input": True, "inputHint": "Test workbook",
|
||||
"sample": True, "darkKey": "test_dark",
|
||||
"checkpoints": [{"name": "Sign in", "max": 300}, {"name": "Process rows", "max": 0},
|
||||
{"name": "Finish up", "max": 120}]}
|
||||
|
||||
|
||||
def _url(tmp, cfg):
|
||||
if os.environ.get("PAGE_CHECK_RUN_TAB_HTML"):
|
||||
return Path(os.environ["PAGE_CHECK_RUN_TAB_HTML"]).resolve().as_uri()
|
||||
out = tmp / f"run_tab_{cfg['input']}.html"
|
||||
out.write_text(TEMPLATE.read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg)), encoding="utf-8")
|
||||
return out.as_uri()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def page_url(tmp_path_factory):
|
||||
return _url(tmp_path_factory.mktemp("run_tab"), CFG)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def no_input_url(tmp_path_factory):
|
||||
return _url(tmp_path_factory.mktemp("run_tab_noinput"), {**CFG, "input": False, "sample": False})
|
||||
|
||||
|
||||
@pytest.fixture(params=list(MODES))
|
||||
def page(request, browser, page_url):
|
||||
yield from open_page(browser, page_url, request.param, FAKE_BRIDGE)
|
||||
|
||||
|
||||
@pytest.fixture(params=list(MODES))
|
||||
def no_input_page(request, browser, no_input_url):
|
||||
yield from open_page(browser, no_input_url, request.param, FAKE_BRIDGE)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What the operator sees on the Run tab, for each message the bot side sends."""
|
||||
from page_check_common import push_to
|
||||
|
||||
|
||||
def push(pg, event, data):
|
||||
push_to(pg, event, data)
|
||||
|
||||
|
||||
def btn(pg):
|
||||
return pg.locator("#top-run-btn")
|
||||
|
||||
|
||||
def test_trigger_waits_for_a_file_then_runs(page):
|
||||
assert "off" in btn(page).get_attribute("class")
|
||||
assert btn(page).inner_text() == "Upload a file to continue"
|
||||
push(page, "file_selected", {"path": "C:/in/input.xlsx", "name": "input.xlsx", "sheets": ["Accounts", "Settings"]})
|
||||
assert btn(page).inner_text() == "Trigger automation"
|
||||
assert ["read_sheet", "C:/in/input.xlsx", "Accounts"] in page.evaluate("window.__calls")
|
||||
btn(page).click()
|
||||
assert btn(page).inner_text() == "Running..."
|
||||
assert ["start_automation", "C:/in/input.xlsx"] in page.evaluate("window.__calls")
|
||||
assert page.locator("#prog-wrap").is_visible()
|
||||
|
||||
|
||||
def test_sheet_preview_masks_passwords(page):
|
||||
push(page, "file_selected", {"path": "C:/in/a.xlsx", "name": "a.xlsx", "sheets": ["Login"]})
|
||||
push(page, "sheet_data", {"sheet": "Login", "header": ["User", "Password"],
|
||||
"rows": [["ann", "hunter22"]], "total": 1, "more": False})
|
||||
cells = page.locator("table.data-tbl td").all_inner_texts()
|
||||
assert cells[0] == "ann" and "hunter22" not in cells[1] and set(cells[1]) == {"•"}
|
||||
|
||||
|
||||
def test_dropped_file_not_found_warns_and_stays_off(page):
|
||||
push(page, "file_selected", {"path": "", "name": "lost.xlsx", "sheets": []})
|
||||
assert "off" in btn(page).get_attribute("class")
|
||||
assert "lost.xlsx" in page.locator("#log").inner_text()
|
||||
|
||||
|
||||
def test_clear_file_resets_the_input(page):
|
||||
push(page, "file_selected", {"path": "C:/in/a.xlsx", "name": "a.xlsx", "sheets": []})
|
||||
page.get_by_role("button", name="Clear file").click()
|
||||
assert page.locator("#dz").is_visible() and "off" in btn(page).get_attribute("class")
|
||||
|
||||
|
||||
def test_checkpoints_drive_steps_boxes_and_progress(page):
|
||||
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
|
||||
btn(page).click()
|
||||
push(page, "cpstate", {"kind": "active", "cp": 2})
|
||||
assert "active" in page.locator("#cpbox-2").get_attribute("class")
|
||||
assert "done" in page.locator("#cpbox-1").get_attribute("class")
|
||||
assert page.locator(".step.active").inner_text().endswith("Process rows")
|
||||
assert page.locator("#prog-pct").inner_text() == "33%"
|
||||
push(page, "cpstate", {"kind": "active", "cp": 1}) # a loop back resets later boxes, the bar stays
|
||||
assert "pending" in page.locator("#cpbox-2").get_attribute("class")
|
||||
assert page.locator("#prog-pct").inner_text() == "33%"
|
||||
|
||||
|
||||
def test_log_drops_consecutive_duplicates(page):
|
||||
for msg in ["Signed in", "Signed in", "Row 1 done"]:
|
||||
push(page, "log", {"msg": msg, "type": "success"})
|
||||
assert page.locator(".log-line").all_inner_texts()[-2:] == ["Signed in", "Row 1 done"]
|
||||
|
||||
|
||||
def test_endings_green_amber_red(page):
|
||||
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
|
||||
for state, dot, fill, prefix in [("done", "done", "prog-fill", "✓ Completed in"),
|
||||
("partial", "partial", "prog-fill partial", "⚠ Finished in"),
|
||||
("error", "error", "prog-fill failed", "✕ Failed after")]:
|
||||
btn(page).click()
|
||||
push(page, "status", {"state": state, "msg": f"ended {state}"})
|
||||
assert page.locator("#sys-dot").get_attribute("class") == f"dot {dot}"
|
||||
assert page.locator("#prog-fill").get_attribute("class") == fill
|
||||
assert page.locator("#prog-time").inner_text().startswith(prefix)
|
||||
assert btn(page).inner_text() == "Trigger automation" # ready to run again
|
||||
|
||||
|
||||
def test_stuck_box_turns_red(page):
|
||||
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
|
||||
btn(page).click()
|
||||
push(page, "cpstate", {"kind": "active", "cp": 3})
|
||||
push(page, "cpstate", {"kind": "stuck", "cp": 3})
|
||||
assert "stuck" in page.locator("#cpbox-3").get_attribute("class")
|
||||
assert "stuck" in page.locator("#cpmeta-3").inner_text()
|
||||
|
||||
|
||||
def test_no_input_bot_is_ready_straight_away(no_input_page):
|
||||
assert not no_input_page.locator("#input-card").is_visible()
|
||||
assert no_input_page.locator("#top-run-btn").inner_text() == "Trigger automation"
|
||||
|
||||
|
||||
def test_dark_toggle_saves_choice(page):
|
||||
dark = page.evaluate("document.body.classList.contains('dark')")
|
||||
page.locator("#mode-btn").click()
|
||||
assert page.evaluate("document.body.classList.contains('dark')") != dark
|
||||
assert ["set_dark", not dark] in page.evaluate("window.__calls")
|
||||
|
||||
|
||||
def test_no_horizontal_scroll(page):
|
||||
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
|
||||
btn(page).click()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Run tab Python pieces: line parsing, endings, sheet reading, sample workbook, theme writing."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
T = ROOT / "rpa-run-tab" / "templates"
|
||||
sys.path.insert(0, str(T))
|
||||
import runner # noqa: E402
|
||||
|
||||
|
||||
def test_parse_lines():
|
||||
assert runner.parse("[CPSTATE] active 3") == ("cpstate", {"kind": "active", "cp": 3})
|
||||
assert runner.parse("[UI:ok] Signed in") == ("log", {"msg": "Signed in", "type": "success"})
|
||||
assert runner.parse("[UI] Opening list") == ("log", {"msg": "Opening list", "type": "info"})
|
||||
assert runner.parse("ValueError: bad row") == ("log", {"msg": "ValueError: bad row", "type": "error"})
|
||||
assert runner.parse("debug: clicked button 4") is None
|
||||
assert runner.parse(' File "bot.py", line 3') is None
|
||||
|
||||
|
||||
def test_outcomes():
|
||||
assert runner.outcome({"total": 3, "failed": []})[0] == "done"
|
||||
assert runner.outcome({"total": 3, "failed": [["a", "x"]]}) == ("partial", "Finished: 1 of 3 failed")
|
||||
assert runner.outcome({"total": 1, "failed": [["a", "x"]]})[0] == "error"
|
||||
assert runner.outcome(None)[0] == "error"
|
||||
assert runner.outcome({"total": 3}, stuck_name="Sign in") == ("error", "Stuck at Sign in, run again")
|
||||
|
||||
|
||||
def test_sample_and_sheet_reading(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["x"])
|
||||
pytest_qt = __import__("importlib").util.find_spec("PyQt6.QtWebEngineWidgets")
|
||||
if pytest_qt is None:
|
||||
import pytest
|
||||
pytest.skip("PyQt6-WebEngine not installed")
|
||||
import run_tab
|
||||
out = tmp_path / "sample.xlsx"
|
||||
run_tab.build_sample(out, {"Accounts": ["Account", "Password"]})
|
||||
header, rows, more = run_tab.read_sheet_rows(out, "Accounts")
|
||||
assert header == ["Account", "Password"] and rows == [["Example Account", "Example Password"]] and not more
|
||||
|
||||
|
||||
def test_palette_writes_theme(tmp_path):
|
||||
page = tmp_path / "run_tab.html"
|
||||
page.write_text((T / "run_tab.html").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
r = subprocess.run([sys.executable, str(T / "palette.py"), "#7a3cff", "--write", str(page)],
|
||||
capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
html = page.read_text(encoding="utf-8")
|
||||
assert "--accent-rgb:122,60,255" in html and html.count("THEME:START") == 1
|
||||
Reference in new issue
Block a user