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
@@ -3,3 +3,4 @@ __pycache__/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.venv/
|
.venv/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
rpa-run-tab/logs/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# RPA SKILLS
|
# RPA SKILLS
|
||||||
|
|
||||||
Shared Claude skills for building RPA dashboards: `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. See `PLAN.md`.
|
Shared Claude skills for building RPA dashboards: `rpa-dashboard-no-ai`, `rpa-dashboard-ai`, and `rpa-run-tab` (Tebra-style Run tab). See `PLAN.md`.
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -1,5 +1,38 @@
|
|||||||
# Glossary
|
# Glossary
|
||||||
|
|
||||||
**Run tab**: the full screen an operator uses to start and watch one bot run, modelled on Tebra Auto Post. It has the Trigger button, the input card and the run card. Built by its own skill.
|
**Run tab**: the full screen an operator uses to start and watch one bot run, modelled on Tebra Auto Post. It has the Trigger button, the input card and the run card. Built by the `rpa-run-tab` skill.
|
||||||
|
|
||||||
**Run panel**: the smaller run section inside `rpa-dashboard-no-ai`: progress bar, steps and live log. It is a subset of the Run tab. Don't use the two names for the same thing.
|
**Run panel**: the smaller run section inside `rpa-dashboard-no-ai`: progress bar, steps and live log. It is a subset of the Run tab. Don't use the two names for the same thing.
|
||||||
|
|
||||||
|
**Trigger button**: the single start button in the top bar. It has three states:
|
||||||
|
- off: "Upload a file to continue"
|
||||||
|
- on: "Trigger automation"
|
||||||
|
- going: "Running…"
|
||||||
|
There is no Stop or Pause.
|
||||||
|
|
||||||
|
**Input card**: the Excel upload part of the Run tab:
|
||||||
|
- sample download
|
||||||
|
- drop zone
|
||||||
|
- file row
|
||||||
|
- sheet tabs
|
||||||
|
- preview of up to 200 rows, with password columns masked
|
||||||
|
- Clear file
|
||||||
|
|
||||||
|
It is hidden for bots that take no input file.
|
||||||
|
|
||||||
|
**Run card**: the progress part of the Run tab:
|
||||||
|
- progress bar with elapsed timer
|
||||||
|
- step list
|
||||||
|
- on-screen log
|
||||||
|
- checkpoint boxes
|
||||||
|
|
||||||
|
**Checkpoint**: one named stage of a bot run, with a time limit in seconds (0 = no limit). The bot announces it with `[CPSTATE] active N` and `[CPSTATE] done N`. A project's checkpoint list drives the step list, the checkpoint boxes and the progress % on the Run tab. _Avoid_: "step" for a separate concept. In the Run tab, the steps are the checkpoints.
|
||||||
|
|
||||||
|
**Stuck**: a checkpoint that has been active for longer than its time limit. A stuck run is killed and ends as failed (ADR-0003).
|
||||||
|
|
||||||
|
**Outcome**: how a run ended, read from the bot's `__RUN_SUMMARY__` line:
|
||||||
|
- **completed** (green)
|
||||||
|
- **partial**: some items failed (amber)
|
||||||
|
- **failed**: crash, no summary, or stuck (red)
|
||||||
|
|
||||||
|
**Fake bot**: a bundled script that prints the Run tab protocol lines, used to verify the page. The skill never runs the real bot.
|
||||||
@@ -113,3 +113,54 @@ rpa-dashboard-ai/
|
|||||||
- asks the questions in order,
|
- asks the questions in order,
|
||||||
- reports any existing frontend,
|
- reports any existing frontend,
|
||||||
- produces a working dashboard page that renders in light and dark.
|
- produces a working dashboard page that renders in light and dark.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan: `rpa-run-tab` skill (third skill)
|
||||||
|
|
||||||
|
Goal: a skill that adds Tebra Auto Post's **Run tab** to any desktop RPA project in **5 minutes**. The look is the same as Tebra; only the colours are the user's choice.
|
||||||
|
- Source: `D:\Tebra_Auto_Post` `src/ui/main_window.py`. The Run tab code is at lines 1871–3979 (page) and 268–1142 (bridge).
|
||||||
|
- Also `run_app.py` (the `--run-automation` child process) and `core.py` (`wd_mark`).
|
||||||
|
- Take the tokenised accent from `D:\Tebra_Auto_Post_TVL`. Bundle a scrubbed template; never read D:\ at runtime.
|
||||||
|
- Terms are in `GLOSSARY.md`; decisions are in `docs/adr/0001`–`0003`.
|
||||||
|
|
||||||
|
## Decisions (from the grill)
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | Skill shape | A new standalone skill, `D:\RPA SKILLS\rpa-run-tab\`. Later, `rpa-dashboard-no-ai` points to it instead of keeping its own Run panel. |
|
||||||
|
| R2 | DB parts (batch chip, resume, clear-approval, roles) | **Left out.** No DB needed. Clear file just clears the file. |
|
||||||
|
| R3 | Stack | **Desktop only**: PyQt6 + QWebEngine. Runs from Python or a PyInstaller exe. |
|
||||||
|
| R4 | Input | Excel input card by default, with sheet tabs, a 200-row preview and password columns masked. **Hidden** for bots that take no file; the Trigger button is then ready straight away. |
|
||||||
|
| R5 | Steps vs checkpoints | **One checkpoint list** (name + time limit) drives the steps, the boxes and the progress %. See ADR-0001. |
|
||||||
|
| R6 | Stuck | Kill the process tree and end as failed "Stuck at X". No auto-restart. See ADR-0003. |
|
||||||
|
| R7 | On-screen log | The bot tags lines `[UI]`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`. Errors and tracebacks always show. All raw lines go to the run log file. See ADR-0002. |
|
||||||
|
| R8 | Host | **Detect.** Add a tab to an existing PyQt web-view page or dashboard. Otherwise build a new window: Tebra top bar + Run tab, no sidebar. |
|
||||||
|
| R9 | Colours | Presets: 1) Tebra Sky, 2) TVL Teal, 3) Custom hex. A custom hex is turned into gradient and accent colours for light and dark, with a contrast check. ok/warn/bad and the log colours stay fixed. |
|
||||||
|
| R10 | Scope of the 5 minutes | Page **plus the real bot wired**. The skill reads the bot and proposes a checkpoint list for the user to confirm in one reply. It then adds the `[CPSTATE]`, `[UI]` and `__RUN_SUMMARY__` prints. |
|
||||||
|
| R11 | Endings | Green "Completed in …". Amber "Finished: N of M failed", with the failed items in the log. Red "Failed after …" or "Stuck at X". |
|
||||||
|
| R12 | Stop | **No Stop or Pause**, same as Tebra. Closing the window asks first, then kills the process tree. |
|
||||||
|
| R13 | Sample input | A headers-only sample built from the sheets and columns the bot reads, plus one fake row. The button is hidden if the skill can't tell. Never real data. |
|
||||||
|
| R14 | Verify | **Fake bot only.** It covers completed, partial and stuck runs in light and dark. The skill never presses Trigger on the real bot. |
|
||||||
|
|
||||||
|
## Kept from Tebra as is
|
||||||
|
- Glass cards and the sky gradient. The Segoe UI font. The Consolas log with no timestamps.
|
||||||
|
- The dark toggle, saved to QSettings.
|
||||||
|
- The status dot: ready / running / done / error.
|
||||||
|
- Elapsed timers on the progress bar and on each checkpoint box.
|
||||||
|
- Progress only ever moves forward. Consecutive duplicate log lines are dropped.
|
||||||
|
- A double-run guard. The parent-death watcher in the child process.
|
||||||
|
- Python → page messages go through `window.__pyMsg` only, never also through `js_message.connect`, which delivers every message twice.
|
||||||
|
|
||||||
|
## Tebra bugs not to copy
|
||||||
|
- A partial failure shows "Completed" at 100%. Fixed by R11.
|
||||||
|
- `STOP_REQUESTED` set only in the parent process. There is no Stop, and reload no longer fakes "Stopped by user".
|
||||||
|
- The checkpoint time limit in JS (300) and in Python (600) disagree. One list (R5) fixes this.
|
||||||
|
- Dead CSS and JS: `.top-ctrl-btn`, `.run-btn`, `showRerunModal`, `#rerun-mask`.
|
||||||
|
|
||||||
|
## Runtime questions (keep to ≤3 for the 5-minute budget)
|
||||||
|
1. Colour: preset or custom hex.
|
||||||
|
2. Confirm the proposed checkpoint list, with names and time limits.
|
||||||
|
3. Excel input or no input. Skip this when the bot reading clearly answers it.
|
||||||
|
|
||||||
|
The title and subtitle come from the project name. The user can edit them later.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# One checkpoint list drives the whole Run tab
|
||||||
|
|
||||||
|
Tebra Auto Post keeps two overlapping lists:
|
||||||
|
- 8 **steps**, which move forward when known phrases appear in the log (`STEP_MARKERS`).
|
||||||
|
- 7 **checkpoints**, which move forward on `[CPSTATE]` lines and have time limits.
|
||||||
|
|
||||||
|
The Run tab skill keeps one checkpoint list. The bot's `[CPSTATE] active|done N` lines move the step list, the checkpoint boxes and the progress % together. The screen still looks like Tebra.
|
||||||
|
|
||||||
|
**Why:** there is one list to ask the user for, so the skill fits in 5 minutes. And a typo in a log phrase can no longer stall the progress bar without anyone noticing.
|
||||||
|
|
||||||
|
**Cost:** the progress bar moves in whole checkpoints, not Tebra's finer per-phrase percentages.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# The bot talks to the Run tab through tagged lines in a log file
|
||||||
|
|
||||||
|
The bot runs as a **separate process** with no Qt. The Run tab starts it with `--log <file>` and reads that file every 0.2 s. It does not read stdout. The bot prints:
|
||||||
|
|
||||||
|
- `[CPSTATE] active N` / `[CPSTATE] done N`: checkpoint progress (see ADR-0001).
|
||||||
|
- `[UI] text`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`: lines the operator sees. Error lines and tracebacks always show, even without a tag. Every raw line stays in the run log file.
|
||||||
|
- `__RUN_SUMMARY__{json}` as its last line. The outcome comes from this line, not from the exit code. If the line is missing, the run counts as failed, so a crash never shows green.
|
||||||
|
|
||||||
|
**Why:** this comes from Tebra Auto Post. UI automation inside a Qt worker thread was unreliable (bug-094). A windowed PyInstaller child's stdout pipe is block-buffered, so log lines arrived in late bursts; reading the log file fixed that. Tagging lines saves each project from writing a table of phrase→friendly-text rules.
|
||||||
|
|
||||||
|
**Conflict:** `rpa-dashboard-no-ai/templates/bot_runner.py` reads `stdout=PIPE`, which is the approach Tebra dropped. Fix it there, or point it at the Run tab skill's runner.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# A stuck run is killed and fails; no auto-restart, no Stop button
|
||||||
|
|
||||||
|
When a checkpoint passes its time limit, the Run tab kills the bot's whole process tree and ends the run as failed: "Stuck at <checkpoint>, run again". It does not restart. There is no Stop or Pause button. A run also ends early when the operator closes the window, which asks first and then kills the process tree.
|
||||||
|
|
||||||
|
**Why:** Tebra Auto Post restarts a stuck bot up to 3 times. That is only safe because its SQL batch table lets a restarted bot skip work it already did. The Run tab skill has no DB (decision Q2), so a restart could do work twice, for example post a payment twice. Tebra removed Stop on purpose ("a run always goes to completion"), and the Run tab keeps the same look.
|
||||||
|
|
||||||
|
**Reopen if:** a project's bot is rerun-safe (it skips items already done). Then auto-restart ×3 can come back as an option.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: rpa-run-tab
|
||||||
|
description: Adds a Tebra-style Run tab to a desktop RPA bot in about 5 minutes. The Run tab is a PyQt6 window (or a tab in an existing PyQt web page) with a Trigger button, an Excel input card (drop zone, sheet tabs, preview with masked passwords, sample download), a progress bar with a timer, a step list, a friendly live log, checkpoint boxes with time limits, and green/amber/red endings. It also wires the real bot to the page. Use when the user asks for a "Run tab", "run screen", "trigger screen", "start/run page for my bot", or "the Tebra run tab" for an RPA or automation project. For a whole dashboard (KPIs, report table), use rpa-dashboard-no-ai.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RPA Run tab
|
||||||
|
|
||||||
|
Build the Run tab, wire the real bot to it and check it with the fake bot. The whole job should take about 5 minutes.
|
||||||
|
The look is fixed. It is Tebra Auto Post's Run tab, and only the colours change.
|
||||||
|
Everything needed is in `templates/`. Never read the original Tebra projects.
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `templates/run_tab.html` | The page. Its config comes from `run_tab.py`, and its theme is between `THEME:START` and `THEME:END`. Each part is fenced with `RUN-TAB:CSS`, `TOPBAR`, `MARKUP` and `JS` markers. |
|
||||||
|
| `templates/run_tab.py` | The PyQt6 window and bridge. It is also the bot's process (`--run-bot`, which loads no Qt). It holds the **CONFIG** block you fill in and `--selfcheck`. |
|
||||||
|
| `templates/runner.py` | Qt-free. It starts the bot, reads its log file every 0.2 s, turns lines into events, kills stuck runs and decides the ending. |
|
||||||
|
| `templates/rt.py` | The protocol helpers the bot calls: `checkpoint`, `checkpoint_done`, `ui`, `result`, `summary`. |
|
||||||
|
| `templates/fake_bot.py` | Prints the protocol for the endings complete, partial, stuck and crash. Used only by the self-check. |
|
||||||
|
| `templates/palette.py` | Theme: `sky`, `teal` or a brand hex. Use `--write` to put it into the page. It checks contrast. |
|
||||||
|
|
||||||
|
The rules come from the repo's ADRs. Keep to them:
|
||||||
|
- One checkpoint list drives the steps, the boxes and the progress %.
|
||||||
|
- The bot talks only through tagged lines in a log file.
|
||||||
|
- A stuck checkpoint kills the run, and the run fails.
|
||||||
|
- There is no auto-restart and no Stop or Pause button.
|
||||||
|
- There is no database, no resume and no roles.
|
||||||
|
|
||||||
|
## 1. Scan (about 1 minute, ask nothing yet)
|
||||||
|
|
||||||
|
**Never run the bot.** Real bots act on live systems.
|
||||||
|
|
||||||
|
1. **Bot entry point.**
|
||||||
|
- Look for a `main(...)` function, an `if __name__ == "__main__"` script, or a `run_*` function.
|
||||||
|
- Note its loop over input rows or items. Those become the summary's `total` and `failed`.
|
||||||
|
- Note its big stages, such as sign in, open a page, process rows, export. Those become the checkpoints.
|
||||||
|
2. **Input.**
|
||||||
|
- Does the bot read an Excel file (`openpyxl`, `pandas.read_excel`, `xlrd`)?
|
||||||
|
- If yes, note the sheet names and column names it reads, for `SAMPLE_SHEETS`.
|
||||||
|
- No Excel file means `HAS_INPUT = False`.
|
||||||
|
3. **Host.** Search for `QWebEngineView`, `setHtml` or a `.html` page loaded by PyQt, or an `rpa-dashboard-no-ai` `dashboard.html`. If you find one, the Run tab becomes a tab in it (step 4b). Otherwise it gets its own window (step 4a).
|
||||||
|
4. **Processes.** Note any extra programs the bot starts and that must die with it, such as `chromedriver.exe` or a desktop app. These go in `KILL_ALSO`.
|
||||||
|
|
||||||
|
## 2. Ask once (one message, at most 3 questions)
|
||||||
|
|
||||||
|
Send the scan result and these questions in one message, then wait:
|
||||||
|
1. **Colour:**
|
||||||
|
- 1) Tebra Sky (blue)
|
||||||
|
- 2) TVL Teal (green)
|
||||||
|
- 3) Custom: send one brand hex
|
||||||
|
2. **Checkpoints:** propose 3–8, each with a name of 2–3 plain words and a time limit, for example `Sign in, max 5 min`. Use `no limit` for long loops over rows. The user confirms or edits the list.
|
||||||
|
3. **Input:** "Excel input, or no input file?" Skip this question when the scan already answered it.
|
||||||
|
|
||||||
|
Default the title and subtitle from the project name, for example `Run automation` and `Upload the input Excel and start <project>`. Don't ask about them.
|
||||||
|
|
||||||
|
## 3. Build
|
||||||
|
|
||||||
|
1. Copy `run_tab.html`, `run_tab.py`, `runner.py` and `fake_bot.py` into `<project>/run_tab/`.
|
||||||
|
2. Copy `rt.py` there and next to the bot's entry file.
|
||||||
|
3. Fill the **CONFIG** block at the top of `run_tab/run_tab.py`:
|
||||||
|
- `TITLE`, `SUBTITLE`
|
||||||
|
- `BOT`: `"bot.py"` relative to the project root, or `"package.module:function"`
|
||||||
|
- `HAS_INPUT`, `INPUT_HINT`
|
||||||
|
- `CHECKPOINTS`
|
||||||
|
- `SAMPLE_SHEETS`
|
||||||
|
- `KILL_ALSO`
|
||||||
|
- `APP_ID`: the project name, no spaces
|
||||||
|
4. Apply the theme: `python <skill>/templates/palette.py <sky|teal|#hex> --write <project>/run_tab/run_tab.html`.
|
||||||
|
- It must print PASS for every row.
|
||||||
|
- If a row fails, tell the user and suggest a darker hex.
|
||||||
|
5. Make sure `openpyxl`, `PyQt6` and `PyQt6-WebEngine` are in the project's requirements.
|
||||||
|
|
||||||
|
## 4. Wire the bot
|
||||||
|
|
||||||
|
Keep the bot's logic as it is. Only add these lines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import rt
|
||||||
|
|
||||||
|
def main(excel_path): # BOT = "bot:main"; the Run tab passes the Excel path
|
||||||
|
rt.checkpoint(1); rt.ui("Signing in"); ...; rt.ui("Signed in", "ok"); rt.checkpoint_done(1)
|
||||||
|
failed = []
|
||||||
|
rt.checkpoint(2)
|
||||||
|
for i, row in enumerate(rows, start=2):
|
||||||
|
try: process(row)
|
||||||
|
except Exception as e: failed.append((f"Row {i}", str(e))); rt.ui(f"Row {i} failed", "warn")
|
||||||
|
rt.checkpoint_done(2)
|
||||||
|
return rt.result(total=len(rows), failed=failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Checkpoint numbers** are 1-based positions in `CHECKPOINTS`. A loop may go back to an earlier checkpoint for the next item; the later boxes reset and the bar never moves back.
|
||||||
|
- **Returning a summary.** A plain script with no entry function prints `rt.summary(total, failed)` as its last line instead. If no summary arrives, the run counts as **failed**.
|
||||||
|
- **What the operator sees.** Only `[UI]` lines and error lines reach the screen. Everything else goes only to `logs/run_<time>.log`.
|
||||||
|
- Keep `[UI]` text to progress words and row numbers.
|
||||||
|
- **Never put** patient or customer names, amounts, account numbers, passwords or other PHI or secrets on screen, or in the failed reasons.
|
||||||
|
- **Bot output.** Leave the bot's existing prints. They go to the run log.
|
||||||
|
|
||||||
|
### 4a. Its own window
|
||||||
|
There is nothing more to do. Start it with `python run_tab/run_tab.py`.
|
||||||
|
|
||||||
|
### 4b. A tab in an existing PyQt page
|
||||||
|
Copy the four fenced parts of `run_tab.html` into the host page:
|
||||||
|
- `RUN-TAB:CSS`, `RUN-TAB:TOPBAR`, `RUN-TAB:MARKUP`, `RUN-TAB:JS`
|
||||||
|
- the `THEME` block, if the host has no tokens of its own
|
||||||
|
|
||||||
|
Then wire the host:
|
||||||
|
- **Page config:** add `const CFG = ...` to the host page; `page_html()` in `run_tab.py` shows its shape.
|
||||||
|
- **Messages:** route the host's Python→page messages through one `window.__pyMsg`, and merge the `handlePyMessage` branches.
|
||||||
|
- **Bridge:** give the host's bridge the `Bridge` slots from `run_tab.py`. They are `browse_file`, `process_dropped_path`, `read_sheet`, `download_sample_input`, `start_automation` and `set_dark`.
|
||||||
|
- **Window:** add the close guard from `RunTabWindow.closeEvent`.
|
||||||
|
- **Don't connect `js_message` in the page** as well as using `__pyMsg`. Doing both shows every message twice.
|
||||||
|
|
||||||
|
## 5. Verify with the fake bot
|
||||||
|
|
||||||
|
```
|
||||||
|
python run_tab/run_tab.py --selfcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs the fake bot through the real runner four times:
|
||||||
|
- complete → green
|
||||||
|
- partial → amber
|
||||||
|
- stuck → red, with box 2 marked stuck
|
||||||
|
- crash → red
|
||||||
|
|
||||||
|
Each line must print `ok`. It then saves the window as `logs/selfcheck_complete_light.png`, `logs/selfcheck_partial_dark.png` and `logs/selfcheck_stuck_light.png`. **Look at all three.** Check:
|
||||||
|
- the Tebra layout
|
||||||
|
- your colours, readable in both themes
|
||||||
|
- the checkpoint names
|
||||||
|
- no horizontal scroll
|
||||||
|
|
||||||
|
**Never press Trigger on the real bot yourself.** Tell the user to start the first real run.
|
||||||
|
|
||||||
|
## 6. Hand over (two lines)
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
- how to start it (`python run_tab/run_tab.py`)
|
||||||
|
- where raw run logs go (`logs/`)
|
||||||
|
|
||||||
|
If the project ships as a PyInstaller exe:
|
||||||
|
- the entry point is `run_tab/run_tab.py`
|
||||||
|
- add `run_tab/run_tab.html` as data
|
||||||
|
- add the bot module as a hidden import, because the exe starts itself with `--run-bot`
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Fake bot for checking the Run tab. Prints the same protocol lines a real bot does (ADR-0002).
|
||||||
|
|
||||||
|
FAKE_MODE: complete | partial | stuck | crash FAKE_CPS: how many checkpoints
|
||||||
|
Never touches anything outside its own log.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import rt # noqa: E402
|
||||||
|
|
||||||
|
mode = os.environ.get("FAKE_MODE", "complete")
|
||||||
|
cps = int(os.environ.get("FAKE_CPS", "3"))
|
||||||
|
print("fake bot starting (this line stays in the log file only)")
|
||||||
|
for n in range(1, cps + 1):
|
||||||
|
rt.checkpoint(n)
|
||||||
|
rt.ui(f"Working on checkpoint {n}")
|
||||||
|
if mode == "stuck" and n == 2:
|
||||||
|
time.sleep(3600) # the Run tab must kill this
|
||||||
|
if mode == "crash" and n == 2:
|
||||||
|
raise RuntimeError("Simulated crash")
|
||||||
|
time.sleep(0.4)
|
||||||
|
rt.ui(f"Checkpoint {n} finished", "ok")
|
||||||
|
rt.checkpoint_done(n)
|
||||||
|
|
||||||
|
if mode == "partial":
|
||||||
|
rt.ui("2 rows could not be processed", "warn")
|
||||||
|
rt.summary(5, [("Row 4", "Account not found"), ("Row 5", "Timed out")])
|
||||||
|
else:
|
||||||
|
rt.summary(5)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Theme the Run tab: a preset or one brand hex -> the THEME block of run_tab.html.
|
||||||
|
|
||||||
|
python palette.py sky|teal|"#7a3cff" print the block and a contrast table
|
||||||
|
python palette.py "#7a3cff" --write run_tab/run_tab.html also write it into the page
|
||||||
|
|
||||||
|
Only the sky gradient and the accent change. ok/warn/bad and the log colours stay fixed (R9).
|
||||||
|
A brand hex that is too light for white button text is darkened until it passes.
|
||||||
|
Exits 1 if a contrast check still fails.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
WHITE, BLACK = "#ffffff", "#000000"
|
||||||
|
INK = {"light": "#1c2b33", "dark": "#e6eef4"}
|
||||||
|
PRESETS = {
|
||||||
|
"sky": {"light": {"--sky-top": "#8fc9e8", "--sky-mid": "#bfe3f2", "--sky-bottom": "#eaf6fb",
|
||||||
|
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#16202a"},
|
||||||
|
"dark": {"--sky-top": "#0f1b26", "--sky-mid": "#13212e", "--sky-bottom": "#182a38",
|
||||||
|
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#2e5d80"}},
|
||||||
|
"teal": {"light": {"--sky-top": "#8fe8ad", "--sky-mid": "#bff2d0", "--sky-bottom": "#eafbf0",
|
||||||
|
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#16202a"},
|
||||||
|
"dark": {"--sky-top": "#0f2617", "--sky-mid": "#132e1c", "--sky-bottom": "#183823",
|
||||||
|
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#2e5d80"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rgb(h):
|
||||||
|
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
|
||||||
|
|
||||||
|
|
||||||
|
def mix(a, b, t):
|
||||||
|
"""Blend hex colour a toward b by t (0..1)."""
|
||||||
|
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
|
||||||
|
|
||||||
|
|
||||||
|
def contrast(a, b):
|
||||||
|
"""WCAG 2 contrast ratio between two hex colours."""
|
||||||
|
def lum(h):
|
||||||
|
c = [v / 255 for v in rgb(h)]
|
||||||
|
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
|
||||||
|
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||||
|
hi, lo = sorted((lum(a), lum(b)), reverse=True)
|
||||||
|
return (hi + 0.05) / (lo + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def derive(brand):
|
||||||
|
brand = brand.strip().lower()
|
||||||
|
if len(brand) == 4: # #abc -> #aabbcc
|
||||||
|
brand = "#" + "".join(ch * 2 for ch in brand[1:])
|
||||||
|
accent = brand
|
||||||
|
for _ in range(60): # white text on the Trigger button must stay readable
|
||||||
|
if contrast(WHITE, accent) >= 3:
|
||||||
|
break
|
||||||
|
accent = mix(accent, BLACK, 0.06)
|
||||||
|
base = {"--accent": accent, "--accent-lite": mix(accent, WHITE, 0.2)}
|
||||||
|
return {
|
||||||
|
"light": {"--sky-top": mix(accent, WHITE, 0.55), "--sky-mid": mix(accent, WHITE, 0.72),
|
||||||
|
"--sky-bottom": mix(accent, WHITE, 0.9), **base, "--accent-dark": mix(accent, "#0b1218", 0.85)},
|
||||||
|
"dark": {"--sky-top": mix(accent, BLACK, 0.85), "--sky-mid": mix(accent, BLACK, 0.82),
|
||||||
|
"--sky-bottom": mix(accent, BLACK, 0.78), **base, "--accent-dark": mix(accent, BLACK, 0.4)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def checks(t):
|
||||||
|
rows = []
|
||||||
|
for mode, m in t.items():
|
||||||
|
rows += [(mode, "white text on the Trigger button", contrast(WHITE, m["--accent"]), 3),
|
||||||
|
(mode, "ink on the top of the sky", contrast(INK[mode], m["--sky-top"]), 4.5),
|
||||||
|
(mode, "ink on the bottom of the sky", contrast(INK[mode], m["--sky-bottom"]), 4.5)]
|
||||||
|
rows.append(("light", "progress % on the sky", contrast(t["light"]["--accent-dark"], t["light"]["--sky-bottom"]), 4.5))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def block(t):
|
||||||
|
def line(m):
|
||||||
|
r = ",".join(str(v) for v in rgb(m["--accent"]))
|
||||||
|
return "; ".join(f"{k}:{v}" for k, v in m.items()) + f"; --accent-rgb:{r};"
|
||||||
|
return ("/* THEME:START (palette.py --write replaces everything down to THEME:END) */\n"
|
||||||
|
f":root{{\n {line(t['light'])}\n}}\nbody.dark{{\n {line(t['dark'])}\n}}\n/* THEME:END */")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2 or not (sys.argv[1] in PRESETS or sys.argv[1].startswith("#")):
|
||||||
|
sys.exit(__doc__)
|
||||||
|
t = PRESETS.get(sys.argv[1]) or derive(sys.argv[1])
|
||||||
|
css = block(t)
|
||||||
|
print(css + "\n")
|
||||||
|
rows = checks(t)
|
||||||
|
for mode, what, ratio, need in rows:
|
||||||
|
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
|
||||||
|
if "--write" in sys.argv:
|
||||||
|
page = sys.argv[sys.argv.index("--write") + 1]
|
||||||
|
html = open(page, encoding="utf-8").read()
|
||||||
|
new, n = re.subn(r"/\* THEME:START.*?THEME:END \*/", lambda _: css, html, flags=re.S)
|
||||||
|
if n != 1:
|
||||||
|
sys.exit(f"THEME:START/END markers not found in {page}")
|
||||||
|
open(page, "w", encoding="utf-8").write(new)
|
||||||
|
print(f"\nwrote the theme into {page}")
|
||||||
|
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Run tab protocol for the bot (ADR-0002). Copy this file next to the bot.
|
||||||
|
|
||||||
|
import rt
|
||||||
|
rt.checkpoint(1) # checkpoint 1 starts (its time limit starts counting)
|
||||||
|
rt.ui("Signed in", "ok") # a line the operator sees: kind "", "ok", "warn" or "error"
|
||||||
|
rt.checkpoint_done(1)
|
||||||
|
...
|
||||||
|
return rt.result(total=40, failed=[("Row 7", "Account not found")])
|
||||||
|
|
||||||
|
The bot's entry function returns rt.result(...), and the Run tab prints the summary line.
|
||||||
|
A bot that is a plain script (no entry function) calls rt.summary(...) as its last line.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
SUMMARY = "__RUN_SUMMARY__"
|
||||||
|
|
||||||
|
|
||||||
|
def checkpoint(n):
|
||||||
|
print(f"[CPSTATE] active {n}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def checkpoint_done(n):
|
||||||
|
print(f"[CPSTATE] done {n}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ui(msg, kind=""):
|
||||||
|
print(f"[UI{':' + kind if kind else ''}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def result(total, failed=()):
|
||||||
|
failed = [[str(item), str(reason)] for item, reason in failed]
|
||||||
|
return {"total": total, "ok": total - len(failed), "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
def summary(total, failed=()):
|
||||||
|
print(SUMMARY + json.dumps(result(total, failed)), flush=True)
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||||
|
<style>
|
||||||
|
/* THEME:START (palette.py --write replaces everything down to THEME:END) */
|
||||||
|
:root{
|
||||||
|
--sky-top:#8fc9e8; --sky-mid:#bfe3f2; --sky-bottom:#eaf6fb;
|
||||||
|
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#16202a;
|
||||||
|
}
|
||||||
|
body.dark{
|
||||||
|
--sky-top:#0f1b26; --sky-mid:#13212e; --sky-bottom:#182a38;
|
||||||
|
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#2e5d80;
|
||||||
|
}
|
||||||
|
/* THEME:END */
|
||||||
|
:root{
|
||||||
|
--glass:rgba(255,255,255,0.60); --glass-strong:rgba(255,255,255,0.78);
|
||||||
|
--border:rgba(255,255,255,0.9);
|
||||||
|
--ink:#1c2b33; --muted:#5c7382;
|
||||||
|
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd;
|
||||||
|
--bad:#c0392b; --bad-bg:#fdecea;
|
||||||
|
}
|
||||||
|
body.dark{
|
||||||
|
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85);
|
||||||
|
--border:rgba(120,160,190,.22);
|
||||||
|
--ink:#e6eef4; --muted:#8fa6b4;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%;font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
color:var(--ink);overflow:hidden;
|
||||||
|
background:linear-gradient(180deg,var(--sky-top) 0%,var(--sky-mid) 45%,var(--sky-bottom) 100%)}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
input[type=file]{display:none}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
|
||||||
|
|
||||||
|
/* RUN-TAB:CSS:START */
|
||||||
|
.main{height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.topbar{background:linear-gradient(180deg,rgba(255,255,255,.82),rgba(255,255,255,.55));
|
||||||
|
border-bottom:1px solid var(--border);backdrop-filter:blur(14px);
|
||||||
|
padding:20px 28px;display:flex;align-items:center;gap:14px;flex-shrink:0;flex-wrap:wrap;
|
||||||
|
box-shadow:0 6px 18px rgba(20,50,70,.10)}
|
||||||
|
.tb-title{font-size:21px;font-weight:800;color:var(--ink)}
|
||||||
|
.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
|
||||||
|
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||||
|
.top-run-btn{display:flex;align-items:center;gap:8px;padding:10px 20px;border-radius:11px;border:none;
|
||||||
|
font-size:13px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}
|
||||||
|
.top-run-btn svg{width:16px;height:16px;stroke:currentColor}
|
||||||
|
.top-run-btn.off{background:rgba(255,255,255,.55);color:var(--muted);cursor:not-allowed}
|
||||||
|
.top-run-btn.on{background:linear-gradient(180deg,var(--accent-lite),var(--accent));color:#fff;
|
||||||
|
box-shadow:0 2px 6px rgba(var(--accent-rgb),.35)}
|
||||||
|
.top-run-btn.on:hover{box-shadow:0 6px 16px rgba(var(--accent-rgb),.4);transform:translateY(-1px)}
|
||||||
|
.top-run-btn.on:active{transform:scale(.97);box-shadow:0 1px 3px rgba(var(--accent-rgb),.3)}
|
||||||
|
.top-run-btn.going{background:var(--warn-bg);color:var(--warn);cursor:not-allowed}
|
||||||
|
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
|
||||||
|
background:var(--glass-strong);cursor:pointer;font-size:16px;line-height:1;
|
||||||
|
display:flex;align-items:center;justify-content:center;transition:all .18s ease;
|
||||||
|
box-shadow:0 1px 3px rgba(20,50,70,.12)}
|
||||||
|
.mode-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.2)}
|
||||||
|
.mode-btn:active{transform:scale(.94)}
|
||||||
|
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border)}
|
||||||
|
.dot{width:8px;height:8px;border-radius:50%;transition:all .3s}
|
||||||
|
.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.partial{background:var(--warn);box-shadow:0 0 7px var(--warn)}
|
||||||
|
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||||
|
.ind-text{font-size:12px;font-weight:700;color:var(--ink)}
|
||||||
|
|
||||||
|
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px;
|
||||||
|
background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,0));
|
||||||
|
box-shadow:inset 2px 2px 0 rgba(255,255,255,.35)}
|
||||||
|
.content::-webkit-scrollbar{width:8px}
|
||||||
|
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.8);border-radius:8px}
|
||||||
|
.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:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
|
||||||
|
.card-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
|
||||||
|
.c-icon{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
||||||
|
background:var(--glass-strong)}
|
||||||
|
.c-icon svg{width:20px;height:20px;stroke:var(--accent-dark)}
|
||||||
|
.c-title{font-size:15px;font-weight:700;color:var(--ink)}
|
||||||
|
.c-sub{font-size:12px;color:var(--muted);margin-top:2px}
|
||||||
|
.btn{padding:9px 15px;border-radius:11px;border:1px solid rgba(var(--accent-rgb),.45);
|
||||||
|
background:linear-gradient(180deg,rgba(var(--accent-rgb),.16),rgba(var(--accent-rgb),.32));
|
||||||
|
color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;
|
||||||
|
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.14)}
|
||||||
|
.btn:hover{background:linear-gradient(180deg,rgba(var(--accent-rgb),.24),rgba(var(--accent-rgb),.42));transform:translateY(-1px);
|
||||||
|
box-shadow:0 4px 12px rgba(var(--accent-rgb),.28)}
|
||||||
|
.btn:active{transform:translateY(0) scale(.97);box-shadow:0 1px 2px rgba(20,50,70,.12)}
|
||||||
|
|
||||||
|
.drop-zone{border:2px dashed rgba(var(--accent-rgb),.45);border-radius:14px;padding:36px 20px;text-align:center;cursor:pointer;transition:all .2s;background:rgba(255,255,255,.35)}
|
||||||
|
.drop-zone:hover,.drop-zone.over{border-color:var(--accent-dark);background:rgba(255,255,255,.6)}
|
||||||
|
.drop-icon{width:44px;height:44px;stroke:rgba(var(--accent-rgb),.8);margin:0 auto 12px;display:block}
|
||||||
|
.drop-zone:hover .drop-icon,.drop-zone.over .drop-icon{stroke:var(--accent-dark)}
|
||||||
|
.drop-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:5px}
|
||||||
|
.drop-sub{font-size:12.5px;color:var(--muted)}
|
||||||
|
.browse-link{color:var(--accent-dark);cursor:pointer;font-weight:700;text-decoration:underline}
|
||||||
|
.tags{display:flex;gap:7px;justify-content:center;margin-top:14px;flex-wrap:wrap}
|
||||||
|
.tag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.6);border:1px solid var(--border);padding:4px 11px;border-radius:6px;font-weight:600}
|
||||||
|
|
||||||
|
.file-row{display:flex;align-items:center;gap:14px;padding:15px 16px;background:var(--ok-bg);border-radius:12px;border:1px solid #bfe6cc;margin-top:14px}
|
||||||
|
.f-details{flex:1;min-width:0}
|
||||||
|
.f-name{font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.f-path{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.f-badge{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font-size:11px;color:var(--ok);font-weight:700}
|
||||||
|
.f-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block}
|
||||||
|
.f-remove{color:var(--muted);cursor:pointer;font-size:20px;padding:3px 9px;border-radius:8px;flex-shrink:0}
|
||||||
|
.f-remove:hover{color:var(--bad);background:var(--bad-bg)}
|
||||||
|
|
||||||
|
.sheet-tabs{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
|
||||||
|
.sheet-btn{padding:9px 15px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.5);color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.08)}
|
||||||
|
.sheet-btn:hover{background:rgba(255,255,255,.8);transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.15)}
|
||||||
|
.sheet-btn:active{transform:scale(.97)}
|
||||||
|
.sheet-btn.active{background:linear-gradient(180deg,var(--accent-lite),var(--accent));border-color:var(--accent);color:#fff}
|
||||||
|
.sheet-preview{margin-top:14px;display:none}
|
||||||
|
.sheet-preview.show{display:block}
|
||||||
|
.preview-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||||
|
.preview-title{font-size:13px;font-weight:700;color:var(--ink)}
|
||||||
|
.preview-count{font-size:11px;color:var(--muted);font-weight:600}
|
||||||
|
.tbl-wrap{border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:280px;background:rgba(255,255,255,.5)}
|
||||||
|
.tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
|
||||||
|
.tbl-wrap::-webkit-scrollbar-thumb{background:rgba(var(--accent-rgb),.3);border-radius:8px}
|
||||||
|
table.data-tbl{width:100%;border-collapse:collapse;font-size:12px}
|
||||||
|
table.data-tbl th{background:rgba(255,255,255,.85);color:var(--ink);font-weight:700;text-align:left;padding:9px 12px;position:sticky;top:0;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||||
|
table.data-tbl td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.7);color:#2b3f4a;white-space:nowrap}
|
||||||
|
table.data-tbl .mask{color:#9fb0b8;letter-spacing:2px}
|
||||||
|
.preview-empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}
|
||||||
|
.clear-bar{display:none;align-items:center;gap:12px;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.7)}
|
||||||
|
.clear-bar.show{display:flex}
|
||||||
|
.clear-btn{padding:9px 16px;border-radius:10px;border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .18s ease;box-shadow:0 1px 3px rgba(192,57,43,.12)}
|
||||||
|
.clear-btn:hover{background:var(--bad);color:#fff;transform:translateY(-1px);box-shadow:0 4px 10px rgba(192,57,43,.25)}
|
||||||
|
.clear-btn:active{transform:scale(.97)}
|
||||||
|
.clear-hint{font-size:11.5px;color:var(--muted)}
|
||||||
|
|
||||||
|
.prog-wrap{display:none;margin-bottom:6px}
|
||||||
|
.prog-wrap.show{display:block}
|
||||||
|
.prog-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||||
|
.prog-lbl{font-size:13px;font-weight:700;color:var(--ink);margin-right:auto}
|
||||||
|
.prog-time{font-size:12.5px;font-weight:700;color:var(--muted);
|
||||||
|
background:rgba(var(--accent-rgb),.12);padding:2px 9px;border-radius:8px;white-space:nowrap}
|
||||||
|
.prog-pct{font-size:13px;color:var(--accent-dark);font-weight:800}
|
||||||
|
.prog-bg{height:8px;background:rgba(255,255,255,.6);border-radius:8px;overflow:hidden;margin-bottom:18px}
|
||||||
|
.prog-fill{height:100%;background:linear-gradient(90deg,#28c26f,#1f9d57);border-radius:8px;width:0%;transition:width .5s ease}
|
||||||
|
.prog-fill.partial{background:linear-gradient(90deg,#e0a53c,#b7791f)}
|
||||||
|
.prog-fill.failed{background:linear-gradient(90deg,#e8695b,#c0392b)}
|
||||||
|
.steps{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
|
||||||
|
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;border:1px solid transparent}
|
||||||
|
.step.done{background:var(--ok-bg);border-color:#cdeed8}
|
||||||
|
.step.active{background:var(--warn-bg);border-color:#f0dcae}
|
||||||
|
.step.stuck{background:var(--bad-bg);border-color:#f0b6b0}
|
||||||
|
.step.pending{opacity:.5}
|
||||||
|
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;font-weight:700}
|
||||||
|
.step.done .step-dot{background:var(--ok);color:#fff}
|
||||||
|
.step.active .step-dot{background:var(--warn);color:#fff}
|
||||||
|
.step.stuck .step-dot{background:var(--bad);color:#fff}
|
||||||
|
.step.pending .step-dot{background:rgba(255,255,255,.7);color:var(--muted)}
|
||||||
|
.step-lbl{font-size:13px;font-weight:600}
|
||||||
|
.step.done .step-lbl{color:var(--ok)}
|
||||||
|
.step.active .step-lbl{color:var(--warn)}
|
||||||
|
.step.stuck .step-lbl{color:var(--bad)}
|
||||||
|
.step.pending .step-lbl{color:var(--muted)}
|
||||||
|
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:200px;overflow-y:auto;
|
||||||
|
font-family:'Consolas','Courier New',monospace;font-size:13.5px;line-height:1.85}
|
||||||
|
.log::-webkit-scrollbar{width:6px}
|
||||||
|
.log::-webkit-scrollbar-thumb{background:#3a4a55;border-radius:6px}
|
||||||
|
.log-line{color:#9fb6c4;overflow-wrap:anywhere}
|
||||||
|
.log-line.info{color:#7fc4f0}
|
||||||
|
.log-line.success{color:#5fe0a0}
|
||||||
|
.log-line.warn{color:#f5c563}
|
||||||
|
.log-line.error{color:#f68c82}
|
||||||
|
|
||||||
|
.cp-wrap{margin-top:14px}
|
||||||
|
#cp-elapsed{margin-left:auto;font-weight:700;color:var(--accent-dark)}
|
||||||
|
.cp-title{font-size:12px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;
|
||||||
|
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||||||
|
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
|
||||||
|
.cp-box{position:relative;border-radius:11px;padding:11px 10px 10px;
|
||||||
|
border:1.5px solid rgba(var(--accent-rgb),.20);background:rgba(var(--accent-rgb),.06);
|
||||||
|
display:flex;flex-direction:column;gap:5px;min-height:74px;transition:all .25s}
|
||||||
|
.cp-box .cp-num{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;
|
||||||
|
justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;
|
||||||
|
background:rgba(255,255,255,.6);color:var(--muted)}
|
||||||
|
.cp-box .cp-name{font-size:12px;font-weight:700;color:var(--ink);line-height:1.25}
|
||||||
|
.cp-box .cp-meta{font-size:10.5px;font-weight:700;color:var(--muted);margin-top:auto}
|
||||||
|
.cp-box.pending{opacity:.55}
|
||||||
|
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);box-shadow:0 0 0 3px rgba(245,197,99,.14)}
|
||||||
|
.cp-box.active .cp-num{background:var(--warn);color:#fff;animation:cp-pulse 1.2s ease-in-out infinite}
|
||||||
|
.cp-box.active .cp-meta{color:var(--warn)}
|
||||||
|
.cp-box.done{border-color:#cdeed8;background:var(--ok-bg)}
|
||||||
|
.cp-box.done .cp-num{background:var(--ok);color:#fff}
|
||||||
|
.cp-box.done .cp-meta{color:var(--ok)}
|
||||||
|
.cp-box.stuck{border-color:#f0b6b0;background:var(--bad-bg)}
|
||||||
|
.cp-box.stuck .cp-num{background:var(--bad);color:#fff}
|
||||||
|
.cp-box.stuck .cp-meta{color:var(--bad)}
|
||||||
|
@keyframes cp-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,197,99,.5)}50%{box-shadow:0 0 0 6px rgba(245,197,99,0)}}
|
||||||
|
|
||||||
|
body.dark .topbar{background:linear-gradient(180deg,rgba(30,45,58,.92),rgba(22,34,44,.7));box-shadow:0 6px 18px rgba(0,0,0,.28)}
|
||||||
|
body.dark .content{background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));box-shadow:inset 2px 2px 0 rgba(255,255,255,.08)}
|
||||||
|
body.dark .content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18)}
|
||||||
|
body.dark .btn{background:linear-gradient(180deg,rgba(var(--accent-rgb),.22),rgba(var(--accent-rgb),.12));color:var(--ink)}
|
||||||
|
body.dark .drop-zone{background:rgba(255,255,255,.04);border-color:rgba(var(--accent-rgb),.35)}
|
||||||
|
body.dark .drop-zone:hover,body.dark .drop-zone.over{background:rgba(255,255,255,.08);border-color:var(--accent-lite)}
|
||||||
|
body.dark .tag{background:rgba(255,255,255,.07)}
|
||||||
|
body.dark .top-run-btn.off{background:rgba(255,255,255,.08)}
|
||||||
|
body.dark .file-row{background:var(--ok-bg);border-color:rgba(47,191,114,.35)}
|
||||||
|
body.dark .sheet-btn{background:rgba(255,255,255,.07)}
|
||||||
|
body.dark .sheet-btn:hover{background:rgba(255,255,255,.14)}
|
||||||
|
body.dark .tbl-wrap{background:rgba(255,255,255,.03)}
|
||||||
|
body.dark table.data-tbl th{background:#1d2f3c;color:var(--ink)}
|
||||||
|
body.dark table.data-tbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
|
||||||
|
body.dark .clear-bar{border-top-color:rgba(120,160,190,.15)}
|
||||||
|
body.dark .clear-btn{border-color:rgba(224,90,75,.4)}
|
||||||
|
body.dark .prog-bg{background:rgba(255,255,255,.12)}
|
||||||
|
body.dark .step.pending .step-dot{background:rgba(255,255,255,.12)}
|
||||||
|
body.dark .step.done{border-color:rgba(47,191,114,.3)}
|
||||||
|
body.dark .step.active{border-color:rgba(217,161,60,.3)}
|
||||||
|
body.dark .step.stuck{border-color:rgba(224,90,75,.35)}
|
||||||
|
body.dark .log{background:#0d161e}
|
||||||
|
body.dark .prog-pct,body.dark .browse-link,body.dark #cp-elapsed{color:var(--accent-lite)}
|
||||||
|
body.dark .c-icon svg{stroke:var(--accent-lite)}
|
||||||
|
body.dark .cp-box .cp-name{color:#cfe0ec}
|
||||||
|
body.dark .cp-box.pending .cp-num{background:rgba(255,255,255,.12)}
|
||||||
|
body.dark .cp-box.done{border-color:rgba(47,191,114,.3)}
|
||||||
|
body.dark .cp-box.stuck{border-color:rgba(224,90,75,.35)}
|
||||||
|
@media (max-width:820px){
|
||||||
|
.topbar{padding:16px}
|
||||||
|
.content{padding:18px 16px}
|
||||||
|
}
|
||||||
|
/* RUN-TAB:CSS:END */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main">
|
||||||
|
<!-- RUN-TAB:TOPBAR:START -->
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<div class="tb-title" id="tb-title">Run automation</div>
|
||||||
|
<div class="tb-sub" id="tb-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tb-right">
|
||||||
|
<button class="top-run-btn off" id="top-run-btn" onclick="doRun()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
<span id="top-run-lbl">Upload a file to continue</span>
|
||||||
|
</button>
|
||||||
|
<button class="mode-btn" id="mode-btn" onclick="toggleDark()" title="Toggle dark mode">🌙</button>
|
||||||
|
<div class="indicator">
|
||||||
|
<div class="dot ready" id="sys-dot"></div>
|
||||||
|
<div class="ind-text" id="sys-txt">System ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RUN-TAB:TOPBAR:END -->
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- RUN-TAB:MARKUP:START -->
|
||||||
|
<div id="tab-run">
|
||||||
|
<div id="input-card">
|
||||||
|
<div class="section-label">Input configuration</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="c-title">Input Excel upload</div>
|
||||||
|
<div class="c-sub" id="input-hint"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="sample-btn" style="margin-left:auto;flex-shrink:0"
|
||||||
|
onclick="pyBridge&&pyBridge.download_sample_input()"
|
||||||
|
title="Save an example workbook with the columns this bot reads">⬇ Sample input</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drop-zone" id="dz" ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)"
|
||||||
|
onclick="pyBridge&&pyBridge.browse_file()">
|
||||||
|
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
<div class="drop-title">Drop your Excel file here</div>
|
||||||
|
<div class="drop-sub">or <span class="browse-link" onclick="event.stopPropagation();pyBridge&&pyBridge.browse_file()">browse files</span></div>
|
||||||
|
<div class="tags"><span class="tag">.xlsx</span><span class="tag">.xlsm</span><span class="tag">.xls</span></div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="fi" accept=".xlsx,.xlsm,.xls" onchange="fileChosen(this.files[0])">
|
||||||
|
|
||||||
|
<div class="file-row hidden" id="file-row">
|
||||||
|
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
|
||||||
|
<div class="f-details">
|
||||||
|
<div class="f-name" id="f-name">—</div>
|
||||||
|
<div class="f-path" id="f-path">—</div>
|
||||||
|
<div class="f-badge">Ready to process</div>
|
||||||
|
</div>
|
||||||
|
<div class="f-remove" onclick="removeFile()" title="Remove">×</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-tabs hidden" id="sheet-tabs"></div>
|
||||||
|
<div class="sheet-preview" id="sheet-preview">
|
||||||
|
<div class="preview-head">
|
||||||
|
<div class="preview-title" id="preview-title">Sheet</div>
|
||||||
|
<div class="preview-count" id="preview-count"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap" id="tbl-wrap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clear-bar" id="clear-bar">
|
||||||
|
<button class="clear-btn" onclick="removeFile()">Clear file</button>
|
||||||
|
<span class="clear-hint">Uploaded the wrong file? Clear and choose again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="c-title">Run automation</div>
|
||||||
|
<div class="c-sub" id="run-flow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prog-wrap" id="prog-wrap">
|
||||||
|
<div class="prog-head">
|
||||||
|
<span class="prog-lbl" id="prog-lbl">Starting...</span>
|
||||||
|
<span class="prog-time" id="prog-time"></span>
|
||||||
|
<span class="prog-pct" id="prog-pct">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="prog-bg"><div class="prog-fill" id="prog-fill"></div></div>
|
||||||
|
<div class="steps" id="steps"></div>
|
||||||
|
<div class="log" id="log"></div>
|
||||||
|
|
||||||
|
<div class="cp-wrap">
|
||||||
|
<div class="cp-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
Progress checkpoints
|
||||||
|
<span id="cp-elapsed"></span>
|
||||||
|
</div>
|
||||||
|
<div class="cp-track" id="cp-track"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RUN-TAB:MARKUP:END -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Filled by run_tab.py from its config: {title, sub, input, inputHint, sample, darkKey,
|
||||||
|
checkpoints:[{name, max}]}. max = seconds before the checkpoint counts as stuck, 0 = no limit. */
|
||||||
|
const CFG = %%CONFIG%%;
|
||||||
|
let pyBridge=null;
|
||||||
|
|
||||||
|
/* RUN-TAB:JS:START */
|
||||||
|
let fileReady=false, filePath='', running=false;
|
||||||
|
const CPS=CFG.checkpoints;
|
||||||
|
|
||||||
|
function escapeHtml(s){ const d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
|
||||||
|
|
||||||
|
/* Python -> page. Messages arrive ONLY through window.__pyMsg (run_tab.py calls it).
|
||||||
|
Never also connect js_message in QWebChannel: Tebra did both and every line showed twice. */
|
||||||
|
window.__pyMsg = function(s){ const m=JSON.parse(s); handlePyMessage(m.event,m.data); };
|
||||||
|
function handlePyMessage(event,data){
|
||||||
|
if(event==='file_selected') setFile(data.name,data.path,data.sheets);
|
||||||
|
else if(event==='sheet_data') renderSheetData(data);
|
||||||
|
else if(event==='log') addLog(data.msg,data.type);
|
||||||
|
else if(event==='cpstate') cpState(data.kind, data.cp);
|
||||||
|
else if(event==='status') onStatus(data.state, data.msg);
|
||||||
|
else if(event==='apply_dark') applyDark(data.on, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRunTab(){
|
||||||
|
document.getElementById('tb-title').textContent=CFG.title;
|
||||||
|
document.getElementById('tb-sub').textContent=CFG.sub;
|
||||||
|
document.getElementById('run-flow').textContent=CPS.map(c=>c.name).join(' → ');
|
||||||
|
document.getElementById('input-hint').textContent=CFG.inputHint||'';
|
||||||
|
document.getElementById('input-card').classList.toggle('hidden', !CFG.input);
|
||||||
|
document.getElementById('sample-btn').classList.toggle('hidden', !CFG.sample);
|
||||||
|
fileReady=!CFG.input;
|
||||||
|
renderCpTrack(); renderSteps(); updateRunBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- dark mode: saved in localStorage and, through the bridge, in QSettings ---- */
|
||||||
|
function applyDark(on, fromPy){
|
||||||
|
document.body.classList.toggle('dark', !!on);
|
||||||
|
const b=document.getElementById('mode-btn'); if(b) b.textContent=on?'☀️':'🌙';
|
||||||
|
try{ localStorage.setItem(CFG.darkKey, on?'1':'0'); }catch(e){}
|
||||||
|
if(pyBridge && !fromPy) pyBridge.set_dark(!!on);
|
||||||
|
}
|
||||||
|
function toggleDark(){ applyDark(!document.body.classList.contains('dark')); }
|
||||||
|
|
||||||
|
/* ---- input file ---- */
|
||||||
|
function dzOver(e){e.preventDefault();document.getElementById('dz').classList.add('over');}
|
||||||
|
function dzLeave(){document.getElementById('dz').classList.remove('over');}
|
||||||
|
function dzDrop(e){e.preventDefault();dzLeave();const f=e.dataTransfer.files[0];if(f)fileChosen(f);}
|
||||||
|
function fileChosen(f){
|
||||||
|
if(!f||running)return;
|
||||||
|
const ext='.'+f.name.split('.').pop().toLowerCase();
|
||||||
|
if(!['.xlsx','.xlsm','.xls'].includes(ext)){addLog('Invalid file type — use .xlsx .xlsm .xls','warn');return;}
|
||||||
|
// QWebEngine hides the real path of a dropped file, so Python resolves the name.
|
||||||
|
if(pyBridge) pyBridge.process_dropped_path(f.path || f.name);
|
||||||
|
else setFile(f.name, f.path || f.name, []);
|
||||||
|
}
|
||||||
|
function setFile(name,path,sheets){
|
||||||
|
if(!path){ addLog('Could not find "'+name+'" on disk — use browse files instead.','warn'); return; }
|
||||||
|
fileReady=true; filePath=path;
|
||||||
|
document.getElementById('dz').style.display='none';
|
||||||
|
document.getElementById('file-row').classList.remove('hidden');
|
||||||
|
document.getElementById('f-name').textContent=name;
|
||||||
|
document.getElementById('f-path').textContent=path;
|
||||||
|
document.getElementById('clear-bar').classList.add('show');
|
||||||
|
updateRunBtn();
|
||||||
|
renderSheetButtons(sheets||[]);
|
||||||
|
}
|
||||||
|
function renderSheetButtons(sheets){
|
||||||
|
const tabs=document.getElementById('sheet-tabs');
|
||||||
|
document.getElementById('sheet-preview').classList.remove('show');
|
||||||
|
if(!sheets.length){tabs.classList.add('hidden');tabs.innerHTML='';return;}
|
||||||
|
tabs.classList.remove('hidden');
|
||||||
|
tabs.innerHTML=sheets.map((s,i)=>`<div class="sheet-btn" id="sb-${i}" data-sheet="${escapeHtml(s)}" onclick="openSheet(this.dataset.sheet,${i})">${escapeHtml(s)}</div>`).join('');
|
||||||
|
openSheet(sheets[0], 0); // show the first sheet right after upload
|
||||||
|
}
|
||||||
|
function openSheet(name,idx){
|
||||||
|
document.querySelectorAll('.sheet-btn').forEach(b=>b.classList.remove('active'));
|
||||||
|
const b=document.getElementById('sb-'+idx); if(b)b.classList.add('active');
|
||||||
|
document.getElementById('tbl-wrap').innerHTML='<div class="preview-empty">Loading...</div>';
|
||||||
|
document.getElementById('sheet-preview').classList.add('show');
|
||||||
|
document.getElementById('preview-title').textContent=name;
|
||||||
|
document.getElementById('preview-count').textContent='';
|
||||||
|
if(pyBridge)pyBridge.read_sheet(filePath,name);
|
||||||
|
}
|
||||||
|
function renderSheetData(data){
|
||||||
|
const wrap=document.getElementById('tbl-wrap');
|
||||||
|
if(data.error){wrap.innerHTML='<div class="preview-empty">Error: '+escapeHtml(data.error)+'</div>';return;}
|
||||||
|
document.getElementById('preview-count').textContent=data.total+' row(s)'+(data.more?' — first '+data.rows.length+' shown':'');
|
||||||
|
if(!data.header.length){wrap.innerHTML='<div class="preview-empty">Sheet is empty</div>';return;}
|
||||||
|
const secret=data.header.map(x=>/pass(word)?|pwd|secret|pin\b/i.test(String(x)));
|
||||||
|
let h='<table class="data-tbl"><thead><tr>';
|
||||||
|
data.header.forEach(x=>h+='<th>'+escapeHtml(x)+'</th>');
|
||||||
|
h+='</tr></thead><tbody>';
|
||||||
|
data.rows.forEach(row=>{
|
||||||
|
h+='<tr>';
|
||||||
|
row.forEach((cell,ci)=>{
|
||||||
|
if(secret[ci] && String(cell)) h+='<td class="mask">'+'•'.repeat(Math.min(String(cell).length,10))+'</td>';
|
||||||
|
else h+='<td>'+escapeHtml(cell)+'</td>';
|
||||||
|
});
|
||||||
|
h+='</tr>';
|
||||||
|
});
|
||||||
|
wrap.innerHTML=h+'</tbody></table>';
|
||||||
|
}
|
||||||
|
function removeFile(){
|
||||||
|
if(running) return;
|
||||||
|
fileReady=!CFG.input; filePath='';
|
||||||
|
document.getElementById('dz').style.display='';
|
||||||
|
document.getElementById('file-row').classList.add('hidden');
|
||||||
|
document.getElementById('fi').value='';
|
||||||
|
document.getElementById('sheet-tabs').classList.add('hidden');
|
||||||
|
document.getElementById('sheet-tabs').innerHTML='';
|
||||||
|
document.getElementById('sheet-preview').classList.remove('show');
|
||||||
|
document.getElementById('clear-bar').classList.remove('show');
|
||||||
|
document.getElementById('tbl-wrap').innerHTML='';
|
||||||
|
updateRunBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- run ---- */
|
||||||
|
function updateRunBtn(){
|
||||||
|
const btn=document.getElementById('top-run-btn'),lbl=document.getElementById('top-run-lbl');
|
||||||
|
if(running){btn.className='top-run-btn going';lbl.textContent='Running...';}
|
||||||
|
else if(fileReady){btn.className='top-run-btn on';lbl.textContent='Trigger automation';}
|
||||||
|
else{btn.className='top-run-btn off';lbl.textContent='Upload a file to continue';}
|
||||||
|
}
|
||||||
|
function doRun(){
|
||||||
|
if(!fileReady||running)return;
|
||||||
|
running=true; maxPct=0;
|
||||||
|
document.getElementById('prog-wrap').classList.add('show');
|
||||||
|
document.getElementById('log').innerHTML='';
|
||||||
|
document.getElementById('prog-fill').className='prog-fill';
|
||||||
|
setProgress(0,'Starting...');
|
||||||
|
resetCheckpoints(); startRunTimer(); updateRunBtn();
|
||||||
|
updateStatus('running','Running automation...');
|
||||||
|
if(pyBridge)pyBridge.start_automation(filePath);
|
||||||
|
else addLog('No Python bridge — page preview only','warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- elapsed timers ---- */
|
||||||
|
let runTimer=null, runStart=0;
|
||||||
|
function fmtElapsed(sec){ if(sec<60) return sec+'s'; return Math.floor(sec/60)+'m '+(sec%60)+'s ('+sec+'s)'; }
|
||||||
|
function fmtClock(sec){ return String(Math.floor(sec/60)).padStart(2,'0')+':'+String(sec%60).padStart(2,'0'); }
|
||||||
|
function tickRunTimer(){
|
||||||
|
const secs=Math.round((Date.now()-runStart)/1000);
|
||||||
|
document.getElementById('prog-time').textContent='⏱ '+fmtElapsed(secs);
|
||||||
|
document.getElementById('cp-elapsed').textContent='⏱ '+fmtClock(secs);
|
||||||
|
tickCpTimer();
|
||||||
|
}
|
||||||
|
function startRunTimer(){ runStart=Date.now(); if(runTimer) clearInterval(runTimer); tickRunTimer(); runTimer=setInterval(tickRunTimer,1000); }
|
||||||
|
function stopRunTimer(prefix){
|
||||||
|
if(runTimer){ clearInterval(runTimer); runTimer=null; }
|
||||||
|
if(runStart) document.getElementById('prog-time').textContent=prefix+fmtElapsed(Math.round((Date.now()-runStart)/1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- checkpoints: one list drives the boxes, the step list and the progress % (ADR-0001) ---- */
|
||||||
|
let cpActive=0, cpActiveStart=0, maxPct=0;
|
||||||
|
const cpStates=()=>CPS.map((_,i)=>{ const b=cpBox(i+1); return b?b.className.replace('cp-box ',''):'pending'; });
|
||||||
|
function fmtMax(sec){ if(!sec) return 'no limit'; return 'max '+(sec%60===0 ? (sec/60)+' min' : sec+'s'); }
|
||||||
|
function renderCpTrack(){
|
||||||
|
document.getElementById('cp-track').innerHTML=CPS.map((d,i)=>
|
||||||
|
'<div class="cp-box pending" id="cpbox-'+(i+1)+'">'+
|
||||||
|
'<div class="cp-num">'+(i+1)+'</div>'+
|
||||||
|
'<div class="cp-name">'+escapeHtml(d.name)+'</div>'+
|
||||||
|
'<div class="cp-meta" id="cpmeta-'+(i+1)+'">'+fmtMax(d.max)+'</div>'+
|
||||||
|
'</div>').join('');
|
||||||
|
}
|
||||||
|
function resetCheckpoints(){ cpActive=0; cpActiveStart=0; renderCpTrack(); renderSteps(); document.getElementById('cp-elapsed').textContent=''; }
|
||||||
|
function cpBox(n){ return document.getElementById('cpbox-'+n); }
|
||||||
|
function setCpMeta(n,txt){ const m=document.getElementById('cpmeta-'+n); if(m) m.textContent=txt; }
|
||||||
|
function cpState(kind,n){
|
||||||
|
if(!n||n>CPS.length) return;
|
||||||
|
if(kind==='active'){
|
||||||
|
// below n = done, n = active, above n = pending (a loop back to an earlier checkpoint resets the later ones)
|
||||||
|
CPS.forEach((d,i)=>{
|
||||||
|
const k=i+1, b=cpBox(k);
|
||||||
|
if(k<n){ b.className='cp-box done'; setCpMeta(k,'✓ done'); }
|
||||||
|
else if(k===n){ b.className='cp-box active'; }
|
||||||
|
else { b.className='cp-box pending'; setCpMeta(k,fmtMax(d.max)); }
|
||||||
|
});
|
||||||
|
cpActive=n; cpActiveStart=Date.now(); tickCpTimer();
|
||||||
|
setProgress(Math.round(100*(n-1)/CPS.length), CPS[n-1].name+'...');
|
||||||
|
} else if(kind==='done'){
|
||||||
|
cpBox(n).className='cp-box done'; setCpMeta(n,'✓ done');
|
||||||
|
if(cpActive===n) cpActive=0;
|
||||||
|
setProgress(Math.round(100*n/CPS.length), CPS[n-1].name+' done');
|
||||||
|
} else if(kind==='stuck'){
|
||||||
|
cpBox(n).className='cp-box stuck'; setCpMeta(n,'⚠ stuck — stopped'); cpActive=0;
|
||||||
|
}
|
||||||
|
renderSteps();
|
||||||
|
}
|
||||||
|
function tickCpTimer(){
|
||||||
|
if(!cpActive||!cpActiveStart) return;
|
||||||
|
const secs=Math.round((Date.now()-cpActiveStart)/1000), def=CPS[cpActive-1];
|
||||||
|
setCpMeta(cpActive, def.max ? fmtClock(secs)+' / '+fmtClock(def.max) : fmtClock(secs));
|
||||||
|
}
|
||||||
|
function renderSteps(){
|
||||||
|
const st=cpStates();
|
||||||
|
document.getElementById('steps').innerHTML=CPS.map((d,i)=>{
|
||||||
|
const s=st[i], dot=s==='done'?'✓':s==='stuck'?'!':(i+1);
|
||||||
|
return `<div class="step ${s}"><div class="step-dot">${dot}</div><div class="step-lbl">${escapeHtml(d.name)}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
function setProgress(pct,label){
|
||||||
|
maxPct=Math.max(maxPct,pct); // the bar only moves forward, even when a loop restarts a checkpoint
|
||||||
|
document.getElementById('prog-fill').style.width=maxPct+'%';
|
||||||
|
document.getElementById('prog-pct').textContent=maxPct+'%';
|
||||||
|
if(label)document.getElementById('prog-lbl').textContent=label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- log: no timestamps on screen, by design; the raw log file has everything ---- */
|
||||||
|
function addLog(msg,type){
|
||||||
|
const box=document.getElementById('log'), last=box.lastElementChild;
|
||||||
|
if(last && last.textContent===msg) return; // drop consecutive duplicates
|
||||||
|
const d=document.createElement('div');
|
||||||
|
d.className='log-line '+(type||'');
|
||||||
|
d.textContent=msg;
|
||||||
|
box.appendChild(d); box.scrollTop=box.scrollHeight;
|
||||||
|
}
|
||||||
|
function updateStatus(state,msg){
|
||||||
|
document.getElementById('sys-dot').className='dot '+state;
|
||||||
|
document.getElementById('sys-txt').textContent=msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- endings: green completed / amber partial / red failed (ADR-0003, R11) ---- */
|
||||||
|
function onStatus(state,msg){
|
||||||
|
if(state==='running'){ updateStatus('running',msg||'Running automation...'); return; }
|
||||||
|
running=false; cpActive=0;
|
||||||
|
const fill=document.getElementById('prog-fill');
|
||||||
|
if(state==='done'){
|
||||||
|
CPS.forEach((_,i)=>{ cpBox(i+1).className='cp-box done'; setCpMeta(i+1,'✓ done'); });
|
||||||
|
renderSteps(); maxPct=0; setProgress(100,'Completed');
|
||||||
|
stopRunTimer('✓ Completed in '); updateStatus('done',msg||'Completed');
|
||||||
|
} else if(state==='partial'){
|
||||||
|
fill.className='prog-fill partial'; maxPct=0; setProgress(100,msg);
|
||||||
|
stopRunTimer('⚠ Finished in '); updateStatus('partial',msg);
|
||||||
|
} else {
|
||||||
|
fill.className='prog-fill failed'; setProgress(maxPct,msg);
|
||||||
|
stopRunTimer('✕ Failed after '); updateStatus('error',msg); addLog(msg,'error');
|
||||||
|
}
|
||||||
|
updateRunBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(window.qt && window.QWebChannel){
|
||||||
|
new QWebChannel(qt.webChannelTransport, function(channel){ pyBridge = channel.objects.pyBridge; });
|
||||||
|
}
|
||||||
|
initRunTab();
|
||||||
|
(function(){ let on=false; try{ on=localStorage.getItem(CFG.darkKey)==='1'; }catch(e){} if(on) applyDark(true, true); })();
|
||||||
|
/* RUN-TAB:JS:END */
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Run tab: PyQt6 window + bridge for the page in run_tab.html.
|
||||||
|
|
||||||
|
python run_tab/run_tab.py open the Run tab
|
||||||
|
python run_tab/run_tab.py --selfcheck run the fake bot through every ending, save screenshots
|
||||||
|
|
||||||
|
The same file is also the bot's process: the window starts `run_tab.py --run-bot ...`
|
||||||
|
(or `<app>.exe --run-bot ...` when frozen). That branch loads no Qt (ADR-0002).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---- CONFIG: the rpa-run-tab skill fills these in ------------------------------------
|
||||||
|
TITLE = "Run automation"
|
||||||
|
SUBTITLE = "Upload the input Excel and start the bot"
|
||||||
|
BOT = "bot.py" # "path/to/bot.py" (from the project root) or "package.module:function"
|
||||||
|
HAS_INPUT = True # False hides the input card; Trigger is ready straight away
|
||||||
|
INPUT_HINT = "Excel workbook with the input sheets"
|
||||||
|
CHECKPOINTS = [ # (name, seconds before it counts as stuck; 0 = no limit)
|
||||||
|
("Sign in", 300),
|
||||||
|
("Process items", 0),
|
||||||
|
("Finish up", 300),
|
||||||
|
]
|
||||||
|
SAMPLE_SHEETS = {} # {"Sheet name": ["Column", ...]}; empty hides the Sample input button
|
||||||
|
KILL_ALSO = [] # extra .exe names to kill with the bot, e.g. ["chromedriver.exe"]
|
||||||
|
APP_ID = "RunTab" # QSettings name and the page's dark-mode key
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FROZEN = getattr(sys, "frozen", False)
|
||||||
|
HERE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||||
|
ROOT = Path(sys.executable).parent if FROZEN else Path(__file__).resolve().parent.parent
|
||||||
|
LOGS = ROOT / "logs"
|
||||||
|
|
||||||
|
|
||||||
|
def _arg(flag, default=""):
|
||||||
|
return sys.argv[sys.argv.index(flag) + 1] if flag in sys.argv else default
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_parent(ppid):
|
||||||
|
"""Exit the bot the moment the window's process dies, so it never runs orphaned."""
|
||||||
|
try:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
import ctypes
|
||||||
|
k32 = ctypes.windll.kernel32
|
||||||
|
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
|
||||||
|
if h:
|
||||||
|
k32.WaitForSingleObject(h, 0xFFFFFFFF)
|
||||||
|
else:
|
||||||
|
import time
|
||||||
|
while os.getppid() == ppid:
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
return # can't watch: keep running
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_bot_child():
|
||||||
|
"""The bot's process: all output goes to --log, the bot runs, its result becomes the summary line."""
|
||||||
|
log = open(_arg("--log"), "a", encoding="utf-8", buffering=1)
|
||||||
|
sys.stdout = sys.stderr = log
|
||||||
|
threading.Thread(target=_watch_parent, args=(int(_arg("--parent-pid", "0")),), daemon=True).start()
|
||||||
|
excel, bot = _arg("--excel"), _arg("--bot", BOT)
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
if bot.endswith(".py"):
|
||||||
|
import runpy
|
||||||
|
sys.argv = [bot] + ([excel] if excel else [])
|
||||||
|
sys.path.insert(0, str((ROOT / bot).parent))
|
||||||
|
runpy.run_path(str(ROOT / bot), run_name="__main__") # a plain script prints its own summary
|
||||||
|
else:
|
||||||
|
import importlib
|
||||||
|
mod, func = bot.split(":")
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
result = getattr(importlib.import_module(mod), func)(*([excel] if excel else []))
|
||||||
|
if isinstance(result, dict):
|
||||||
|
print("__RUN_SUMMARY__" + json.dumps(result))
|
||||||
|
log.flush()
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if "--run-bot" in sys.argv:
|
||||||
|
run_bot_child()
|
||||||
|
|
||||||
|
# ---- everything below runs only in the window's process --------------------------------
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
from PyQt6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot # noqa: E402
|
||||||
|
from PyQt6.QtWebChannel import QWebChannel # noqa: E402
|
||||||
|
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox # noqa: E402
|
||||||
|
|
||||||
|
import runner # noqa: E402
|
||||||
|
|
||||||
|
SEARCH_DIRS = [ROOT, ROOT / "input", Path.home() / "Downloads", Path.home() / "Desktop", Path.home() / "Documents"]
|
||||||
|
|
||||||
|
|
||||||
|
def page_html():
|
||||||
|
cfg = {"title": TITLE, "sub": SUBTITLE, "input": HAS_INPUT, "inputHint": INPUT_HINT,
|
||||||
|
"sample": bool(SAMPLE_SHEETS), "darkKey": APP_ID + "_dark",
|
||||||
|
"checkpoints": [{"name": n, "max": m} for n, m in CHECKPOINTS]}
|
||||||
|
return (HERE / "run_tab.html").read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg))
|
||||||
|
|
||||||
|
|
||||||
|
def bot_cmd(excel, log, bot=BOT):
|
||||||
|
me = [sys.executable] if FROZEN else [sys.executable, str(Path(__file__).resolve())]
|
||||||
|
return me + ["--run-bot", "--bot", bot, "--excel", excel or "", "--log", str(log),
|
||||||
|
"--parent-pid", str(os.getpid())]
|
||||||
|
|
||||||
|
|
||||||
|
def build_sample(path, sheets):
|
||||||
|
"""Headers-only sample workbook with one fake row. Never real data (R13)."""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
wb = Workbook()
|
||||||
|
wb.remove(wb.active)
|
||||||
|
for name, cols in sheets.items():
|
||||||
|
ws = wb.create_sheet(name[:31])
|
||||||
|
ws.append(cols)
|
||||||
|
ws.append([f"Example {c}".strip() for c in cols])
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_sheet_rows(path, sheet, cap=200):
|
||||||
|
"""(header, rows, more): first `cap` non-empty rows as text, Excel date serials as m/d/yyyy."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
def cell(c):
|
||||||
|
if c is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(c, datetime):
|
||||||
|
c = c.date()
|
||||||
|
if isinstance(c, date):
|
||||||
|
return f"{c.month}/{c.day}/{c.year}"
|
||||||
|
if isinstance(c, (int, float)) and not isinstance(c, bool) and 36526 <= c <= 73050: # 2000..2099
|
||||||
|
d = date(1899, 12, 30) + timedelta(days=int(c))
|
||||||
|
return f"{d.month}/{d.day}/{d.year}"
|
||||||
|
return str(c)
|
||||||
|
|
||||||
|
wb = load_workbook(path, read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
ws = wb[sheet] if sheet in wb.sheetnames else wb.active
|
||||||
|
rows, more = [], False
|
||||||
|
for r in ws.iter_rows(values_only=True):
|
||||||
|
cells = [cell(c) for c in r]
|
||||||
|
if not any(x.strip() for x in cells):
|
||||||
|
continue
|
||||||
|
if len(rows) >= cap:
|
||||||
|
more = True
|
||||||
|
break
|
||||||
|
rows.append(cells)
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
return (rows[0] if rows else []), rows[1:], more
|
||||||
|
|
||||||
|
|
||||||
|
class Bridge(QObject):
|
||||||
|
"""Exposed to the page as pyBridge."""
|
||||||
|
js_message = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, window, bot=BOT, checkpoints=CHECKPOINTS):
|
||||||
|
super().__init__()
|
||||||
|
self.window, self.bot, self.checkpoints = window, bot, checkpoints
|
||||||
|
self.run = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self):
|
||||||
|
return self.run is not None and self.run.thread.is_alive()
|
||||||
|
|
||||||
|
def send(self, event, data):
|
||||||
|
self.js_message.emit(json.dumps({"event": event, "data": data}))
|
||||||
|
|
||||||
|
def _file(self, path):
|
||||||
|
sheets = []
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
wb = load_workbook(path, read_only=True)
|
||||||
|
sheets = list(wb.sheetnames)
|
||||||
|
wb.close()
|
||||||
|
except Exception as e:
|
||||||
|
self.send("log", {"msg": f"Could not open the workbook: {e}", "type": "warn"})
|
||||||
|
self.send("file_selected", {"path": path, "name": os.path.basename(path), "sheets": sheets})
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def browse_file(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self.window, "Select input Excel file", str(Path.home()),
|
||||||
|
"Excel files (*.xlsx *.xlsm *.xls)")
|
||||||
|
if path:
|
||||||
|
self._file(path)
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def process_dropped_path(self, path):
|
||||||
|
# The page only knows the dropped file's name, so look for it in the usual folders.
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
path = next((str(d / path) for d in SEARCH_DIRS if (d / path).is_file()), "")
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
self._file(path)
|
||||||
|
else:
|
||||||
|
self.send("file_selected", {"path": "", "name": os.path.basename(path) or "file", "sheets": []})
|
||||||
|
|
||||||
|
@pyqtSlot(str, str)
|
||||||
|
def read_sheet(self, path, sheet):
|
||||||
|
try:
|
||||||
|
header, rows, more = read_sheet_rows(path, sheet)
|
||||||
|
self.send("sheet_data", {"sheet": sheet, "header": header, "rows": rows,
|
||||||
|
"total": len(rows), "more": more})
|
||||||
|
except Exception as e:
|
||||||
|
self.send("sheet_data", {"sheet": sheet, "error": str(e), "header": [], "rows": [], "total": 0})
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def download_sample_input(self):
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self.window, "Save sample input",
|
||||||
|
str(Path.home() / f"{APP_ID}_sample_input.xlsx"),
|
||||||
|
"Excel files (*.xlsx)")
|
||||||
|
if path:
|
||||||
|
build_sample(path, SAMPLE_SHEETS)
|
||||||
|
self.send("log", {"msg": f"Sample saved: {path}", "type": "success"})
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def start_automation(self, excel):
|
||||||
|
if self.running: # double-run guard
|
||||||
|
return
|
||||||
|
LOGS.mkdir(exist_ok=True)
|
||||||
|
log = LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||||
|
self.run = runner.Run(bot_cmd(excel if HAS_INPUT else "", log, self.bot), log, self.checkpoints,
|
||||||
|
self.send, KILL_ALSO, cwd=str(ROOT)).start()
|
||||||
|
|
||||||
|
@pyqtSlot(bool)
|
||||||
|
def set_dark(self, on):
|
||||||
|
QSettings("RPA", APP_ID).setValue("dark", on)
|
||||||
|
|
||||||
|
|
||||||
|
class RunTabWindow(QMainWindow):
|
||||||
|
def __init__(self, bot=BOT, checkpoints=CHECKPOINTS):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle(TITLE)
|
||||||
|
self.setMinimumSize(940, 620)
|
||||||
|
self.resize(1320, 820)
|
||||||
|
self.view = QWebEngineView()
|
||||||
|
self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # no Reload mid-run
|
||||||
|
self.bridge = Bridge(self, bot, checkpoints)
|
||||||
|
self.channel = QWebChannel()
|
||||||
|
self.channel.registerObject("pyBridge", self.bridge)
|
||||||
|
self.view.page().setWebChannel(self.channel)
|
||||||
|
self.bridge.js_message.connect(self._push)
|
||||||
|
self.view.loadFinished.connect(self._loaded)
|
||||||
|
self.view.setHtml(page_html(), QUrl("qrc:///"))
|
||||||
|
self.setCentralWidget(self.view)
|
||||||
|
|
||||||
|
def _push(self, s):
|
||||||
|
self.view.page().runJavaScript(f"window.__pyMsg && window.__pyMsg({json.dumps(s)})")
|
||||||
|
|
||||||
|
def _loaded(self, ok):
|
||||||
|
if ok:
|
||||||
|
on = QSettings("RPA", APP_ID).value("dark", False, type=bool)
|
||||||
|
self.bridge.send("apply_dark", {"on": on})
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self.bridge.running:
|
||||||
|
ans = QMessageBox.question(
|
||||||
|
self, f"Close {TITLE}?",
|
||||||
|
"The automation is still running.\n\nClosing will STOP it now. Close anyway?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
|
||||||
|
if ans != QMessageBox.StandardButton.Yes:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
self.bridge.run.kill()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def selfcheck():
|
||||||
|
"""Fake bot through every ending with the real runner, then window screenshots of a green
|
||||||
|
run (light), an amber run (dark) and a stuck run (light), saved to logs/ (R14).
|
||||||
|
Never runs the real bot."""
|
||||||
|
import time
|
||||||
|
fake = str((HERE / "fake_bot.py").relative_to(ROOT))
|
||||||
|
os.environ["FAKE_CPS"] = str(len(CHECKPOINTS))
|
||||||
|
quick = [(n, 2 if i == 1 else 0) for i, (n, _) in enumerate(CHECKPOINTS)] # checkpoint 2 sticks after 2 s
|
||||||
|
want = {"complete": "done", "partial": "partial", "stuck": "error", "crash": "error"}
|
||||||
|
LOGS.mkdir(exist_ok=True)
|
||||||
|
for mode, state in want.items():
|
||||||
|
os.environ["FAKE_MODE"] = mode
|
||||||
|
events = []
|
||||||
|
log = LOGS / f"selfcheck_{mode}.log"
|
||||||
|
r = runner.Run(bot_cmd("", log, fake), log, quick, lambda e, d: events.append((e, d)), cwd=str(ROOT)).start()
|
||||||
|
r.thread.join(60)
|
||||||
|
end = events[-1][1] if events and events[-1][0] == "status" else {}
|
||||||
|
assert end.get("state") == state, f"{mode}: expected {state}, got {end} (log: {log})"
|
||||||
|
if mode == "stuck":
|
||||||
|
assert ("cpstate", {"kind": "stuck", "cp": 2}) in events, f"stuck box missing: {events}"
|
||||||
|
print(f"ok {mode:<8} -> {end['state']}: {end['msg']}")
|
||||||
|
|
||||||
|
# The window: one green run in light, one amber run in dark, saved as PNGs to look at.
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
w = RunTabWindow(fake, quick)
|
||||||
|
w.resize(1280, 900)
|
||||||
|
w.show()
|
||||||
|
shots = [("complete", False), ("partial", True), ("stuck", False)]
|
||||||
|
|
||||||
|
def wait_for_end(then):
|
||||||
|
def poll():
|
||||||
|
if w.bridge.running or w.bridge.run is None:
|
||||||
|
QTimer.singleShot(200, poll)
|
||||||
|
else:
|
||||||
|
QTimer.singleShot(800, then)
|
||||||
|
poll()
|
||||||
|
|
||||||
|
def next_shot():
|
||||||
|
if not shots:
|
||||||
|
app.quit()
|
||||||
|
return
|
||||||
|
mode, dark = shots.pop(0)
|
||||||
|
os.environ["FAKE_MODE"] = mode
|
||||||
|
w.bridge.run = None
|
||||||
|
w.bridge.send("apply_dark", {"on": dark})
|
||||||
|
w.view.page().runJavaScript("fileReady=true; filePath=''; doRun();")
|
||||||
|
|
||||||
|
def save():
|
||||||
|
out = LOGS / f"selfcheck_{mode}_{'dark' if dark else 'light'}.png"
|
||||||
|
w.view.grab().save(str(out))
|
||||||
|
print(f"ok screenshot {out}")
|
||||||
|
next_shot()
|
||||||
|
|
||||||
|
def scrolled_save():
|
||||||
|
w.view.page().runJavaScript("document.getElementById('prog-wrap').scrollIntoView()")
|
||||||
|
QTimer.singleShot(400, save)
|
||||||
|
QTimer.singleShot(300, lambda: wait_for_end(scrolled_save))
|
||||||
|
|
||||||
|
w.view.loadFinished.connect(lambda ok: QTimer.singleShot(500, next_shot))
|
||||||
|
QTimer.singleShot(90_000, app.quit) # never hang
|
||||||
|
t = time.monotonic()
|
||||||
|
app.exec()
|
||||||
|
print(f"selfcheck done in {time.monotonic() - t:.0f}s")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--selfcheck" in sys.argv:
|
||||||
|
selfcheck()
|
||||||
|
sys.exit(0)
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName(TITLE)
|
||||||
|
w = RunTabWindow()
|
||||||
|
w.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Runs the bot in its own process and turns its log file into Run tab events.
|
||||||
|
|
||||||
|
No Qt in here (ADR-0002): the bot runs as a separate process with no Qt, writes every line
|
||||||
|
to a log file, and this module reads that file every 0.2 s. It does not read a stdout pipe,
|
||||||
|
because a windowed PyInstaller child's pipe is block-buffered and lines arrive late.
|
||||||
|
|
||||||
|
Bot lines it understands:
|
||||||
|
[CPSTATE] active N / [CPSTATE] done N checkpoint progress (ADR-0001)
|
||||||
|
[UI] text, [UI:ok] / [UI:warn] / [UI:error] text lines the operator sees
|
||||||
|
Error / exception lines always shown, even untagged
|
||||||
|
__RUN_SUMMARY__{"total":n,"ok":n,"failed":[[item, reason], ...]} the outcome
|
||||||
|
Everything else stays in the log file only.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
SUMMARY = "__RUN_SUMMARY__"
|
||||||
|
_CP = re.compile(r"^\[CPSTATE\]\s+(active|done)\s+(\d+)")
|
||||||
|
_UI = re.compile(r"^\[UI(?::(ok|warn|error))?\]\s?(.*)$")
|
||||||
|
# Untagged lines that still reach the screen: the last line of a traceback
|
||||||
|
# ("ValueError: ...") and lines starting with ERROR.
|
||||||
|
# ponytail: a regex guess, so add a [UI:error] line to the bot when an error shows badly.
|
||||||
|
_ERR = re.compile(r"^([A-Za-z_][\w.]*(Error|Exception)\b|ERROR\b)")
|
||||||
|
_UI_TYPE = {None: "info", "ok": "success", "warn": "warn", "error": "error"}
|
||||||
|
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(line):
|
||||||
|
"""One bot line -> (event, data), or None when it stays off screen."""
|
||||||
|
m = _CP.match(line)
|
||||||
|
if m:
|
||||||
|
return "cpstate", {"kind": m[1], "cp": int(m[2])}
|
||||||
|
m = _UI.match(line)
|
||||||
|
if m:
|
||||||
|
return "log", {"msg": m[2], "type": _UI_TYPE[m[1]]}
|
||||||
|
if _ERR.match(line):
|
||||||
|
return "log", {"msg": line, "type": "error"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def outcome(summary, stuck_name=None):
|
||||||
|
"""(state, message) for the page: done (green), partial (amber) or error (red)."""
|
||||||
|
if stuck_name:
|
||||||
|
return "error", f"Stuck at {stuck_name}, run again"
|
||||||
|
if summary is None:
|
||||||
|
return "error", "Failed: the bot stopped without a summary (see the run log)"
|
||||||
|
total = int(summary.get("total") or 0)
|
||||||
|
failed = summary.get("failed") or []
|
||||||
|
if failed and len(failed) >= total:
|
||||||
|
return "error", f"Failed: all {len(failed)} item(s) failed"
|
||||||
|
if failed:
|
||||||
|
return "partial", f"Finished: {len(failed)} of {total} failed"
|
||||||
|
return "done", f"Completed: {total} item(s)" if total else "Completed"
|
||||||
|
|
||||||
|
|
||||||
|
def kill_tree(proc, kill_also=()):
|
||||||
|
"""Kill the bot and everything it started, then any extra .exe names (e.g. a driver)."""
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||||
|
capture_output=True, timeout=15, creationflags=_NO_WINDOW)
|
||||||
|
else:
|
||||||
|
proc.kill() # ponytail: direct child only off Windows; use a process group if bots spawn children there
|
||||||
|
for image in kill_also:
|
||||||
|
subprocess.run(["taskkill", "/F", "/IM", image],
|
||||||
|
capture_output=True, timeout=10, creationflags=_NO_WINDOW)
|
||||||
|
|
||||||
|
|
||||||
|
class Run:
|
||||||
|
"""One bot run. emit(event, data) is called from the run thread."""
|
||||||
|
|
||||||
|
def __init__(self, cmd, log_path, checkpoints, emit, kill_also=(), cwd=None):
|
||||||
|
self.cmd, self.log_path, self.emit = cmd, str(log_path), emit
|
||||||
|
self.checkpoints = checkpoints # [(name, max_seconds)], 0 = no limit
|
||||||
|
self.kill_also, self.cwd = kill_also, cwd
|
||||||
|
self.proc = None
|
||||||
|
self.summary = None
|
||||||
|
self.active, self.since, self.stuck = 0, 0.0, None
|
||||||
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
kill_tree(self.proc, self.kill_also)
|
||||||
|
|
||||||
|
def _line(self, line):
|
||||||
|
if line.startswith(SUMMARY):
|
||||||
|
try:
|
||||||
|
self.summary = json.loads(line[len(SUMMARY):])
|
||||||
|
except ValueError:
|
||||||
|
self.emit("log", {"msg": "Could not read the bot's summary line", "type": "warn"})
|
||||||
|
return
|
||||||
|
ev = parse(line)
|
||||||
|
if not ev:
|
||||||
|
return
|
||||||
|
if ev[0] == "cpstate":
|
||||||
|
kind, n = ev[1]["kind"], ev[1]["cp"]
|
||||||
|
if kind == "active":
|
||||||
|
self.active, self.since = n, time.monotonic()
|
||||||
|
elif n == self.active:
|
||||||
|
self.active = 0
|
||||||
|
self.emit(*ev)
|
||||||
|
|
||||||
|
def _check_stuck(self):
|
||||||
|
if not self.active or self.active > len(self.checkpoints):
|
||||||
|
return False
|
||||||
|
name, limit = self.checkpoints[self.active - 1]
|
||||||
|
if limit and time.monotonic() - self.since > limit:
|
||||||
|
self.stuck = name
|
||||||
|
self.emit("cpstate", {"kind": "stuck", "cp": self.active})
|
||||||
|
self.emit("log", {"msg": f"{name} took longer than {limit}s, stopping the run", "type": "error"})
|
||||||
|
self.kill()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
self.emit("status", {"state": "running", "msg": "Running automation..."})
|
||||||
|
open(self.log_path, "w", encoding="utf-8").close()
|
||||||
|
try:
|
||||||
|
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
cwd=self.cwd, creationflags=_NO_WINDOW)
|
||||||
|
except OSError as e:
|
||||||
|
self.emit("status", {"state": "error", "msg": f"Could not start the bot: {e}"})
|
||||||
|
return
|
||||||
|
buf = ""
|
||||||
|
with open(self.log_path, encoding="utf-8", errors="replace") as f:
|
||||||
|
while True:
|
||||||
|
exited = self.proc.poll() is not None # check before reading, so the last lines are never lost
|
||||||
|
chunk = f.read()
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
*lines, buf = buf.split("\n")
|
||||||
|
for ln in lines:
|
||||||
|
self._line(ln.rstrip("\r"))
|
||||||
|
elif exited or self.stuck:
|
||||||
|
break
|
||||||
|
elif self._check_stuck():
|
||||||
|
self.proc.wait(timeout=30)
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if buf.strip():
|
||||||
|
self._line(buf.rstrip("\r"))
|
||||||
|
for item, reason in (self.summary or {}).get("failed") or []:
|
||||||
|
self.emit("log", {"msg": f"✕ {item}: {reason}", "type": "error"})
|
||||||
|
state, msg = outcome(None if self.stuck else self.summary, self.stuck)
|
||||||
|
self.emit("status", {"state": state, "msg": msg})
|
||||||
@@ -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
|
`bot_runner.py`, saves its events to `evals/results/<fixture>.run.jsonl`, and
|
||||||
replays them in the page check.
|
replays them in the page check.
|
||||||
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
- `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