No-AI skill tracer: SKILL.md, theme presets, sample data, Seam 1 page check

- rpa-dashboard-no-ai/SKILL.md: trigger description and the workflow in spec
  order (scan, report/keep-or-restyle, colours, stack, sections, build, wire,
  verify).
- templates/themes.md: Tebra Sky and Superbill Ember, light and dark blocks.
- templates/palette.py: derives a custom palette from one brand hex and
  checks text contrast in light and dark.
- dashboard.html: built-in sample data so a page with no bot works on first
  open; cleared batches in the donut; component tokens so Ember is a
  token-only swap; accessible names for KPI cards, nav, steps, log, search
  and the dark toggle.
- bridge.py: placeholders inside string literals, so it passes the lint hook
  unfilled.
- tests/page_check: Seam 1 harness (fake Qt bridge, light/dark/narrow).
- tests/evals: Seam 2 runner and fixture a-empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 13:59:26 +05:30
1 parent 49f8f49b65
commit 7f2ddecffb
13 files changed
+865 -57

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Tests
Run everything from the repo root:
```
uv run --with pytest --with playwright pytest tests
```
- `test_*.py`: assert-based self-checks for template logic (lint of unfilled Python
templates, the custom palette, presets in step with the template).
- `page_check/`: **Seam 1.** Loads a dashboard page in headless Chromium with no
backend, pushes contract envelopes through `window.__pyMsg`, and asserts on what
is visible. Every case runs in light, dark and narrow (800px) mode.
- By default it checks the no-AI template, filled with test values. To check a
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
- The page sees a fake Qt bridge. Every action the page sends is recorded in
`window.__calls` as `[slot, ...args]`.
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
and `push(event, data)` fixtures from `conftest.py`.
- `evals/`: **Seam 2.** Runs the skills on a fixture project with `claude -p`,
then grades the reply and runs the page check on the page it built:
```
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
```
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
`FIXTURES` in `run_eval.py`.
- `fixtures/<letter>-<name>/`: small test projects for the evals.
+100
View File
@@ -0,0 +1,100 @@
"""Seam 2 skill eval: run the skills on a fixture project with `claude -p`, then grade.
Usage (from the repo root):
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
Copies tests/fixtures/<fixture> to a temp folder, installs every rpa-dashboard-*
skill there as a project skill, and runs `claude -p` with the user's answers in the
prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1 page
check on the page it built, and writes tests/evals/results/<fixture>.json.
Exit code 0 means every check passed.
"""
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REPLY_FORMAT = """
When you're done, end your reply with exactly these two lines:
FRONTEND: <what your scan found, or none>
QUESTIONS: <each question the skill told you to ask, in order, separated by " | ">"""
# Per fixture: the request, what the reply must (not) mention, and checks on the output.
FIXTURES = {
"a-empty": {
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
"work, so here are my answers to anything the skill asks. Colours: custom, my "
"brand colour is #7a3cff. Desktop or web: desktop. Optional sections: none.",
"frontend": r"^\s*(none|no\b)",
"asks": [r"colou?r", r"desktop|web", r"section"],
"never_asks": [r"\bkeep\b|restyle"],
"brand": "#7a3cff",
},
}
def main(fixture):
cfg = FIXTURES[fixture]
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
project = work / "project"
shutil.copytree(ROOT / "tests" / "fixtures" / fixture, project)
for skill in ROOT.glob("rpa-dashboard-*"):
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
run = subprocess.run(
[shutil.which("claude") or "claude", "-p", cfg["prompt"] + REPLY_FORMAT, "--output-format", "json",
"--permission-mode", "acceptEdits", "--allowedTools", "Skill", "Read", "Write", "Edit",
"Glob", "Grep", "Bash(python *)", "PowerShell(python *)"],
cwd=project, capture_output=True, text=True, encoding="utf-8", timeout=1800)
reply = json.loads(run.stdout).get("result", "") if run.stdout.strip().startswith("{") else run.stdout
def line(key):
m = re.search(rf"^\W*{key}\W*:(.*)$", reply, re.M | re.I)
return m[1] if m else ""
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
for pat in cfg["asks"]:
checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I))
for pat in cfg["never_asks"]:
checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I)
pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts]
checks["exactly one page built"] = len(pages) == 1
results = ROOT / "tests" / "evals" / "results"
results.mkdir(exist_ok=True)
if pages:
html = pages[0].read_text(encoding="utf-8")
shutil.copy(pages[0], results / f"{fixture}.dashboard.html")
checks["no placeholders left"] = "{{" not in html
if cfg.get("brand"):
spec = importlib.util.spec_from_file_location("palette", ROOT / "rpa-dashboard-no-ai/templates/palette.py")
palette = importlib.util.module_from_spec(spec)
spec.loader.exec_module(palette)
t = palette.derive(cfg["brand"])
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t)
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
checks["page check passes"] = pc.returncode == 0
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
else:
page_check = ["no page"]
out = {"fixture": fixture, "passed": all(checks.values()), "checks": checks, "page_check": page_check,
"files": sorted(str(p.relative_to(project)) for p in project.rglob("*") if p.is_file() and ".claude" not in p.parts),
"reply": reply}
(results / f"{fixture}.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
for name, ok in checks.items():
print(f"{'PASS' if ok else 'FAIL'} {name}")
print("page check:", *page_check)
print("work dir:", project)
return 0 if out["passed"] else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
+4
View File
@@ -0,0 +1,4 @@
# Fixture (a): empty project
No frontend and no bot. The no-AI skill should report no frontend, ask for colours,
the stack and optional sections, and produce a page that runs on sample data.
+79
View File
@@ -0,0 +1,79 @@
"""Seam 1 page check: load a dashboard page with no backend, push contract envelopes
through window.__pyMsg, and assert on what is visible.
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
"""
import json
import os
import re
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
TEST_FILL = {
"APP_NAME": "Test Bot",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
}
FAKE_BRIDGE = """
window.__calls = [];
window.qt = {webChannelTransport: {}};
window.QWebChannel = function (_transport, ready) {
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => { window.__calls.push([slot, ...args]); }});
ready({objects: {pyBridge}});
};
"""
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
@pytest.fixture(scope="session")
def page_url(tmp_path_factory):
if os.environ.get("PAGE_CHECK_HTML"):
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
out = tmp_path_factory.mktemp("page") / "dashboard.html"
out.write_text(html, encoding="utf-8")
return out.as_uri()
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
@pytest.fixture(params=list(MODES))
def page(request, browser, page_url):
width, height, dark = MODES[request.param]
ctx = browser.new_context(viewport={"width": width, "height": height})
ctx.add_init_script(FAKE_BRIDGE)
pg = ctx.new_page()
pg.set_default_timeout(5000)
errors = []
pg.on("pageerror", lambda e: errors.append(e))
pg.goto(page_url)
assert not errors, f"page script errors: {errors}"
if dark:
push_to(pg, "apply_dark", {"on": True})
yield pg
ctx.close()
def push_to(pg, event, data):
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
@pytest.fixture
def push(page):
"""push(event, data): send one contract envelope to the page, as the bot would."""
return lambda event, data: push_to(page, event, data)
+169
View File
@@ -0,0 +1,169 @@
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
import re
from playwright.sync_api import expect
def batch(n, state):
return {"batch_id": n, "date": "2026-10-16", "state": state, "items_total": 10, "items_done": 4}
BATCHES = [batch(1, "completed"), batch(2, "completed"), batch(3, "completed"),
batch(4, "inprogress"), batch(5, "notcompleted"), batch(6, "cleared")]
def kpi(page, label, n):
return expect(page.get_by_role("group", name=label, exact=True)).to_have_text(
re.compile(rf"^\s*{n}\s*{label}\s*$"))
def test_kpi_counts_match_batch_states(page, push):
push("dashboard_data", {"batches": BATCHES})
kpi(page, "Total batches", 6)
kpi(page, "Completed", 3)
kpi(page, "In progress", 1)
kpi(page, "Not completed", 1)
def colour_name(css):
"""'rgb(r, g, b)' -> red | amber | green | blue | grey, so checks survive any palette."""
r, g, b = (int(x) / 255 for x in re.findall(r"\d+", css)[:3])
hi, lo = max(r, g, b), min(r, g, b)
if hi - lo < 0.25 * min(hi + lo, 2 - hi - lo): # low HSL saturation
return "grey"
d = hi - lo
h = 60 * (((g - b) / d) % 6 if hi == r else (b - r) / d + 2 if hi == g else (r - g) / d + 4)
return "red" if h < 20 or h >= 330 else "amber" if h < 70 else "green" if h < 175 else "blue"
def donut_segments(donut):
"""[(colour name, percent of the ring)] read from the rendered conic gradient."""
css = donut.evaluate("el => getComputedStyle(el).backgroundImage")
segs = []
for colour, positions in re.findall(r"(rgba?\([^)]*\))((?:\s+[\d.]+%)+)", css):
pos = [float(p) for p in re.findall(r"[\d.]+", positions)]
name = colour_name(colour)
if segs and segs[-1][0] == name:
segs[-1][2] = pos[-1]
else:
segs.append([name, pos[0], pos[-1]])
return [(name, round(end - start)) for name, start, end in segs if end > start]
def test_donut_and_legend_match_batch_states(page, push):
push("dashboard_data", {"batches": BATCHES})
legend = page.get_by_role("list", name="Batch status")
for row in ["Completed · 3", "In progress · 1", "Not completed · 1", "Cleared · 1"]:
expect(legend.get_by_text(row, exact=True)).to_be_visible()
donut = page.get_by_role("img", name=re.compile("^Batch status"))
expect(donut).to_have_text(re.compile(r"^\s*6\s*batches\s*$"))
assert donut_segments(donut) == [("green", 50), ("amber", 17), ("red", 17), ("grey", 17)]
def open_tab(page, name):
page.get_by_role("navigation").get_by_role("button", name=name, exact=True).click()
def test_step_list_follows_step_label(page, push):
steps = page.evaluate("STEPS")
open_tab(page, "Run")
push("step", {"label": steps[1], "pct": 40})
items = page.get_by_role("list", name="Steps").get_by_role("listitem")
expect(items).to_have_count(len(steps))
expect(items.nth(0)).to_have_text(re.compile(rf"^\s*✓\s*{re.escape(steps[0])}\s*$"))
expect(items.nth(1)).to_have_attribute("aria-current", "step")
for i in range(2, len(steps)):
expect(items.nth(i)).to_have_text(re.compile(rf"^\s*{i + 1}\s*{re.escape(steps[i])}\s*$"))
expect(page.get_by_text("40%", exact=True)).to_be_visible()
push("step", {"label": steps[-1], "pct": 100})
for i in range(len(steps)):
expect(items.nth(i)).to_have_text(re.compile(r"^\s*✓"))
def colour_of(locator, prop="color"):
return colour_name(locator.evaluate(f"el => getComputedStyle(el).{prop}"))
def test_log_lines_show_their_type(page, push):
open_tab(page, "Run")
lines = [("info", "Opening the portal", "blue"), ("success", "Logged in.", "green"),
("warn", "Slow response", "amber"), ("error", "Login failed", "red"),
("error", "<b>raw bot line</b>", "red")]
for kind, msg, _ in lines:
push("log", {"msg": msg, "type": kind})
log = page.get_by_role("log")
for _, msg, colour in lines:
line = log.get_by_text(msg, exact=True)
expect(line).to_be_visible()
assert colour_of(line) == colour, msg
def test_report_search_filters_rows_and_shows_bucket_pill(page, push):
keys = [c["key"] for c in page.evaluate("REPORT_COLUMNS")]
assert "status" in keys, "the report needs a status column for the bucket pill"
cases = [("alpha", "Posted", "success", "green"), ("bravo", "Needs review", "attention", "amber"),
("charlie", "Error", "failed", "red")]
rows = [{**{k: f"{k}-{name}" for k in keys}, "status": status, "bucket": bucket}
for name, status, bucket, _ in cases]
open_tab(page, "Report")
push("report_data", {"rows": rows})
body = page.get_by_role("table").locator("tbody").get_by_role("row")
expect(body).to_have_count(3)
search = page.get_by_role("searchbox", name="Search report")
for name, status, _, colour in cases:
search.fill(name)
expect(body).to_have_count(1)
pill = body.get_by_text(status, exact=True)
expect(pill).to_be_visible()
assert colour_of(pill, "backgroundColor") == colour, status
search.fill("")
expect(body).to_have_count(3)
def is_dark(page):
"""Dark theme = light text on the page."""
rgb = re.findall(r"\d+", page.evaluate("getComputedStyle(document.body).color"))[:3]
return sum(int(x) for x in rgb) / 3 > 128
def test_dark_toggle_flips_theme(page, request):
before = is_dark(page)
assert before == (request.node.callspec.params["page"] == "dark") # apply_dark took effect
toggle = page.get_by_role("button", name="Dark mode", exact=True)
expect(toggle).to_have_attribute("aria-pressed", str(before).lower())
toggle.click()
assert is_dark(page) != before
expect(toggle).to_have_attribute("aria-pressed", str(not before).lower())
assert ["set_dark", not before] in page.evaluate("window.__calls") # host is asked to remember it
def expect_sample_dashboard(page):
legend = page.get_by_role("list", name="Batch status")
expect(legend).to_contain_text("·")
n = sum(int(c) for c in re.findall(r"· (\d+)", legend.inner_text()))
assert n > 0
kpi(page, "Total batches", n)
def test_no_bot_page_runs_on_sample_data(page):
"""No bot yet: the page answers its own data actions, so it works on day one."""
expect_sample_dashboard(page)
open_tab(page, "Report")
rows = page.get_by_role("table").locator("tbody").get_by_role("row")
expect(rows.first).not_to_have_text("No rows yet")
run = page.get_by_role("button", name="▶ Run", exact=True)
run.click()
expect(run).to_be_disabled()
expect(page.get_by_role("list", name="Steps").get_by_role("listitem").last).to_have_text(
re.compile(r"^\s*✓"), timeout=10000)
expect(run).to_be_enabled()
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
pg = browser.new_page()
pg.goto(page_url)
expect_sample_dashboard(pg)
pg.close()
+45
View File
@@ -0,0 +1,45 @@
"""Custom palette: one brand hex in, light + dark accent tokens out, readable in both modes."""
import importlib.util
import subprocess
import sys
from pathlib import Path
PALETTE = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "palette.py"
spec = importlib.util.spec_from_file_location("palette", PALETTE)
palette = importlib.util.module_from_spec(spec)
spec.loader.exec_module(palette)
# Fixed Tebra Sky base colours the derived accents sit on (from dashboard.html).
LIGHT_BG, DARK_BG = "#eaf6fb", "#182a38"
LIGHT_INK, DARK_INK = "#1c2b33", "#e6eef4"
LOG_ERROR = "#f68c82" # dimmest log text colour
def contrast(a, b):
def lum(h):
c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)]
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 test_derived_palette_is_readable_in_light_and_dark():
for brand in ["#3d9bd0", "#ffd400", "#0b1f4d", "#7a3cff", "#e4002b", "#00a651", "#ffffff", "#000000"]:
t = palette.derive(brand)
for mode, bg, ink in [("light", LIGHT_BG, LIGHT_INK), ("dark", DARK_BG, DARK_INK)]:
m = t[mode]
assert contrast("#ffffff", m["--accent"]) >= 3, (brand, mode, "button text")
assert contrast("#ffffff", m["--accent-2"]) >= 3, (brand, mode, "button text, light end")
assert contrast(m["--accent"], bg) >= 3, (brand, mode, "accent text")
assert contrast(ink, m["--accent-soft"]) >= 4.5, (brand, mode, "secondary button text")
assert contrast(LOG_ERROR, m["--accent-dark"]) >= 4.5, (brand, mode, "log text")
def test_brand_kept_when_it_already_passes():
assert palette.derive("#1f6fb2")["light"]["--accent"] == "#1f6fb2"
def test_cli_prints_both_token_blocks_and_contrast_table():
out = subprocess.run([sys.executable, str(PALETTE), "#7a3cff"], capture_output=True, text=True, check=True).stdout
assert "body{" in out and "body.dark{" in out and "PASS" in out and "FAIL" not in out
+24
View File
@@ -0,0 +1,24 @@
"""Python templates must stay valid Python before their placeholders are filled."""
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py"])
def test_template_passes_lint_hook_unfilled(template):
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
capture_output=True, text=True)
assert r.returncode == 0, r.stdout
def test_tebra_sky_preset_is_the_template_default():
templates = ROOT / "rpa-dashboard-no-ai" / "templates"
themes = (templates / "themes.md").read_text(encoding="utf-8")
sky = themes.split("## 1. Tebra Sky")[1].split("```css\n")[1].split("```")[0]
assert sky in (templates / "dashboard.html").read_text(encoding="utf-8")