Optional sections: checkpoint tracker, admin/roles, login page

- dashboard.html: each section sits in OPTIONAL:<name> BEGIN/END blocks in
  the style, markup and script, with sample answers so a page with no bot
  shows it working. Core change: setUser records the role on <body>, so CSS
  hides admin-only parts from operators.
- templates/sections.py keeps the chosen sections and deletes the rest,
  markers included; it stops on a broken marker.
- templates/sections.md: what each section needs from the host (events,
  actions, desktop slot snippets, the role re-check, the login hand-off).
- SKILL.md step 5 asks one multi-select question and runs sections.py.
- Page check: checkpoint states, admin visibility by role and assign/remove,
  login theme and hand-off to auth or the stub. Cases skip on a generated
  page without the section; the template must carry all three. The harness
  signs in when the page has a login screen.
- tests/test_sections.py: no sections leave no trace, and that page still
  passes the page check.
- Eval runner: a-empty-sections variant (all three) and per-section checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 14:31:25 +05:30
1 parent 3eb3fdf00e
commit 9a5b95448d
10 files changed
+536 -10

No files matched your search

+16 -5
View File
@@ -15,7 +15,8 @@ source projects.
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
| `templates/reference/` | Raw Tebra extracts. Use them only to port optional sections. |
| `templates/sections.py`, `templates/sections.md` | Keeps the chosen optional sections in the page and deletes the rest; what each section needs from the host. |
| `templates/reference/` | Raw Tebra extracts, for reference only. The optional sections are already in `dashboard.html`. |
Follow the steps in order. Ask one question at a time, and wait for the answer.
If the user's request already answers a question, don't ask it again.
@@ -59,10 +60,20 @@ Ask only if there is no frontend. If there is one, use its stack.
Always built: the shell (sidebar, top bar, dark toggle, status dot), KPI cards,
the status donut, the run panel and the report table.
Ask which extras to add (choose any, or none): **checkpoint tracker**,
**admin/roles screen**, **login page**. Build only the ones chosen. Unchosen
sections must leave no markup, script or style behind, so delete their
`<!-- OPTIONAL: ... -->` markers.
Ask which extras to add, as one multi-select question (choose any, or none):
- **Checkpoint tracker**: numbered boxes on the Run tab that show each checkpoint as
pending, in progress, done or stuck.
- **Admin/roles screen**: an Admin tab, for admins only, to give people admin or
operator access.
- **Login page**: a sign-in screen in the dashboard's theme. It's UI only and hands
off to the project's own auth.
Build only the chosen ones. In step 6, right after copying the page, run
`python templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
It deletes the unchosen sections completely (markup, script and style), so nothing
is left behind. Never cut the blocks by hand. Then follow `templates/sections.md` for
each kept section: what to fill, which events the bot sends, and which host actions
to answer.
## 6. Build
+1 -1
View File
@@ -18,7 +18,7 @@ All updates use one envelope: `{"event": "<name>", "data": {...}}`.
| `set_user` | `{username, emp_id, role}` |
| `apply_dark` | `{on: bool}` |
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
Optional sections (only if chosen) add `cpstate`, `admin_data` and `login_result`, and the actions `load_admin`, `assign_role`, `remove_role` and `login`. See `sections.md`.
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
+147 -3
View File
@@ -117,6 +117,32 @@ table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
/* OPTIONAL:checkpoints BEGIN */
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
.cp-name{color:var(--ink);font-size:12.5px}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
/* OPTIONAL:checkpoints END */
/* OPTIONAL:admin BEGIN */
body:not([data-role="admin" i]) .adm-only{display:none!important}
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
.adm-note{font-size:12.5px;color:var(--muted)}
/* OPTIONAL:admin END */
/* OPTIONAL:login BEGIN */
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
.login-card .c-title{font-size:19px;margin:0}
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
/* OPTIONAL:login END */
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
@@ -131,7 +157,9 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
<!-- OPTIONAL: Admin nav item (roles) goes here -->
<!-- OPTIONAL:admin BEGIN -->
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
<!-- OPTIONAL:admin END -->
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
@@ -141,6 +169,9 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<!-- OPTIONAL:login BEGIN -->
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
<!-- OPTIONAL:login END -->
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
@@ -169,7 +200,10 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
<!-- OPTIONAL:checkpoints BEGIN -->
<div class="section-label">Checkpoints</div>
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
<!-- OPTIONAL:checkpoints END -->
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
@@ -181,10 +215,41 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
<!-- OPTIONAL:admin BEGIN -->
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
<div id="tab-admin" class="hidden adm-only">
<div class="card"><div class="c-title">Assign access</div>
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
<button class="btn primary">Assign</button>
<span class="adm-note" id="adm-note" role="status"></span>
</form>
</div>
<div class="card"><div class="c-title">Users with access</div>
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
</div>
</div>
<!-- OPTIONAL:admin END -->
</section>
</main>
</div>
<!-- OPTIONAL:login BEGIN -->
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
<div class="login-screen" id="login-screen">
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
<div class="c-title">{{APP_NAME}}</div>
<p class="login-sub">Sign in to continue</p>
<label>Username<input id="login-user" autocomplete="username" required></label>
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
<div class="login-err" id="login-err" role="alert"></div>
<button class="btn primary">Sign in</button>
</form>
</div>
<!-- OPTIONAL:login END -->
<script>
// ===== CONFIG (fill per project) =====
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
@@ -234,9 +299,22 @@ const SAMPLE = {
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
// OPTIONAL:checkpoints BEGIN
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
// OPTIONAL:checkpoints END
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
},400); },
// OPTIONAL:admin BEGIN
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
logout(){},
// OPTIONAL:login END
};
// ===== EVENT DISPATCH (see contract.md) =====
@@ -249,6 +327,15 @@ function handle(event, d){
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
// OPTIONAL:checkpoints BEGIN
case 'cpstate': cpState(d.kind, d.cp); break;
// OPTIONAL:checkpoints END
// OPTIONAL:admin BEGIN
case 'admin_data': admRender(d); break;
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
case 'login_result': loginResult(d); break;
// OPTIONAL:login END
}
}
@@ -265,7 +352,7 @@ const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub'
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
@@ -303,6 +390,63 @@ function renderReport(){
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
// OPTIONAL:checkpoints BEGIN
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
const CHECKPOINTS = {{CHECKPOINTS_JSON}}; // e.g. ["Open the portal","Load the list","Process items","Finish up"]
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
let cpKinds = CHECKPOINTS.map(()=>'pending');
function cpState(kind, n){
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
cpKinds[n-1] = kind;
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
}
cpState('pending', 1);
// OPTIONAL:checkpoints END
// OPTIONAL:admin BEGIN
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
TABS.admin = ['Admin','Who can use this dashboard'];
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
function admRender(d){
$('adm-note').textContent = d.msg||'';
const rows = d.rows||[];
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
}
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
// OPTIONAL:admin END
// OPTIONAL:login BEGIN
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
function loginShow(on){
$('login-screen').classList.toggle('hidden', !on);
document.querySelector('.app').inert = on;
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
}
function loginSubmit(e){
e.preventDefault(); $('login-err').textContent='';
const u=$('login-user').value.trim(), p=$('login-pass').value;
if(SAMPLE_DATA || bridge) return api('login', u, p);
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
.then(r=>r.json()).then(d=>handle('login_result',d))
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
}
function loginResult(d){
$('login-pass').value='';
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
if(d.user) setUser(d.user);
loginShow(false); showTab('dashboard');
}
loginShow(true);
// OPTIONAL:login END
renderSteps();
startTransport();
</script>
+106
View File
@@ -0,0 +1,106 @@
# Optional sections
Three extras live in `dashboard.html` as marked blocks (`OPTIONAL:<name> BEGIN` ... `END`)
in the style, the markup and the script. Build only the ones the user picked:
```
python <skill>/templates/sections.py <project>/dashboard/dashboard.html checkpoints admin login
```
Name only the chosen sections; name none to remove all three. The script deletes every
unchosen block (markup, script and style) and the marker lines of the chosen ones, and it
stops if a marker is broken. Never cut the blocks by hand.
With `SAMPLE_DATA = true`, every section works on its own sample answers. Once the bot is
wired, the host must answer the section's actions as below. Desktop hosts add the slots to
the `Bridge` class in `bridge.py`. Web hosts serve `POST {API_BASE}/<action-with-dashes>`,
like the core actions, and send the events in the `/run/status` events list.
## Checkpoint tracker (`checkpoints`)
Numbered boxes on the Run tab. Each one is pending, in progress, done or stuck.
- Fill `{{CHECKPOINTS_JSON}}` with the checkpoint names, in order, from the bot's real
stages, for example `["Open the portal","Load the list","Process items","Finish up"]`.
They can be finer than `STEPS`.
- Event `cpstate {kind: "pending"|"active"|"done"|"stuck", cp}`. `cp` counts from 1.
`active` also marks the earlier boxes done and the later ones pending, so the bot can
send `active` for each checkpoint as it starts, and `stuck` when one fails.
- Desktop: add a marker table next to `STEP_MARKERS` in `bridge.py`, and send the event
from `_on_line`, using specific substrings the bot really prints:
```python
CHECKPOINT_MARKERS = [("[cp 1]", "active", 1), ("[cp 1 failed]", "stuck", 1)] # (substring, kind, cp)
# in _on_line, after the step markers:
for marker, kind, cp in CHECKPOINT_MARKERS:
if marker in low:
self.send_to_js("cpstate", {"kind": kind, "cp": cp})
break
```
## Admin/roles screen (`admin`)
An Admin tab where an admin assigns or removes admin and operator access. The Admin nav
item and the screen appear only while `set_user` says `role: "admin"`; operators never see
them. Hiding them is not security: **the host re-checks that the signed-in user is an admin
on every admin action**, and refuses otherwise.
- Event `admin_data {rows: [{username, emp_id, role: "admin"|"operator"}], msg?}`. `msg` is
shown above the list, for example "Access assigned." or "Only admins can do that."
- Actions: `load_admin()` when the tab opens, `assign_role(who, role)`, `remove_role(who)`.
`who` is a username or employee ID. Answer each one with a fresh `admin_data`.
- Desktop slots, using the project's own user store:
```python
@pyqtSlot()
def load_admin(self):
if self.role != "admin": # self.role: set when the user signs in
return
self.send_to_js("admin_data", {"rows": read_access_list()})
@pyqtSlot(str, str)
def assign_role(self, who, role):
if self.role == "admin" and role in ("admin", "operator"):
save_access(who, role)
self.load_admin()
@pyqtSlot(str)
def remove_role(self, who):
if self.role == "admin":
delete_access(who)
self.load_admin()
```
## Login page (`login`)
A sign-in screen in the page's own theme, shown over the dashboard until sign-in succeeds,
plus a Sign out button in the top bar. It is UI only: it hands the username and password to
the project's existing auth. Don't build an auth backend.
- Desktop action `login(username, password)`. The host checks them and sends
`login_result {ok, msg?, user?: {username, emp_id, role}}`. On `ok` the screen closes and
`user` fills the sidebar (and the role, if the admin screen is built). Otherwise `msg`
shows under the form.
- Web: `POST {API_BASE}/login` with JSON `{username, password}`, answered with the same
`{ok, msg?, user?}` body.
- Action `logout()` when the user signs out. The screen comes back.
- The page sits behind the screen, but the host must still refuse data actions until
sign-in succeeds.
- No auth in the project yet: keep a stub that lets any non-empty username in, and tell the
user it is a stub. In sample mode the page has its own stub.
```python
@pyqtSlot(str, str)
def login(self, username, password):
role = check_login(username, password) # the project's auth: a role, or None
self.role = role or ""
if role:
self.send_to_js("login_result", {"ok": True, "user": {"username": username, "emp_id": "", "role": role}})
else:
self.send_to_js("login_result", {"ok": False, "msg": "Wrong username or password."})
@pyqtSlot()
def logout(self):
self.role = ""
```
+35
View File
@@ -0,0 +1,35 @@
"""Keep the optional sections the user chose in dashboard.html, and delete the rest.
Usage: python sections.py <dashboard.html> [checkpoints] [admin] [login]
Each section sits between marker lines, in HTML, CSS and script:
<!-- OPTIONAL:admin BEGIN --> /* OPTIONAL:admin BEGIN */ // OPTIONAL:admin BEGIN
... ... ...
<!-- OPTIONAL:admin END --> /* OPTIONAL:admin END */ // OPTIONAL:admin END
Chosen sections lose only their marker lines. Unchosen ones lose everything.
The file is rewritten in place. Name no sections to remove all of them.
"""
import re
import sys
from pathlib import Path
SECTIONS = ("checkpoints", "admin", "login")
BLOCK = re.compile(r"^[ \t]*(?:<!--|/\*|//) OPTIONAL:(\w+) BEGIN.*\n((?:.*\n)*?)[ \t]*(?:<!--|/\*|//) OPTIONAL:\1 END.*\n", re.M)
def keep(html, chosen):
unknown = set(chosen) - set(SECTIONS)
if unknown:
sys.exit(f"unknown section(s) {', '.join(sorted(unknown))}; choose from {', '.join(SECTIONS)}")
html = BLOCK.sub(lambda m: m[2] if m[1] in chosen else "", html)
if "OPTIONAL:" in html:
sys.exit("an OPTIONAL marker has no matching BEGIN/END line; fix the template")
return html
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
page = Path(sys.argv[1])
page.write_text(keep(page.read_text(encoding="utf-8"), sys.argv[2:]), encoding="utf-8")
print(f"{page}: kept {', '.join(sys.argv[2:]) or 'no optional sections'}")
+5
View File
@@ -17,6 +17,11 @@ uv run --with pytest --with playwright pytest tests
`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`.
- Optional-section cases (`test_optional_sections.py`) skip on a generated page
built without that section. The template must carry all of them. A page with
the login section is signed in (`login_result ok`) before each case.
- `test_sections.py`: the optional-section strip tool keeps only the chosen
sections, and a page with none of them still passes the page check.
- `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:
+22 -1
View File
@@ -35,6 +35,18 @@ FIXTURES = {
"asks": [r"colou?r", r"desktop|web", r"section"],
"never_asks": [r"\bkeep\b|restyle"],
"brand": "#7a3cff",
"sections": [],
},
"a-empty-sections": {
"dir": "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: Superbill Ember. "
"Desktop or web: web. Optional sections: all three (checkpoint tracker, admin/roles "
"screen, login page). The project has no auth yet.",
"frontend": r"^\s*(none|no\b)",
"asks": [r"colou?r", r"desktop|web", r"section"],
"never_asks": [r"\bkeep\b|restyle"],
"sections": ["checkpoints", "admin", "login"],
},
}
@@ -43,7 +55,7 @@ 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)
shutil.copytree(ROOT / "tests" / "fixtures" / cfg.get("dir", fixture), project)
for skill in ROOT.glob("rpa-dashboard-*"):
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
@@ -80,10 +92,19 @@ def main(fixture):
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)
if "sections" in cfg: # chosen sections built, the others leave no trace
spec = importlib.util.spec_from_file_location("test_sections", ROOT / "tests" / "test_sections.py")
traces = importlib.util.module_from_spec(spec)
spec.loader.exec_module(traces)
for name, trace in traces.TRACES.items():
want = name in cfg["sections"]
checks[f"{name} {'built' if want else 'left no trace'}"] = bool(trace.search(html)) == want
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:]]
if cfg.get("sections"): # every chosen section's page check ran
checks["no section checks skipped"] = "skipped" not in page_check[0]
else:
page_check = ["no page"]
+3
View File
@@ -22,6 +22,7 @@ TEST_FILL = {
"API_BASE": "",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
"CHECKPOINTS_JSON": json.dumps(["Sign in", "Open the list", "Process items", "Finish up"]),
}
FAKE_BRIDGE = """
window.__calls = [];
@@ -63,6 +64,8 @@ def page(request, browser, page_url):
pg.on("pageerror", lambda e: errors.append(e))
pg.goto(page_url)
assert not errors, f"page script errors: {errors}"
if pg.get_by_role("form", name="Sign in").is_visible(): # login page section: the host lets us in
push_to(pg, "login_result", {"ok": True})
if dark:
push_to(pg, "apply_dark", {"on": True})
yield pg
+146
View File
@@ -0,0 +1,146 @@
"""Optional sections (see templates/sections.md). On a generated page, a case skips when the
page was built without its section. The template itself must carry every section."""
import os
import re
import pytest
from playwright.sync_api import expect
from test_contract import colour_of, open_tab
def need(locator, section):
if not locator.count():
if not os.environ.get("PAGE_CHECK_HTML"):
pytest.fail(f"the template has no {section}")
pytest.skip(f"page built without the {section}")
return locator
def fits_width(page, locator):
box = locator.bounding_box()
return box["x"] >= 0 and box["x"] + box["width"] <= page.viewport_size["width"]
CP_SHOWS = {"pending": ("Pending", "grey"), "active": ("In progress", "amber"),
"done": ("Done", "green"), "stuck": ("Stuck", "red")}
def test_checkpoint_boxes_show_each_state(page, push):
track = need(page.get_by_role("list", name="Checkpoints", include_hidden=True), "checkpoint tracker")
open_tab(page, "Run")
boxes = track.get_by_role("listitem")
n = boxes.count()
assert n >= 2, "the tracker needs at least two checkpoints"
def expect_boxes(*kinds):
for i, kind in enumerate(kinds):
label, colour = CP_SHOWS[kind]
expect(boxes.nth(i)).to_have_text(re.compile(rf"^\s*{i + 1}(?!\d).*{label}\s*$"))
assert colour_of(boxes.nth(i)) == colour, (i + 1, kind)
expect_boxes(*["pending"] * n)
push("cpstate", {"kind": "active", "cp": 2}) # earlier boxes are done, later ones pending
expect_boxes("done", "active", *["pending"] * (n - 2))
push("cpstate", {"kind": "stuck", "cp": 2})
expect_boxes("done", "stuck")
push("cpstate", {"kind": "done", "cp": 2})
expect_boxes("done", "done")
push("cpstate", {"kind": "pending", "cp": 2})
expect_boxes("done", "pending")
assert fits_width(page, boxes.last)
ADA = {"username": "ada", "emp_id": "E1", "role": "admin"}
OLIVE = {"username": "olive", "emp_id": "E2", "role": "operator"}
def admin_nav(page):
return need(page.get_by_role("navigation").get_by_role("button", name="Admin", exact=True, include_hidden=True),
"admin screen")
def test_admin_screen_only_for_admins(page, push):
nav = admin_nav(page)
push("set_user", OLIVE)
expect(nav).to_be_hidden()
push("set_user", ADA)
expect(nav).to_be_visible()
nav.click()
users = page.get_by_role("table", name="Users with access")
push("admin_data", {"rows": [ADA, OLIVE]})
rows = users.locator("tbody").get_by_role("row")
expect(rows).to_have_count(2)
expect(rows.nth(1)).to_contain_text("olive")
expect(rows.nth(1)).to_contain_text(re.compile("operator", re.I))
push("set_user", OLIVE) # operators see neither the nav item nor the screen
expect(nav).to_be_hidden()
expect(users).to_be_hidden()
def test_admin_assigns_and_removes_roles(page, push):
nav = admin_nav(page)
push("set_user", ADA)
nav.click()
sample = page.evaluate("SAMPLE_DATA")
form = page.get_by_role("form", name="Assign access")
form.get_by_role("textbox", name="Username or employee ID").fill("grace")
form.get_by_role("combobox", name="Role").select_option("admin")
form.get_by_role("button", name="Assign").click()
rows = page.get_by_role("table", name="Users with access").locator("tbody").get_by_role("row")
if not sample: # wired page: the host answers with fresh admin_data
assert ["assign_role", "grace", "admin"] in page.evaluate("window.__calls")
push("admin_data", {"rows": [ADA, {"username": "grace", "emp_id": "", "role": "admin"}]})
grace = rows.filter(has_text="grace")
expect(grace).to_contain_text(re.compile("admin", re.I))
grace.get_by_role("button", name="Remove").click()
if not sample:
assert ["remove_role", "grace"] in page.evaluate("window.__calls")
push("admin_data", {"rows": [ADA]})
expect(grace).to_have_count(0)
def login_form(page):
return need(page.get_by_role("form", name="Sign in", include_hidden=True), "login page")
def sign_out(page):
page.get_by_role("button", name="Sign out", exact=True).click()
def light_text(locator):
rgb = re.findall(r"\d+", locator.evaluate("el => getComputedStyle(el).color"))[:3]
return sum(int(x) for x in rgb) / 3 > 128
def test_login_page_renders_in_the_theme(page, request):
form = login_form(page)
sign_out(page)
expect(form).to_be_visible()
assert fits_width(page, form)
assert light_text(form) == (request.node.callspec.params["page"] == "dark")
expect(form.get_by_role("button", name="Sign in")).to_be_visible()
def test_login_hands_off_to_auth_or_stub(page, push):
form = login_form(page)
sample = page.evaluate("SAMPLE_DATA")
sign_out(page)
form.get_by_role("textbox", name="Username").fill("alice")
form.get_by_label("Password").fill("secret")
form.get_by_role("button", name="Sign in").click()
if sample: # no auth yet: the stub signs anyone in
expect(form).to_be_hidden()
expect(page.get_by_text("alice", exact=True)).to_have_count(1)
sign_out(page)
else: # the host checks it with the project's auth
calls = page.evaluate("window.__calls")
assert ["logout"] in calls and ["login", "alice", "secret"] in calls
push("login_result", {"ok": False, "msg": "Wrong password"})
expect(form.get_by_role("alert")).to_have_text("Wrong password")
expect(form).to_be_visible()
push("login_result", {"ok": True, "user": {"username": "bob", "emp_id": "7", "role": "operator"}})
expect(form).to_be_hidden()
expect(page.get_by_text("bob", exact=True)).to_have_count(1)
+55
View File
@@ -0,0 +1,55 @@
"""Optional sections: the strip tool keeps the chosen sections and leaves nothing of the rest."""
import importlib.util
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = ROOT / "rpa-dashboard-no-ai" / "templates"
spec = importlib.util.spec_from_file_location("sections", TEMPLATES / "sections.py")
sections = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sections)
TEMPLATE = (TEMPLATES / "dashboard.html").read_text(encoding="utf-8")
# Text each optional section brings into the page (markup, script or style).
TRACES = {
"checkpoints": re.compile(r"checkpoint|cpstate|cp-", re.I),
"admin": re.compile(r"admin", re.I),
"login": re.compile(r"login_|sign in|sign out|logout", re.I),
}
def test_no_sections_leave_nothing_behind():
html = sections.keep(TEMPLATE, [])
for name, trace in TRACES.items():
assert not trace.search(html), f"{name} left behind: {trace.search(html)[0]!r}"
assert "OPTIONAL" not in html
@pytest.mark.parametrize("chosen", [["checkpoints"], ["admin"], ["login"], ["checkpoints", "admin", "login"]])
def test_chosen_sections_are_kept_and_the_rest_removed(chosen):
html = sections.keep(TEMPLATE, chosen)
for name, trace in TRACES.items():
assert bool(trace.search(html)) == (name in chosen), name
assert "OPTIONAL" not in html
def test_unknown_section_is_refused():
with pytest.raises(SystemExit, match="checkpoints, admin, login"):
sections.keep(TEMPLATE, ["charts"])
def test_page_without_sections_passes_the_page_check(tmp_path):
fill = {"APP_NAME": "T", "APP_SUBTITLE": "", "LOGO_SRC": "", "API_BASE": "",
"STEPS_JSON": json.dumps(["Start", "Work", "Done"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}])}
page_file = tmp_path / "dashboard.html"
page_file.write_text(re.sub(r"\{\{(\w+)\}\}", lambda m: fill[m[1]], sections.keep(TEMPLATE, [])), encoding="utf-8")
r = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q", "-p", "no:cacheprovider"],
env={**os.environ, "PAGE_CHECK_HTML": str(page_file)}, capture_output=True, text=True)
assert r.returncode == 0, r.stdout[-2000:]