No-AI desktop path: PyQt bot wired to the page
- templates/bot_runner.py (new, no Qt): runs the bot as a child process (unbuffered, UTF-8), tails its output, keeps raw lines in bot_raw.log, and turns lines into step/log/status events. Step markers and friendly rules use specific substrings, first match wins; unmatched error lines are always shown, other unmatched lines hidden. Dry run: python dashboard/bot_runner.py prints the events the page would get. - bridge.py: delegates to bot_runner, one run at a time, restores the saved dark mode on every page load (fixes sample-data mode in PyQt), adds load_latest_report / download_report slots the page calls. - SKILL.md: skip the stack question for a PyQt project; desktop wiring steps from the bot's real output; verify steps for the host. - tests: assert-based self-check for the line logic and run loop; Seam 1 run replay (status dot, Run button, steps, log) and wired-page mode; fixture b-pyqt-bot and its Seam 2 eval (all 14 checks pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb3fdf00e
commit
162edd41df
17 files changed
+753
-47
No files matched your search
@@ -11,7 +11,8 @@ source projects.
|
|||||||
| File | What it is |
|
| File | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
|
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
|
||||||
| `templates/bridge.py` | Minimal PyQt6 host. It runs the bot as a child process and tails its output. |
|
| `templates/bridge.py` | Minimal PyQt6 host: the window, the bridge slots, and the saved dark-mode choice. |
|
||||||
|
| `templates/bot_runner.py` | The bot wiring, without Qt. It runs the bot as a child process, tails its output, and turns lines into step and log events. |
|
||||||
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
|
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
|
||||||
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
|
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
|
||||||
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
|
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
|
||||||
@@ -50,6 +51,8 @@ Skip this if the user chose to keep the existing look. Offer:
|
|||||||
## 4. Ask desktop or web
|
## 4. Ask desktop or web
|
||||||
|
|
||||||
Ask only if there is no frontend. If there is one, use its stack.
|
Ask only if there is no frontend. If there is one, use its stack.
|
||||||
|
Don't ask if the project already uses PyQt (`PyQt6` or `PyQt5` in its requirements,
|
||||||
|
`pyproject.toml` or imports): choose desktop and say so.
|
||||||
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
|
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
|
||||||
through a bridge, so no web server is needed.
|
through a bridge, so no web server is needed.
|
||||||
- **Web:** the same page, served by a backend that it polls.
|
- **Web:** the same page, served by a backend that it polls.
|
||||||
@@ -84,9 +87,9 @@ sections must leave no markup, script or style behind, so delete their
|
|||||||
with the preset from `templates/themes.md`. For Ember, also add its font link.
|
with the preset from `templates/themes.md`. For Ember, also add its font link.
|
||||||
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
|
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
|
||||||
tokens it prints, and show the user its contrast table.
|
tokens it prints, and show the user its contrast table.
|
||||||
4. Desktop: also copy `templates/bridge.py` next to the page, and fill
|
4. Desktop: also copy `templates/bridge.py` and `templates/bot_runner.py` next to
|
||||||
`{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and
|
the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in
|
||||||
`{{APP_NAME}}`.
|
`bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`).
|
||||||
5. Search the output for `{{`. None may be left.
|
5. Search the output for `{{`. None may be left.
|
||||||
|
|
||||||
## 7. Wire the data
|
## 7. Wire the data
|
||||||
@@ -97,10 +100,22 @@ both directions.
|
|||||||
answers its own data actions, so it opens with sample batches and report rows,
|
answers its own data actions, so it opens with sample batches and report rows,
|
||||||
and Run plays a demo run. Tell the user which events their bot must send later.
|
and Run plays a demo run. Tell the user which events their bot must send later.
|
||||||
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
|
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
|
||||||
- Desktop: in `bridge.py`, set `BOT_CMD` to start the bot as a child process.
|
- Desktop: all the wiring is in `bot_runner.py`.
|
||||||
Fill `STEP_MARKERS` and `FRIENDLY_RULES` from lines the bot really prints. Use
|
1. Get the bot's real output: run it once (for example `python -m bot`), or read
|
||||||
specific substrings (for example `[step 3]`, not a bare word); the first match
|
its `print` and logging calls. Never invent lines it doesn't print.
|
||||||
wins. Unmatched error lines must always reach the log.
|
2. Set `BOT_CMD` to start the bot as a child process, and `BOT_CWD` to the folder
|
||||||
|
it runs from.
|
||||||
|
3. Set `STEPS_JSON` in the page to the bot's real stages. Fill `STEP_MARKERS` with
|
||||||
|
`(substring, step label, pct)`: one specific substring per stage (for example
|
||||||
|
`[step 3]`, never a bare word like `login`), each label exactly one of the
|
||||||
|
page's STEPS, and pct rising to 100 on the bot's last line. The first match wins.
|
||||||
|
4. Fill `FRIENDLY_RULES` with `(substring, plain-English message, type)` for the
|
||||||
|
lines an operator should see. Unmatched lines are hidden, except error lines
|
||||||
|
(any line with a word from `ERROR_WORDS`), which are always shown as they are.
|
||||||
|
Add the bot's own failure words to `ERROR_WORDS` if it uses others.
|
||||||
|
5. Dry run without the window: `python dashboard/bot_runner.py`. It prints each
|
||||||
|
event the page would get. Check that the steps arrive in order and end at
|
||||||
|
100, and that the bot's error lines appear as `error` log events.
|
||||||
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
|
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
|
||||||
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
|
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
|
||||||
- Apply the contract's derived rules: batch state from Pending plus date, and
|
- Apply the contract's derived rules: batch state from Pending plus date, and
|
||||||
@@ -108,7 +123,10 @@ both directions.
|
|||||||
|
|
||||||
## 8. Verify
|
## 8. Verify
|
||||||
|
|
||||||
Open the page. On desktop run the host; otherwise open the file or the served URL.
|
Open the page. On desktop run the host (`python dashboard/bridge.py`), click Run,
|
||||||
|
and check that the Run button stays disabled until the bot finishes, and that the
|
||||||
|
dark-mode choice is still set after you close and reopen the window. Otherwise
|
||||||
|
open the file or the served URL.
|
||||||
With sample data, opening the file in a browser is enough. Check:
|
With sample data, opening the file in a browser is enough. Check:
|
||||||
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
|
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
|
||||||
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
|
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Bot wiring for the desktop host: runs the bot and turns its output into page events.
|
||||||
|
|
||||||
|
No Qt here, so it can be tested and dry-run on its own. Fill BOT_CMD, STEP_MARKERS and
|
||||||
|
FRIENDLY_RULES from lines the bot really prints.
|
||||||
|
|
||||||
|
Dry run, no window: python bot_runner.py (prints each event the page would get)
|
||||||
|
"""
|
||||||
|
import json, os, subprocess, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # the bot, started as a child process
|
||||||
|
BOT_CWD = Path(__file__).resolve().parents[1] # folder BOT_CMD runs in (the project root)
|
||||||
|
RAW_LOG = Path(__file__).with_name("bot_raw.log") # every raw bot line, kept for audit
|
||||||
|
# (specific substring, step label from STEPS in dashboard.html, pct). First match wins.
|
||||||
|
# e.g. ("[step 2]", "Login", 40). Never a bare word like "login": it matches unrelated lines.
|
||||||
|
STEP_MARKERS = []
|
||||||
|
# (specific substring, plain-English message, type: info|success|warn|error). First match wins.
|
||||||
|
# e.g. ("[login] done", "Logged in.", "success"). Unmatched lines are hidden, except error lines.
|
||||||
|
FRIENDLY_RULES = []
|
||||||
|
# A line holding any of these is an error line. Unmatched error lines are shown raw, never hidden.
|
||||||
|
ERROR_WORDS = ("error", "exception", "traceback", "fatal", "failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(rules, line):
|
||||||
|
low = line.lower()
|
||||||
|
return next((r for r in rules if r[0].lower() in low), None)
|
||||||
|
|
||||||
|
|
||||||
|
def events_for(line):
|
||||||
|
"""The page events, as (event, data) pairs, for one raw bot line."""
|
||||||
|
out = []
|
||||||
|
if marker := _first(STEP_MARKERS, line):
|
||||||
|
out.append(("step", {"label": marker[1], "pct": marker[2]}))
|
||||||
|
if rule := _first(FRIENDLY_RULES, line):
|
||||||
|
out.append(("log", {"msg": rule[1], "type": rule[2]}))
|
||||||
|
elif any(w in line.lower() for w in ERROR_WORDS): # never hide a real failure
|
||||||
|
out.append(("log", {"msg": line, "type": "error"}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run(send):
|
||||||
|
"""Run the bot in its own process and send(event, data) for each line it prints, as it prints it."""
|
||||||
|
send("status", {"state": "running", "msg": "Running…"})
|
||||||
|
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} # live lines; no cp1252 crash
|
||||||
|
try:
|
||||||
|
raw = open(RAW_LOG, "a", encoding="utf-8")
|
||||||
|
p = subprocess.Popen(BOT_CMD, cwd=BOT_CWD, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, encoding="utf-8", errors="replace")
|
||||||
|
except OSError as e:
|
||||||
|
send("log", {"msg": f"Could not start the bot: {e}", "type": "error"})
|
||||||
|
send("status", {"state": "error", "msg": "Could not start the bot"})
|
||||||
|
return
|
||||||
|
with p, raw:
|
||||||
|
for line in p.stdout:
|
||||||
|
raw.write(line)
|
||||||
|
for event, data in events_for(line.rstrip()):
|
||||||
|
send(event, data)
|
||||||
|
ok = p.returncode == 0
|
||||||
|
send("status", {"state": "done", "msg": "Completed"} if ok else
|
||||||
|
{"state": "error", "msg": f"Failed (exit code {p.returncode})"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(lambda event, data: print(json.dumps({"event": event, "data": data}), flush=True))
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
||||||
|
|
||||||
The bot runs as a child process that prints lines, and this window tails them.
|
The bot runs as a child process and this window shows its progress. The bot wiring
|
||||||
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
|
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
|
||||||
|
Run it with: python dashboard/bridge.py
|
||||||
"""
|
"""
|
||||||
import json, subprocess, sys, threading
|
import json, sys, threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
||||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||||
from PyQt6.QtWebChannel import QWebChannel
|
from PyQt6.QtWebChannel import QWebChannel
|
||||||
|
|
||||||
|
import bot_runner
|
||||||
|
|
||||||
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
||||||
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
|
|
||||||
STEPS = ["{{STEP_1}}", "{{STEP_2}}"] # same list as STEPS in dashboard.html
|
|
||||||
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
|
|
||||||
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
|
|
||||||
|
|
||||||
|
|
||||||
def classify(line):
|
def settings():
|
||||||
s = line.lower()
|
return QSettings("{{ORG}}", "{{APP_NAME}}")
|
||||||
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
|
|
||||||
|
|
||||||
|
|
||||||
class Bridge(QObject):
|
class Bridge(QObject):
|
||||||
@@ -27,8 +25,10 @@ class Bridge(QObject):
|
|||||||
|
|
||||||
def __init__(self, view):
|
def __init__(self, view):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.view = view
|
self.view, self._one_run = view, threading.Lock()
|
||||||
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
|
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
|
||||||
|
# Restore the saved dark-mode choice on every page load, sample-data mode included.
|
||||||
|
view.loadFinished.connect(lambda _ok: self.send_to_js("apply_dark", {"on": settings().value("dark", False, bool)}))
|
||||||
|
|
||||||
def send_to_js(self, event, data):
|
def send_to_js(self, event, data):
|
||||||
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
||||||
@@ -36,38 +36,29 @@ class Bridge(QObject):
|
|||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def load_dashboard(self):
|
def load_dashboard(self):
|
||||||
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
||||||
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def load_latest_report(self):
|
||||||
|
self.send_to_js("report_data", {"rows": []}) # TODO: read the latest report rows
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def download_report(self):
|
||||||
|
pass # TODO: export the report rows to .xlsx
|
||||||
|
|
||||||
@pyqtSlot(bool)
|
@pyqtSlot(bool)
|
||||||
def set_dark(self, on):
|
def set_dark(self, on):
|
||||||
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
|
settings().setValue("dark", on)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def start_automation(self):
|
def start_automation(self):
|
||||||
threading.Thread(target=self._run, daemon=True).start()
|
if self._one_run.acquire(blocking=False): # a second click can't start a second bot
|
||||||
|
threading.Thread(target=self._run, daemon=True).start()
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
self.send_to_js("status", {"state": "running", "msg": "Running…"})
|
try:
|
||||||
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
bot_runner.run(self.send_to_js)
|
||||||
for raw in p.stdout:
|
finally:
|
||||||
self._on_line(raw.rstrip())
|
self._one_run.release()
|
||||||
ok = p.wait() == 0
|
|
||||||
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
|
|
||||||
|
|
||||||
def _on_line(self, line):
|
|
||||||
# ponytail: raw line only printed here; persist to file/DB if audit is needed
|
|
||||||
low, kind = line.lower(), classify(line)
|
|
||||||
for marker, idx, pct in STEP_MARKERS:
|
|
||||||
if marker in low:
|
|
||||||
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
|
|
||||||
break
|
|
||||||
for marker, msg, t in FRIENDLY_RULES:
|
|
||||||
if marker in low:
|
|
||||||
if msg:
|
|
||||||
self.send_to_js("log", {"msg": msg, "type": t})
|
|
||||||
return
|
|
||||||
if kind == "error": # never hide a real failure
|
|
||||||
self.send_to_js("log", {"msg": line, "type": "error"})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||||
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
|
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
|
||||||
|
`bot_runner.py` turns the bot's output lines into `status`, `step` and `log` events, and keeps every raw line in `bot_raw.log`.
|
||||||
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
|
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
|
||||||
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
|
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
|
||||||
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
|
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
|
||||||
|
|||||||
+8
-1
@@ -15,6 +15,11 @@ uv run --with pytest --with playwright pytest tests
|
|||||||
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
|
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
|
||||||
- The page sees a fake Qt bridge. Every action the page sends is recorded in
|
- The page sees a fake Qt bridge. Every action the page sends is recorded in
|
||||||
`window.__calls` as `[slot, ...args]`.
|
`window.__calls` as `[slot, ...args]`.
|
||||||
|
- `PAGE_CHECK_WIRED=1`: the page is wired to a bot (sample data off). Sample-data
|
||||||
|
cases skip, and a case checks that the page's actions go to the bot. With no
|
||||||
|
`PAGE_CHECK_HTML`, the template is checked with `SAMPLE_DATA` switched off.
|
||||||
|
- `PAGE_CHECK_REPLAY=run.jsonl`: replay a real bot run (the JSON lines that
|
||||||
|
`python dashboard/bot_runner.py` prints) instead of the built-in run.
|
||||||
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
|
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
|
||||||
and `push(event, data)` fixtures from `conftest.py`.
|
and `push(event, data)` fixtures from `conftest.py`.
|
||||||
- `evals/`: **Seam 2.** Runs the skills on a fixture project with `claude -p`,
|
- `evals/`: **Seam 2.** Runs the skills on a fixture project with `claude -p`,
|
||||||
@@ -25,5 +30,7 @@ uv run --with pytest --with playwright pytest tests
|
|||||||
```
|
```
|
||||||
|
|
||||||
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
|
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
|
||||||
`FIXTURES` in `run_eval.py`.
|
`FIXTURES` in `run_eval.py`. For a bot fixture (`b-pyqt-bot`), the eval also
|
||||||
|
dry-runs the generated `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.
|
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
|
||||||
|
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
|
||||||
|
backend (falls back to polling GET /dashboard and /run/status).
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Work-item follow-up bot</title>
|
||||||
|
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
|
||||||
|
body{
|
||||||
|
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
|
||||||
|
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
|
||||||
|
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
|
||||||
|
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
|
||||||
|
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
|
||||||
|
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
|
||||||
|
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
|
||||||
|
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
|
||||||
|
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
|
||||||
|
--progress-bg:var(--ok); --blur:blur(14px);
|
||||||
|
}
|
||||||
|
body.dark{
|
||||||
|
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
|
||||||
|
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
|
||||||
|
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
|
||||||
|
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
|
||||||
|
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||||
|
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||||
|
}
|
||||||
|
/* ===== BASE ===== */
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%;overflow:hidden}
|
||||||
|
body{font-family:var(--font);color:var(--ink);
|
||||||
|
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||||
|
.app{display:flex;height:100vh}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
/* sidebar */
|
||||||
|
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
|
||||||
|
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
|
||||||
|
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
|
||||||
|
.logo-box img{width:28px;height:28px;object-fit:contain}
|
||||||
|
.logo-title{font-size:15px;font-weight:700}
|
||||||
|
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
|
||||||
|
.nav{padding:16px 12px;flex:1}
|
||||||
|
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
|
||||||
|
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
|
||||||
|
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
|
||||||
|
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
|
||||||
|
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||||
|
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
|
||||||
|
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
|
||||||
|
/* main + topbar */
|
||||||
|
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
|
||||||
|
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
|
||||||
|
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||||
|
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
|
||||||
|
.dot{width:8px;height:8px;border-radius:50%}
|
||||||
|
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
|
||||||
|
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
|
||||||
|
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||||
|
@keyframes blink{50%{opacity:.4}}
|
||||||
|
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
|
||||||
|
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
|
||||||
|
/* cards, buttons, pills */
|
||||||
|
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
|
||||||
|
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
|
||||||
|
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
|
||||||
|
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
|
||||||
|
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
|
||||||
|
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
|
||||||
|
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
|
||||||
|
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
|
||||||
|
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
|
||||||
|
ul,ol{list-style:none}
|
||||||
|
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
|
||||||
|
/* KPI */
|
||||||
|
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
|
||||||
|
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
|
||||||
|
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
|
||||||
|
.kpi.total .kpi-ic{background:var(--accent)}
|
||||||
|
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
|
||||||
|
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
|
||||||
|
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
|
||||||
|
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
|
||||||
|
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
|
||||||
|
/* donut */
|
||||||
|
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||||
|
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
|
||||||
|
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
|
||||||
|
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||||
|
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
|
||||||
|
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
|
||||||
|
.dl-dot{width:10px;height:10px;border-radius:3px}
|
||||||
|
/* run panel */
|
||||||
|
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
|
||||||
|
.prog-head .pct{margin-left:auto;color:var(--accent)}
|
||||||
|
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
|
||||||
|
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
|
||||||
|
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
|
||||||
|
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
|
||||||
|
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
|
||||||
|
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
|
||||||
|
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
|
||||||
|
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
|
||||||
|
/* report table */
|
||||||
|
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||||
|
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
|
||||||
|
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
|
||||||
|
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
|
||||||
|
td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||||
|
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
|
||||||
|
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="logo-area">
|
||||||
|
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
|
||||||
|
<div><div class="logo-title">Work-item follow-up bot</div><div class="logo-sub">Portal work-item updates</div></div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
|
||||||
|
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
|
||||||
|
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
|
||||||
|
<div class="tb-right">
|
||||||
|
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
|
||||||
|
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
|
||||||
|
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div id="tab-dashboard">
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
|
||||||
|
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
|
||||||
|
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
|
||||||
|
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-grid">
|
||||||
|
<div class="card"><div class="c-title">Batch status split</div>
|
||||||
|
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RUN -->
|
||||||
|
<div id="tab-run" class="hidden">
|
||||||
|
<div class="card"><div class="c-title">Run automation</div>
|
||||||
|
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
|
||||||
|
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
|
||||||
|
<ol id="steps" aria-label="Steps"></ol>
|
||||||
|
<div class="log" id="log" role="log" aria-label="Run log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REPORT -->
|
||||||
|
<div id="tab-report" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
|
||||||
|
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== CONFIG (fill per project) =====
|
||||||
|
const STEPS = ["Initializing", "Login", "Process work items", "Write summary", "Completed"]; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||||
|
const REPORT_COLUMNS = [{"key": "item", "label": "Work item"}, {"key": "status", "label": "Status"}]; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
|
||||||
|
const API_BASE = ""; // web mode only, e.g. "/api"
|
||||||
|
const SAMPLE_DATA = false; // wired to bot.py through bot_runner.py
|
||||||
|
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
|
||||||
|
|
||||||
|
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
|
||||||
|
let bridge = null, reportRows = [];
|
||||||
|
function api(slot, ...args){
|
||||||
|
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
|
||||||
|
if(bridge) return bridge[slot](...args);
|
||||||
|
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
|
||||||
|
}
|
||||||
|
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
|
||||||
|
function startTransport(){
|
||||||
|
if(window.qt && window.qt.webChannelTransport){
|
||||||
|
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
|
||||||
|
}else if(SAMPLE_DATA){
|
||||||
|
api('load_dashboard');
|
||||||
|
}else{
|
||||||
|
const poll = async()=>{
|
||||||
|
try{
|
||||||
|
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
|
||||||
|
const s = await (await fetch(`${API_BASE}/run/status`)).json();
|
||||||
|
(s.events||[]).forEach(e=>handle(e.event,e.data));
|
||||||
|
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
|
||||||
|
}catch(e){ setTimeout(poll, 10000); }
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== EVENT DISPATCH (see contract.md) =====
|
||||||
|
function handle(event, d){
|
||||||
|
switch(event){
|
||||||
|
case 'status': setStatus(d.state, d.msg); break;
|
||||||
|
case 'step': setStep(d.label, d.pct); break;
|
||||||
|
case 'log': addLog(d.msg, d.type); break;
|
||||||
|
case 'dashboard_data': renderDashboard(d.batches||[]); break;
|
||||||
|
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
|
||||||
|
case 'set_user': setUser(d); break;
|
||||||
|
case 'apply_dark': setDark(!!d.on); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UI =====
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
|
||||||
|
function showTab(t){
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
|
||||||
|
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
|
||||||
|
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
|
||||||
|
if(t==='dashboard') api('load_dashboard');
|
||||||
|
if(t==='report') api('load_latest_report');
|
||||||
|
}
|
||||||
|
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
|
||||||
|
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
|
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
|
||||||
|
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
|
||||||
|
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
|
||||||
|
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
|
||||||
|
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
|
||||||
|
|
||||||
|
function renderSteps(active=-1){
|
||||||
|
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
|
||||||
|
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
|
||||||
|
}
|
||||||
|
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
|
||||||
|
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
|
||||||
|
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
|
||||||
|
|
||||||
|
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
|
||||||
|
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
|
||||||
|
// batch state -> [label, colour token, pill class]
|
||||||
|
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
|
||||||
|
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
|
||||||
|
function renderDashboard(b){
|
||||||
|
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
|
||||||
|
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
|
||||||
|
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
|
||||||
|
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
|
||||||
|
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
|
||||||
|
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
|
||||||
|
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
|
||||||
|
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
|
||||||
|
$('recent').className = b.length?'':'empty';
|
||||||
|
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
|
||||||
|
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
|
||||||
|
}
|
||||||
|
function renderReport(){
|
||||||
|
const q=$('r-search').value.toLowerCase();
|
||||||
|
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
|
||||||
|
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
|
||||||
|
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
|
||||||
|
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
|
||||||
|
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
|
||||||
|
}
|
||||||
|
renderSteps();
|
||||||
|
startTransport();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"fixture": "b-pyqt-bot",
|
||||||
|
"passed": true,
|
||||||
|
"checks": {
|
||||||
|
"frontend reported correctly": true,
|
||||||
|
"asks /colou?r/": true,
|
||||||
|
"asks /section/": true,
|
||||||
|
"never asks /\\bkeep\\b|restyle/": true,
|
||||||
|
"never asks /desktop|web/": true,
|
||||||
|
"exactly one page built": true,
|
||||||
|
"no placeholders left": true,
|
||||||
|
"page wired to the bot (sample data off)": true,
|
||||||
|
"bot_runner.py built": true,
|
||||||
|
"no placeholders left in the host": true,
|
||||||
|
"bot dry run ends done": true,
|
||||||
|
"real steps reach the page": true,
|
||||||
|
"bot error line reaches the log": true,
|
||||||
|
"page check passes": true
|
||||||
|
},
|
||||||
|
"page_check": [
|
||||||
|
"27 passed, 4 skipped in 13.24s"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"bot.py",
|
||||||
|
"dashboard\\bot_raw.log",
|
||||||
|
"dashboard\\bot_runner.py",
|
||||||
|
"dashboard\\bridge.py",
|
||||||
|
"dashboard\\dashboard.html",
|
||||||
|
"requirements.txt"
|
||||||
|
],
|
||||||
|
"reply": "Dashboard ready aachu, PyQt bot-oda wire pannirukken. Ithu desktop app (PyQt6), local project mattum; cloud/deploy edhuvum illa. Neenga work pannumbothu answer panna mudiyaathu-nu sonneenga, adhaan 3-file change-ku \"go\" wait pannala.\n\n| Item | Result |\n|---|---|\n| Files | `dashboard/dashboard.html`, `dashboard/bridge.py`, `dashboard/bot_runner.py` |\n| Theme / extras | Tebra Sky / onnum illa (optional markers ellam remove pannitten) |\n| Data | Sample data illa. `bot_runner.py` `python -m bot`-a child process-aa run pannum |\n| Steps | Initializing \u2192 Login \u2192 Process work items \u2192 Write summary \u2192 Completed (bot-oda `[step 1-4]`, `[all done]` lines-la irundhu) |\n| Dry run | 5/5 steps order-la vandhuchu, 100%-la end aachu; `ERROR: could not attach\u2026` line `error` log-aa vandhuchu |\n| Qt host (offscreen) | Run click pannadhum button disable aachu, bot mudinjadhum enable; status \"Completed\", 100%; dark mode reopen pannappuram-um on-la irundhuchu |\n| Screenshots | Light, dark, 800px width: KPI, donut, steps, log, report pills ellam correct-aa render aachu; horizontal scroll illa; `{{` onnum meedhi illa |\n\nRun panna: `python dashboard/bridge.py`\n\nAlso noticed (fix pannala):\n- `load_dashboard` / `load_latest_report` empty list-dhaan anuppudhu, bot DB-yo report-o ezhudhala. Adhanaala live data-vula KPI-yum Report tab-um empty-aa irukkum. Test pannumbothu sample rows inject panni dhaan check panninen.\n- `bot.py` `ERROR` print pannaalum exit code 0-dhaan, so status \"Completed\"-nu dhaan kaattum.\n- \"Work item updated.\" log line-la WI number varaadhu; raw line `dashboard/bot_raw.log`-la save aagum.\n- Browser-la file-aa direct-aa open panna fetch errors varum, aana Qt-kulla avai varaadhu.\n\nFRONTEND: none\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to build: checkpoint tracker, admin/roles screen, login page, or none?"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{"event": "status", "data": {"state": "running", "msg": "Running\u2026"}}
|
||||||
|
{"event": "step", "data": {"label": "Initializing", "pct": 10}}
|
||||||
|
{"event": "log", "data": {"msg": "Starting up.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work items loaded.", "type": "info"}}
|
||||||
|
{"event": "step", "data": {"label": "Login", "pct": 30}}
|
||||||
|
{"event": "log", "data": {"msg": "Logging in to the portal.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Logged in.", "type": "success"}}
|
||||||
|
{"event": "step", "data": {"label": "Process work items", "pct": 50}}
|
||||||
|
{"event": "log", "data": {"msg": "Processing work items.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "The portal is slow to respond.", "type": "warn"}}
|
||||||
|
{"event": "log", "data": {"msg": "ERROR: could not attach the note to WI-1003", "type": "error"}}
|
||||||
|
{"event": "step", "data": {"label": "Write summary", "pct": 85}}
|
||||||
|
{"event": "log", "data": {"msg": "Writing the summary.", "type": "info"}}
|
||||||
|
{"event": "step", "data": {"label": "Completed", "pct": 100}}
|
||||||
|
{"event": "log", "data": {"msg": "All work items processed.", "type": "success"}}
|
||||||
|
{"event": "status", "data": {"state": "done", "msg": "Completed"}}
|
||||||
+34
-1
@@ -36,9 +36,39 @@ FIXTURES = {
|
|||||||
"never_asks": [r"\bkeep\b|restyle"],
|
"never_asks": [r"\bkeep\b|restyle"],
|
||||||
"brand": "#7a3cff",
|
"brand": "#7a3cff",
|
||||||
},
|
},
|
||||||
|
"b-pyqt-bot": {
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Colours: Tebra Sky. "
|
||||||
|
"Optional sections: none.",
|
||||||
|
"frontend": r"^\s*(none|no\b)",
|
||||||
|
"asks": [r"colou?r", r"section"],
|
||||||
|
"never_asks": [r"\bkeep\b|restyle", r"desktop|web"],
|
||||||
|
"bot": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bot_checks(project, html, checks, replay):
|
||||||
|
"""Bot fixtures: the page is wired, and a dry run of the generated wiring gives real steps and logs.
|
||||||
|
Returns the env that points the page check at that run."""
|
||||||
|
checks["page wired to the bot (sample data off)"] = bool(re.search(r"SAMPLE_DATA\s*=\s*false", html))
|
||||||
|
runners = [p for p in project.rglob("bot_runner.py") if ".claude" not in p.parts]
|
||||||
|
checks["bot_runner.py built"] = len(runners) == 1
|
||||||
|
if not runners:
|
||||||
|
return {}
|
||||||
|
host = [runners[0], runners[0].with_name("bridge.py")]
|
||||||
|
checks["no placeholders left in the host"] = all(p.exists() and "{{" not in p.read_text(encoding="utf-8") for p in host)
|
||||||
|
dry = subprocess.run([sys.executable, str(runners[0])], cwd=project, capture_output=True, text=True,
|
||||||
|
encoding="utf-8", timeout=300)
|
||||||
|
replay.write_text(dry.stdout, encoding="utf-8")
|
||||||
|
events = [json.loads(x) for x in dry.stdout.splitlines() if x.startswith("{")]
|
||||||
|
steps = [e["data"] for e in events if e["event"] == "step"]
|
||||||
|
checks["bot dry run ends done"] = bool(events) and events[-1]["data"].get("state") == "done"
|
||||||
|
checks["real steps reach the page"] = len({s["label"] for s in steps}) >= 3 and steps[-1]["pct"] == 100
|
||||||
|
checks["bot error line reaches the log"] = any(e["event"] == "log" and e["data"]["type"] == "error" for e in events)
|
||||||
|
return {"PAGE_CHECK_WIRED": "1", "PAGE_CHECK_REPLAY": str(replay)}
|
||||||
|
|
||||||
|
|
||||||
def main(fixture):
|
def main(fixture):
|
||||||
cfg = FIXTURES[fixture]
|
cfg = FIXTURES[fixture]
|
||||||
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
|
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
|
||||||
@@ -80,8 +110,11 @@ def main(fixture):
|
|||||||
spec.loader.exec_module(palette)
|
spec.loader.exec_module(palette)
|
||||||
t = palette.derive(cfg["brand"])
|
t = palette.derive(cfg["brand"])
|
||||||
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t)
|
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t)
|
||||||
|
env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])}
|
||||||
|
if cfg.get("bot"):
|
||||||
|
env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl"))
|
||||||
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
|
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
|
||||||
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
|
env=env, capture_output=True, text=True)
|
||||||
checks["page check passes"] = pc.returncode == 0
|
checks["page check passes"] = pc.returncode == 0
|
||||||
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
|
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
|
||||||
else:
|
else:
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Work-item follow-up bot
|
||||||
|
|
||||||
|
A desktop RPA bot. It logs in to the portal and updates each work item.
|
||||||
|
The operator window will be a PyQt6 app.
|
||||||
|
|
||||||
|
Run the bot on its own:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m bot
|
||||||
|
```
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""Work-item follow-up bot. Start it with: python -m bot"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
ITEMS = ["WI-1001", "WI-1002", "WI-1003"]
|
||||||
|
|
||||||
|
|
||||||
|
def say(line):
|
||||||
|
print(line)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
say("[step 1] Initializing")
|
||||||
|
say("Loading settings from config.ini")
|
||||||
|
say("Completed loading 3 work items")
|
||||||
|
say("[step 2] Logging in to the portal")
|
||||||
|
say("Login page loaded")
|
||||||
|
say("[login] ok")
|
||||||
|
say("[step 3] Processing work items")
|
||||||
|
for i, item in enumerate(ITEMS, 1):
|
||||||
|
say(f"[item] {item} updated ({i}/{len(ITEMS)})")
|
||||||
|
say("WARNING: portal slow to respond, waited 5s")
|
||||||
|
say("ERROR: could not attach the note to WI-1003")
|
||||||
|
say("[step 4] Writing the summary")
|
||||||
|
say(f"[all done] {len(ITEMS)} items processed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
PyQt6
|
||||||
|
PyQt6-WebEngine
|
||||||
@@ -4,6 +4,8 @@ through window.__pyMsg, and assert on what is visible.
|
|||||||
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
|
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
|
||||||
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
|
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
|
||||||
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_REPLAY: a JSON-lines file of envelopes a real bot run produced (bot_runner.py's dry run).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -32,6 +34,14 @@ window.QWebChannel = function (_transport, ready) {
|
|||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
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")
|
||||||
@@ -39,6 +49,8 @@ def page_url(tmp_path_factory):
|
|||||||
if os.environ.get("PAGE_CHECK_HTML"):
|
if os.environ.get("PAGE_CHECK_HTML"):
|
||||||
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
|
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
|
||||||
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
|
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
|
||||||
|
if WIRED:
|
||||||
|
html = html.replace("const SAMPLE_DATA = true", "const SAMPLE_DATA = false")
|
||||||
out = tmp_path_factory.mktemp("page") / "dashboard.html"
|
out = tmp_path_factory.mktemp("page") / "dashboard.html"
|
||||||
out.write_text(html, encoding="utf-8")
|
out.write_text(html, encoding="utf-8")
|
||||||
return out.as_uri()
|
return out.as_uri()
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""A bot run as the page sees it: status, step and log envelopes in order, from a real bot
|
||||||
|
run (PAGE_CHECK_REPLAY, the JSON lines bot_runner.py's dry run prints) or a built-in one."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import WIRED
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
from test_contract import colour_of, kpi, open_tab
|
||||||
|
|
||||||
|
LOG_COLOURS = {"info": "blue", "success": "green", "warn": "amber", "error": "red"}
|
||||||
|
DOT_COLOURS = {"ready": "green", "running": "amber", "done": "green", "error": "red"}
|
||||||
|
|
||||||
|
|
||||||
|
def built_in_run(steps):
|
||||||
|
run = [{"event": "status", "data": {"state": "running", "msg": "Running…"}}]
|
||||||
|
for i, label in enumerate(steps):
|
||||||
|
run.append({"event": "step", "data": {"label": label, "pct": round((i + 1) / len(steps) * 100)}})
|
||||||
|
run.append({"event": "log", "data": {"msg": f"Finished {label}.", "type": "success"}})
|
||||||
|
run.insert(4, {"event": "log", "data": {"msg": "ERROR: portal timed out, retrying", "type": "error"}})
|
||||||
|
return run + [{"event": "status", "data": {"state": "done", "msg": "Completed"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_run():
|
||||||
|
path = os.environ.get("PAGE_CHECK_REPLAY")
|
||||||
|
return [json.loads(x) for x in Path(path).read_text(encoding="utf-8-sig").splitlines() if x.strip()] if path else None
|
||||||
|
|
||||||
|
|
||||||
|
def expect_status(page, state, msg):
|
||||||
|
# The status indicator has no role of its own, so it's found by its documented ids.
|
||||||
|
expect(page.locator("#st-text")).to_have_text(msg or state)
|
||||||
|
assert colour_of(page.locator("#st-dot"), "backgroundColor") == DOT_COLOURS[state], state
|
||||||
|
run = page.get_by_role("button", name="▶ Run", exact=True)
|
||||||
|
expect(run).to_be_disabled() if state == "running" else expect(run).to_be_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_run_shows_status_steps_and_log(page, push):
|
||||||
|
steps = page.evaluate("STEPS")
|
||||||
|
run = recorded_run() or built_in_run(steps)
|
||||||
|
open_tab(page, "Run")
|
||||||
|
expect_status(page, "ready", "System ready")
|
||||||
|
items = page.get_by_role("list", name="Steps").get_by_role("listitem")
|
||||||
|
log, last_step = page.get_by_role("log"), -1
|
||||||
|
for envelope in run:
|
||||||
|
event, d = envelope["event"], envelope["data"]
|
||||||
|
push(event, d)
|
||||||
|
if event == "status":
|
||||||
|
expect_status(page, d["state"], d.get("msg"))
|
||||||
|
elif event == "step":
|
||||||
|
assert d["label"] in steps, f"step label {d['label']!r} is not one of the page's STEPS {steps}"
|
||||||
|
idx = steps.index(d["label"])
|
||||||
|
assert idx >= last_step, f"progress went back from {steps[last_step]!r} to {d['label']!r}"
|
||||||
|
last_step = idx
|
||||||
|
expect(page.get_by_text(f"{d['pct']}%", exact=True)).to_be_visible()
|
||||||
|
if d["pct"] >= 100:
|
||||||
|
for i in range(len(steps)):
|
||||||
|
expect(items.nth(i)).to_have_text(re.compile(r"^\s*✓"))
|
||||||
|
else:
|
||||||
|
expect(items.nth(idx)).to_have_attribute("aria-current", "step")
|
||||||
|
elif event == "log":
|
||||||
|
line = log.get_by_text(d["msg"], exact=True).last
|
||||||
|
expect(line).to_be_visible()
|
||||||
|
assert colour_of(line) == LOG_COLOURS[d["type"]], d["msg"]
|
||||||
|
assert any(e["event"] == "step" for e in run), "the run never moved the progress bar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_run_shows_error_status(page, push):
|
||||||
|
push("status", {"state": "running", "msg": "Running…"})
|
||||||
|
expect_status(page, "running", "Running…")
|
||||||
|
push("status", {"state": "error", "msg": "Failed (exit code 1)"})
|
||||||
|
expect_status(page, "error", "Failed (exit code 1)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not WIRED, reason="only for a page wired to a bot (PAGE_CHECK_WIRED=1)")
|
||||||
|
def test_wired_page_sends_its_actions_to_the_bot(page):
|
||||||
|
calls = lambda: page.evaluate("window.__calls") # noqa: E731
|
||||||
|
assert ["load_dashboard"] in calls()
|
||||||
|
kpi(page, "Total batches", 0) # no sample batches on a wired page
|
||||||
|
page.get_by_role("button", name="▶ Run", exact=True).click()
|
||||||
|
assert ["start_automation"] in calls()
|
||||||
|
open_tab(page, "Report")
|
||||||
|
assert ["load_latest_report"] in calls()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +147,7 @@ def expect_sample_dashboard(page):
|
|||||||
kpi(page, "Total batches", n)
|
kpi(page, "Total batches", n)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("sample_data")
|
||||||
def test_no_bot_page_runs_on_sample_data(page):
|
def test_no_bot_page_runs_on_sample_data(page):
|
||||||
"""No bot yet: the page answers its own data actions, so it works on day one."""
|
"""No bot yet: the page answers its own data actions, so it works on day one."""
|
||||||
expect_sample_dashboard(page)
|
expect_sample_dashboard(page)
|
||||||
@@ -162,6 +164,7 @@ def test_no_bot_page_runs_on_sample_data(page):
|
|||||||
expect(run).to_be_enabled()
|
expect(run).to_be_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("sample_data")
|
||||||
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
|
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
|
||||||
pg = browser.new_page()
|
pg = browser.new_page()
|
||||||
pg.goto(page_url)
|
pg.goto(page_url)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Bot wiring self-check (templates/bot_runner.py): which page events each raw bot line makes."""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
RUNNER = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "bot_runner.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner():
|
||||||
|
spec = importlib.util.spec_from_file_location("bot_runner", RUNNER)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.STEP_MARKERS = [("[step 1]", "Initializing", 10), ("[step 2]", "Login", 40),
|
||||||
|
("[step 3]", "Process items", 70), ("[all done]", "Completed", 100)]
|
||||||
|
mod.FRIENDLY_RULES = [("[login] done", "Logged in.", "success"), ("[login]", "Logging in...", "info"),
|
||||||
|
("could not save receipt", "Could not save the receipt.", "error")]
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def steps(runner, line):
|
||||||
|
return [d for e, d in runner.events_for(line) if e == "step"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_marker_moves_progress(runner):
|
||||||
|
assert steps(runner, "[step 2] Opening the login page") == [{"label": "Login", "pct": 40}]
|
||||||
|
assert steps(runner, "[STEP 3] Processing 12 items") == [{"label": "Process items", "pct": 70}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_word_does_not_move_progress(runner):
|
||||||
|
assert steps(runner, "Login page loaded slowly") == []
|
||||||
|
assert steps(runner, "Completed loading the config") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_step_marker_wins(runner):
|
||||||
|
assert steps(runner, "[step 1] retry after [step 3] timed out") == [{"label": "Initializing", "pct": 10}]
|
||||||
|
|
||||||
|
|
||||||
|
def logs(runner, line):
|
||||||
|
return [d for e, d in runner.events_for(line) if e == "log"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_friendly_rule_rewrites_the_line_and_first_rule_wins(runner):
|
||||||
|
assert logs(runner, "[login] done in 2.1s") == [{"msg": "Logged in.", "type": "success"}]
|
||||||
|
assert logs(runner, "[LOGIN] sending credentials") == [{"msg": "Logging in...", "type": "info"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmatched_non_error_lines_are_hidden(runner):
|
||||||
|
for line in ["Loading config from settings.ini", "[step 2] Opening the login page", "warning: slow page"]:
|
||||||
|
assert logs(runner, line) == [], line
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("line", [
|
||||||
|
"ERROR: portal timed out", "Traceback (most recent call last):", "ValueError: bad date",
|
||||||
|
"Exception: boom", "FATAL disk full", "Login failed for user 3"])
|
||||||
|
def test_unmatched_error_line_is_always_shown_raw(runner, line):
|
||||||
|
assert logs(runner, line) == [{"msg": line, "type": "error"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matched_error_line_shows_its_friendly_message(runner):
|
||||||
|
assert logs(runner, "ERROR could not save receipt 42") == [{"msg": "Could not save the receipt.", "type": "error"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_on_a_step_line_moves_progress_and_is_shown(runner):
|
||||||
|
line = "[step 3] ERROR item 2 rejected"
|
||||||
|
assert runner.events_for(line) == [("step", {"label": "Process items", "pct": 70}),
|
||||||
|
("log", {"msg": line, "type": "error"})]
|
||||||
|
|
||||||
|
|
||||||
|
def run_bot(runner, tmp_path, script):
|
||||||
|
"""Run a fake bot through runner.run(); return [(seconds since start, event, data)]."""
|
||||||
|
bot = tmp_path / "fake_bot.py"
|
||||||
|
bot.write_text(script, encoding="utf-8")
|
||||||
|
runner.BOT_CMD, runner.BOT_CWD, runner.RAW_LOG = [sys.executable, str(bot)], tmp_path, tmp_path / "raw.log"
|
||||||
|
t0, sent = time.monotonic(), []
|
||||||
|
runner.run(lambda event, data: sent.append((time.monotonic() - t0, event, data)))
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_streams_bot_output_live_from_a_child_process(runner, tmp_path):
|
||||||
|
sent = run_bot(runner, tmp_path, "import os, time\n"
|
||||||
|
"print('[step 1] pid', os.getpid())\n"
|
||||||
|
"print('[login] done')\n"
|
||||||
|
"time.sleep(1)\n"
|
||||||
|
"print('[all done] ✓ 3 items')\n")
|
||||||
|
events = [(e, d) for _, e, d in sent]
|
||||||
|
assert events == [("status", {"state": "running", "msg": "Running…"}),
|
||||||
|
("step", {"label": "Initializing", "pct": 10}),
|
||||||
|
("log", {"msg": "Logged in.", "type": "success"}),
|
||||||
|
("step", {"label": "Completed", "pct": 100}),
|
||||||
|
("status", {"state": "done", "msg": "Completed"})]
|
||||||
|
assert sent[3][0] - sent[1][0] > 0.5, "lines must reach the page while the bot runs, not at exit"
|
||||||
|
raw = (tmp_path / "raw.log").read_text(encoding="utf-8")
|
||||||
|
assert "[login] done" in raw and "✓ 3 items" in raw # raw lines kept for audit
|
||||||
|
assert f"pid {os.getpid()}" not in raw # the bot ran in its own process
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ends_in_error_when_the_bot_fails(runner, tmp_path):
|
||||||
|
sent = run_bot(runner, tmp_path, "print('[step 1] start')\nraise SystemExit(3)\n")
|
||||||
|
assert sent[-1][1:] == ("status", {"state": "error", "msg": "Failed (exit code 3)"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ends_in_error_when_the_bot_cannot_start(runner, tmp_path):
|
||||||
|
runner.BOT_CMD, runner.BOT_CWD, runner.RAW_LOG = ["no-such-bot-xyz"], tmp_path, tmp_path / "raw.log"
|
||||||
|
sent = []
|
||||||
|
runner.run(lambda event, data: sent.append((event, data)))
|
||||||
|
assert sent[-2][0] == "log" and sent[-2][1]["type"] == "error"
|
||||||
|
assert sent[-1] == ("status", {"state": "error", "msg": "Could not start the bot"})
|
||||||
@@ -9,7 +9,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
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"])
|
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py",
|
||||||
|
"rpa-dashboard-no-ai/templates/bot_runner.py"])
|
||||||
def test_template_passes_lint_hook_unfilled(template):
|
def test_template_passes_lint_hook_unfilled(template):
|
||||||
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
||||||
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
||||||
|
|||||||
Reference in new issue
Block a user