T5.3 - B4 surface: a pipeline strip, four server counts, four shareable links

Four cells on the launcher - Work packages, Release ready, On hold, Overdue -
every number from /api/wps/metrics, which T4.1 built. There is deliberately no
cache fallback anywhere in renderPipeline(): a remembered number sitting beside
three live ones is the failure B4 exists to remove, wearing a different hat.

The cells are the four the endpoint already computes and the dashboard already
filters on, so they map 1:1 onto its flags rather than inventing a fifth slice
nothing downstream understands. wp-creation-app.js now names them (DASH_FLAGS)
for the same reason: a cell linking to a filter the board does not recognise is
a dead link that still looks live.

"Links to a filtered view via a shareable URL" needed the filter to BE URL state,
which it was not - the dashboard kept its flag in a variable. So:

  - dashToggleFlag pushes ?flag=<f>, and clears it on the way out of the board
  - the creator applies ?flag= before its first render, not after (applying it
    after paints the whole board and throws it away)
  - Back and Forward move through filters like any other state
  - work-package-suite-app.js forwards the flag ACROSS the iframe boundary, since
    the creator's src carries only the project. B7/T7.1 dissolves that frame and
    this hand-off goes with it; it is commented as such.

Zero is a real answer for one slice of a project that has work in it. Four zeros
on a project with none is not a reading, it is a strip that looks broken - that
case gets a sentence and a way into the creator instead. A failed request gets an
explicit error naming the failure, and no cells at all.

  html/index.html            the strip, its states, PIPE_CELLS
  html/wp-creation-app.js    flag as URL state; DASH_FLAGS; dashApplyFlag
  html/work-package-suite-app.js  forward the flag into the frame; clear on exit
  tests/pipeline_check.py    new - 43 checks

Done when
  [x] every number comes from a server endpoint - proved by poisoning localStorage
      with 99 fake packages and demanding the strip still read the server's 4
  [x] each cell links to a filtered view via a shareable URL - and the probe
      FOLLOWS the link and reads the filter inside the frame rather than trusting
      that a correct-looking URL was built
  [x] a project with zero work packages renders a sensible empty state
  [x] the strip announces updates via aria-live (polite - a count is not an
      interruption) and reports aria-busy while it is counting

What the probe caught

  The link landed on "Complete the SOP Configuration first". Not the strip's
  fault: browser_check.py's fixture stores a bare {governance: …} blob as the SOP
  data, where production stores {sop, state}. restoreSavedSOP() needs `state` and
  bails without it, so sopComplete stays false and the WP tab shows its gate.
  pipeline_check seeds the production shape. The underlying wart is real and is
  logged rather than fixed - see BL-018.

Verified one at a time
  pipeline_check  43/43   new
  launcher_check  58/58
  stepper_check   70/70
  url_state       23/23   the dashboard's new flag state did not disturb it
  aggregates      16/16
  browser_check   71/71
  a11y            22/22
  autosave        34/34
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: still 0 across all page sheets and inline blocks. The
four cells are told apart by a label, a sentence and an accent - three channels,
so colour is not carrying it alone (C1).

Raised, not fixed
  BL-018  The WP tab's gate is the last localStorage-derived status in the app.
          T4.1 moved the launcher's card to the server; the wizard page still
          decides gate-or-creator from wp_suite_sop_complete plus a state blob.
          pullProject refreshes both on load so a connected user is fine, but the
          two answers come from different places and the fallback is silent.
          Includes a second, sharper edge: project-data.js:210 writes that flag
          for ANY row returned, including one with no `state` to restore - so the
          flag is written and never read consistently. T7.1 owns it.

Question for the PR, per CLAUDE.md: the strip counts Overdue against `data.due`,
which is free text today. CR-004/CR-018 restructure location but not dates. If
"overdue" is going to drive anything beyond a launcher tile, that field needs a
type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:38:58 -05:00
parent 77c043c3db
commit 6088ef17e8
6 changed files with 569 additions and 6 deletions

View File

@@ -225,8 +225,14 @@ Wave 5 added more, for the same reason:
```bash
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
```
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
globals: `dashFilter` and `currentView` are declared with `let` in a classic script, so
they are not properties of `window` and a cross-frame read of either comes back
`undefined` — which is indistinguishable from a filter that never applied.
`launcher_check.py` is the only probe that runs itself in **two subprocesses**, and both
reasons are worth knowing before writing a third:

View File

@@ -298,6 +298,32 @@ deliberately deferred.
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
target against the stripped figure.
### BL-018 — The Work Package tab's gate is the last localStorage-derived status
- **Found during:** T5.3
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
`renderWPTab()` shows the gate or the creator on it
- **What:** `T4.1` moved the *launcher's* SOP status onto `/api/projects/{id}/summary`, and
`aggregates_check.py` proves the card reports the server's answer over a lying cache. The
SOP **wizard page** still decides whether to show the creator or the "complete the SOP
Configuration first" gate from `localStorage.wp_suite_sop_complete` plus a `wp_suite_state`
blob. `ProjectData.pullProject()` refreshes both from the server on load, so a signed-in
user with a working connection is fine — but the two answers come from different places,
and the fallback is silent rather than an error state, which is the shape `B4` objects to.
- **Also:** `project-data.js:210` writes `wp_suite_sop_complete = '1'` whenever
`/api/sops/latest` returns any row, including one whose `data` carries no `state`. The
wizard then holds a browser that believes the SOP is complete and has nothing to restore,
so `restoreSavedSOP()` bails and `sopComplete` stays false — the flag is written and never
read consistently. Found because `browser_check.py`'s fixture seeds exactly that shape, and
a pipeline-strip link consequently landed on the gate. `pipeline_check.py` seeds the
production shape (`{sop, state}`) instead.
- **Why not now:** `T5.3` is the strip. Changing which source the WP gate trusts changes what
the SOP wizard does when offline, and `B7`/`T7.1` dissolves that iframe and rewrites this
hand-off wholesale.
- **Suggested wave or follow-up:** `T7.1`, or wave 9 with `C2` if the gate survives the
rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}`
shape so it stops being the only place this discrepancy is visible.
### BL-014 — Four controls fall back to the browser's default focus ring
- **Found during:** T3.4

View File

@@ -317,6 +317,39 @@
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
.field-error:empty { display: none; }
/* PIPELINE STRIP — B4 surface (T5.3)
Four counts, every one of them from /api/wps/metrics. There is deliberately
no localStorage fallback anywhere in here: a per-browser number that looks
authoritative is the thing B4 removes, and a stale four beside a live four
is worse than an error. */
.pipeline { margin-bottom: 1.5rem; }
.pipeline-head { font-size: 13px; font-weight: 600; text-transform: none;
color: var(--cds-text-secondary); margin-bottom: 0.6rem; }
.pipeline-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1px;
background: var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle); }
.pipe-cell { display: flex; flex-direction: column; gap: 0.15rem; padding: 1rem 1.1rem;
background: var(--cds-layer); text-decoration: none; color: var(--cds-text-primary);
border-left: 3px solid transparent; transition: background 0.15s; }
.pipe-cell:hover { background: var(--cds-layer-hover); }
.pipe-num { font-size: 1.75rem; font-weight: 300; line-height: 1.1; }
.pipe-label { font-size: 13px; font-weight: 600; }
.pipe-sub { font-size: 12px; color: var(--cds-text-secondary); }
/* Each cell's accent says which slice it is, and the label says it in words —
the number alone is the same shape in all four (C1). */
.pipe-cell.is-total { border-left-color: var(--cds-interactive-01); }
.pipe-cell.is-ready { border-left-color: var(--cds-support-success); }
.pipe-cell.is-onhold { border-left-color: var(--wp-status-warning-text); }
.pipe-cell.is-overdue { border-left-color: var(--cds-support-error); }
/* Zero is a real answer for one slice of a project that has work in it. Four
zeros on a project with no work packages at all is not a reading, it is a
broken-looking strip, so that case gets a sentence instead. */
.pipe-empty, .pipe-error { grid-column: 1 / -1; background: var(--cds-layer);
padding: 1.1rem 1.2rem; font-size: 13px; color: var(--cds-text-secondary); }
.pipe-error { color: var(--wp-status-warning-text); }
.pipe-empty a { color: var(--cds-link-primary); }
.pipe-loading { grid-column: 1 / -1; background: var(--cds-layer); padding: 1.1rem 1.2rem;
font-size: 13px; color: var(--cds-text-secondary); font-style: italic; }
/* FIRST RUN
A brand-new account has no projects, so it has no tool cards either — this
is the whole page for that person, and it has to say what to do next. */
@@ -437,6 +470,16 @@
</div>
</section>
<!-- PIPELINE STRIP — B4 surface / T5.3
Four counts from /api/wps/metrics, each one a link into the dashboard
filtered to that slice. The URL carries the filter (?view=dashboard&
flag=…), so a cell can be sent to someone rather than only clicked.
aria-live because it refreshes in place when the project changes. -->
<section class="pipeline" id="pipeline" hidden aria-labelledby="pipeline-head">
<h2 class="pipeline-head" id="pipeline-head">Work package pipeline</h2>
<div class="pipeline-strip" id="pipeline-strip" aria-live="polite" aria-busy="true"></div>
</section>
<!-- TOOL CARDS (shown once a project is active) -->
<div class="cards-grid" id="overview" style="display:none">
@@ -712,6 +755,8 @@
if(!active){
cards.style.display = 'none';
const strip = document.getElementById('pipeline');
if(strip) strip.hidden = true;
heroTitle.textContent = 'Work Package Suite';
// Two different situations, and telling someone to "select a project"
// when there are none to select is the thing B3 is about.
@@ -737,6 +782,69 @@
// reflectSOPStatus.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
reflectSOPStatus(active);
renderPipeline(active);
}
// ── PIPELINE STRIP (B4 surface / T5.3) ────────────────────────────────────
// Four counts, all four from /api/wps/metrics. There is deliberately no cache
// fallback: B4's whole point is that a per-browser number which looks
// authoritative is worse than no number, and the same is true of a remembered
// one shown next to live ones.
//
// The flags match wp-creation-app.js's DASH_FLAGS. A cell linking to a filter
// the dashboard does not recognise is a dead link that still looks live.
const PIPE_CELLS = [
{flag: '', cls: 'is-total', label: 'Work packages', key: 'total',
sub: 'everything on this project'},
{flag: 'ready', cls: 'is-ready', label: 'Release ready', key: 'release_ready',
sub: 'no open constraints, nothing waiting'},
{flag: 'onhold', cls: 'is-onhold', label: 'On hold', key: 'on_hold',
sub: 'raised as an issue'},
{flag: 'overdue', cls: 'is-overdue', label: 'Overdue', key: 'overdue',
sub: 'past due and not closed'},
];
function dashHref(projectId, flag){
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
if(flag) q.set('flag', flag);
return 'work-package-suite.html?' + q.toString();
}
function renderPipeline(active){
const host = document.getElementById('pipeline');
const strip = document.getElementById('pipeline-strip');
if(!host || !strip) return;
host.hidden = false;
strip.setAttribute('aria-busy', 'true');
strip.innerHTML = '<div class="pipe-loading">Counting work packages…</div>';
fetch('/api/wps/metrics?project_id=' + encodeURIComponent(active.id),
{ headers: { 'Accept': 'application/json' } })
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(m => {
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
strip.setAttribute('aria-busy', 'false');
if(!m.total){
// Four zeros on a project with no work packages is not a reading, it
// is a strip that looks broken. Say the true thing instead.
strip.innerHTML = `<div class="pipe-empty">No work packages on this project yet.
<a href="${esc(dashHref(active.id, ''))}">Open the creator</a> to write the first one.</div>`;
return;
}
strip.innerHTML = PIPE_CELLS.map(c => `
<a class="pipe-cell ${c.cls}" href="${esc(dashHref(active.id, c.flag))}">
<span class="pipe-num">${Number(m[c.key] || 0)}</span>
<span class="pipe-label">${esc(c.label)}</span>
<span class="pipe-sub">${esc(c.sub)}</span>
</a>`).join('');
})
.catch(err => {
if(ProjectData.getActiveId() !== active.id) return;
strip.setAttribute('aria-busy', 'false');
strip.innerHTML = `<div class="pipe-error">⚠ Could not load work package counts —
${esc((err && err.message) || 'offline')}. These numbers come from the server;
nothing stale is shown in their place.</div>`;
});
}
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }

View File

@@ -519,8 +519,12 @@ function switchTool(tool, opts){
// restoring because the user pressed Back - recording that as a new entry would
// make Back appear to do nothing.
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
// `flag` is the dashboard's filter (T5.3). It rides in the URL so a pipeline
// cell is shareable, which also means it has to be cleared on the way out —
// otherwise leaving the board and coming back re-applies a filter nobody asked
// for a second time.
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
: { tab: tool, view: '' });
: { tab: tool, view: '', flag: '' });
}
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
// creator) but opens it straight to the dashboard view.
@@ -641,7 +645,16 @@ function renderWPTab(wantDash){
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
}
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
if(dash && typeof cw.showDashboard === 'function'){
// T5.3: the launcher's pipeline cells link to a filtered board. The
// creator is an iframe child whose src carries only the project, so the
// filter has to be handed across rather than read from the child's own
// URL — and BEFORE showDashboard(), which renders. B7/T7.1 dissolves
// this frame and this hand-off goes with it.
const flag = new URLSearchParams(window.location.search).get('flag') || '';
if(typeof cw.dashApplyFlag === 'function') cw.dashApplyFlag(flag);
cw.showDashboard();
}
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
} catch(e){ /* cross-document timing; nothing useful to do */ }
};

View File

@@ -1828,10 +1828,39 @@ function dashRefreshAfterWrite(){
done();
}
function dashToggleFlag(f){
// The four flags the dashboard filters on. Named here because the launcher's
// pipeline strip (T5.3 / B4) links straight to them, and a cell that links to a
// filter this file does not recognise is a dead link that still looks live.
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
function dashToggleFlag(f, opts){
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
dashPage=0; renderDashboard();
dashPage=0;
// S3: which slice of the board you are looking at is state, so it belongs in
// the URL. Without this a pipeline cell could open the filter but nobody could
// send anyone the result — which is the half of T5.3 that is about sharing.
if(!(opts && opts.fromUrl)) urlSyncDashFlag();
renderDashboard();
}
function urlSyncDashFlag(opts){
if(typeof WPUrl === 'undefined') return;
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
if(activeProjectId) patch.project = activeProjectId;
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
}
// Apply a flag arriving from outside — a URL, or the embedding shell forwarding
// one across the iframe boundary. Never pushes history: the caller's own
// navigation is what put us here.
function dashApplyFlag(f){
const next = DASH_FLAGS.indexOf(f) >= 0 ? f : '';
if(dashFilter.flag === next) return next;
dashFilter.flag = next;
dashPage = 0;
if(currentView === 'Dashboard') renderDashboard();
return next;
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
@@ -1889,7 +1918,7 @@ function showDashboard(opts){
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
currentView='Dashboard'; cmtUpdateCurStep();
if(typeof WPUrl !== 'undefined' && !fromUrl){
const patch = { view: 'dashboard', wp: '' };
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
if(activeProjectId) patch.project = activeProjectId;
WPUrl.push(patch);
}
@@ -2333,7 +2362,13 @@ function bootData(){
if(ix>=0) wpNavOpen(ix);
else toast('That work package is not on this project (it may have been deleted).');
}
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
// A pipeline cell links straight to a filtered board, so the filter has to be
// applied BEFORE the first render — applying it after would paint the whole
// board and then throw it away, which on a big project is visible.
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){
dashApplyFlag(p.get('flag') || '');
showDashboard({fromUrl:true});
}
// Back / Forward. The URL is the state, so restoring is "read it and show that",
// not a bespoke undo stack. Guarded against re-pushing while we restore, or every
@@ -2342,6 +2377,10 @@ function bootData(){
WPUrl.onChange(function(state, viaPop){
if(!viaPop) return;
if(state.view === 'dashboard'){
// The flag first: showDashboardFromUrl() renders, so setting it after
// would paint the unfiltered board and then replace it. dashApplyFlag
// re-renders itself only when the board is already open.
dashApplyFlag(state.flag || '');
if(currentView !== 'Dashboard') showDashboardFromUrl();
return;
}

371
tests/pipeline_check.py Normal file
View File

@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""The launcher's pipeline strip — B4 surface (T5.3).
T4.1 put the counts on the server; this is the surface that shows them, and the
failure it has to avoid is the one B4 names: a per-browser number that looks
authoritative. So the strip is checked the way aggregates_check.py checks the
dashboard — by POISONING localStorage with different numbers and demanding the
strip still report the server's.
1. every number comes from a server endpoint
2. each cell links to a filtered view via a shareable URL, and the filter
actually applies at the other end
3. a project with zero work packages renders a sentence, not four zeros
4. the strip announces updates via aria-live, because it refreshes in place
5. a failed request is an error, not four zeros and not a remembered number
Check 2 is the one with a seam in it: the dashboard is an iframe CHILD of the SOP
page, so the filter has to cross that boundary. The probe follows the link and
reads the filter inside the frame, rather than trusting that the URL was built.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
"#pipeline-strip .pipe-error')"
CELLS_JS = r"""
JSON.stringify([...document.querySelectorAll('#pipeline-strip .pipe-cell')].map(a => ({
href: a.getAttribute('href'),
num: (a.querySelector('.pipe-num') || {}).textContent,
label: ((a.querySelector('.pipe-label') || {}).textContent || '').trim(),
sub: ((a.querySelector('.pipe-sub') || {}).textContent || '').trim(),
accent: getComputedStyle(a).borderLeftColor,
tag: a.tagName,
})))
"""
def settle(seconds=1.4):
time.sleep(seconds)
# The shape ProjectData.pushSOP writes: { sop, state }. browser_check's fixture
# stores a bare {governance: …} blob, which is enough for the field view but NOT
# for the SOP wizard — restoreSavedSOP() needs `state` and bails without it, so
# the Work Package tab shows its "complete the SOP first" gate and a pipeline link
# lands on a locked door. Production data has both keys; the fixture should too,
# or the probe is testing a shape no real project has.
SOP_DATA = {
"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
"governance": {"disciplines": ["Mechanical", "Electrical"],
"woFormat": "WP##-[TYPE]"},
"woTypes": [{"name": "Conduit Install", "enabled": True}]},
"state": {"bimEnabled": False,
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
"division": "Internal", "site": "QA Lab"},
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamMembers": [],
"signoffRoles": [{"role": "Superintendent", "name": ""},
{"role": "Foreman", "name": ""}],
"wpTypes": [{"name": "Conduit Install", "enabled": True, "notes": "",
"approval": "", "specSection": ""}],
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
"disciplines": ["Mechanical", "Electrical"],
"discMode": "choice", "instanceSuffix": "letter",
"sizeHoursMax": ""},
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
"trackingUrl": "", "commissioningUrl": ""},
"constraints": [], "sequence": [], "sources": []},
}
def seed_two_projects(db_path):
"""browser_check's fixture, plus a third project with no work packages at all
— the empty state is a database state and cannot be faked in the browser."""
tok = seed(db_path)
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
sop = db.get(models.Sop, "sopA")
if sop:
sop.data = SOP_DATA
db.add(models.Project(id="projEmpty", name="Empty Job", number="E-1",
client="Internal QA"))
db.flush()
db.add(models.ProjectMember(id="pmE", user_id="user_root", project_id="projEmpty",
role=""))
# One package that is genuinely overdue and one on hold, so three of the
# four cells are non-zero and a strip that hardcoded them would show.
db.add(models.WorkPackage(
id="wpA3", project_id="projA", sop_id="sopA", number="WP03-HOLD",
subject="held for access", status="Issue", type="Conduit Install",
data={"disciplines": ["Electrical"], "hours": "8",
"constraints": [{"name": "Access", "status": "open", "comment": "no permit"}]}))
db.add(models.WorkPackage(
id="wpA4", project_id="projA", sop_id="sopA", number="WP04-LATE",
subject="late wire pull", status="In Progress", type="Conduit Install",
data={"disciplines": ["Electrical"], "hours": "12", "due": "2020-01-01",
"constraints": []}))
db.commit()
return tok
def server_metrics(page, base, project_id):
return json.loads(page.eval(
"fetch('/api/wps/metrics?project_id=%s', {headers:{Accept:'application/json'}})"
".then(r => r.text())" % project_id))
def run(page, base, tok):
def visit(path, wait=READY):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + path, wait)
settle(1.6)
print("\n1. every number comes from the server")
visit("/index.html?project=projA")
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
truth = server_metrics(page, base, "projA")
cells = json.loads(page.eval(CELLS_JS))
chk("the strip renders four cells", len(cells) == 4, [c["label"] for c in cells])
want = [("Work packages", truth["total"]), ("Release ready", truth["release_ready"]),
("On hold", truth["on_hold"]), ("Overdue", truth["overdue"])]
for (label, n), cell in zip(want, cells):
chk("%-14s reads %s, the server's own number" % (label, n),
cell["label"] == label and cell["num"] == str(n),
"cell %r = %r, server = %r" % (cell["label"], cell["num"], n))
chk("...and the fixture is not all zeros, so this proves something",
truth["total"] > 0 and truth["on_hold"] > 0 and truth["overdue"] > 0, truth)
print("\n1b. a poisoned cache does not move a single number")
page.eval("""(() => {
// Every shape the launcher has ever cached work packages under, filled with
// numbers nothing like the server's.
const fake = Array.from({length: 99}, (_, i) => ({id: 'x' + i, status: 'Closed'}));
for (const k of ['wp_packages', 'wp_packages__projA', 'wp_suite_wps',
'wp_suite_wps__projA', 'wp_creation_packages']) {
localStorage.setItem(k, JSON.stringify(fake));
}
return true;
})()""")
page.goto(base + "/index.html?project=projA", READY)
settle(1.6)
poisoned = json.loads(page.eval(CELLS_JS))
chk("with 99 fake packages in localStorage the total is still the server's %d"
% truth["total"],
poisoned and poisoned[0]["num"] == str(truth["total"]),
poisoned[0]["num"] if poisoned else poisoned)
chk("...and no cell reads 99", all(c["num"] != "99" for c in poisoned),
[c["num"] for c in poisoned])
page.eval("localStorage.clear(); true")
print("\n4. the strip announces, and says when it is working")
chk("the strip is a live region", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-live')") == "polite")
chk("...polite, not assertive — a count is not an interruption", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-live')") != "assertive")
chk("...and reports aria-busy=false once the numbers land", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-busy')") == "false")
chk("the section is named", page.eval(
"!!document.querySelector('#pipeline[aria-labelledby]')"))
print("\n the four cells are told apart by more than a colour (C1)")
chk("every cell carries a label in words",
all(c["label"] for c in poisoned), [c["label"] for c in poisoned])
chk("...and a sentence saying what it counts",
all(len(c["sub"]) > 8 for c in poisoned), [c["sub"] for c in poisoned])
chk("...four distinct labels", len({c["label"] for c in poisoned}) == 4)
chk("...and four distinct accents, as a second channel",
len({c["accent"] for c in poisoned}) == 4, [c["accent"] for c in poisoned])
print("\n2. each cell is a shareable link into a filtered board")
chk("every cell is a real <a>, not a div with a handler",
all(c["tag"] == "A" for c in poisoned), [c["tag"] for c in poisoned])
hrefs = [c["href"] for c in poisoned]
chk("...all pointing at the dashboard",
all("view=dashboard" in h for h in hrefs), hrefs)
chk("...all carrying the project, so the link works on a cold browser",
all("project=projA" in h for h in hrefs), hrefs)
chk("...three of them naming a filter, the total naming none",
[("flag=" in h) for h in hrefs] == [False, True, True, True], hrefs)
chk("...and the filters are the ones the dashboard knows",
sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h)
== ["onhold", "overdue", "ready"], hrefs)
print(" ...and the filter survives the trip into the creator's iframe")
onhold_href = [h for h in hrefs if "flag=onhold" in h][0]
page.goto(base + "/" + onhold_href.lstrip("/"))
for _ in range(40):
got = page.eval("""(() => {
const f = document.getElementById('wp-frame');
try { return f && f.contentWindow && f.contentWindow.wpCreatorReady ? 'ready' : ''; }
catch (e) { return 'x'; }
})()""")
if got == "ready":
break
time.sleep(0.3)
settle(2.0)
# Read the FRAME'S DOM, not its globals. `dashFilter` and `currentView` are
# declared with let in a classic script, so they are not properties of window
# and a cross-frame read of them comes back undefined — which looks exactly
# like a filter that was never applied. The board itself is the evidence.
inner = json.loads(page.eval("""(() => {
try {
const d = document.getElementById('wp-frame').contentDocument;
const dv = d.getElementById('dashboard-view');
const active = d.querySelector('.dash-metric.dm-active .dm-label');
// The board only — NOT the gating panel next to it, which is server-derived
// and not filtered. Counting both would let a two-package gating list pass
// this check whatever the filter did.
const panel = [...d.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim());
});
const rows = panel
? [...panel.querySelectorAll('tbody tr')]
.map(r => (r.cells[0] ? r.cells[0].textContent : '').trim()).filter(Boolean)
: null;
return JSON.stringify({
shown: !!dv && getComputedStyle(dv).display !== 'none',
active: active ? active.textContent.trim() : null,
rows: rows,
});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()"""))
chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner)
chk("...with the on-hold tile already the active filter, inside the frame",
(inner.get("active") or "").lower() == "on hold", inner)
chk("...and the board listing only the held package, not all four",
inner.get("rows") == ["WP03-HOLD"], inner)
print("\n3. a project with no work packages says so, in a sentence")
visit("/index.html?project=projEmpty")
chk("no cells are rendered", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
empty_txt = page.eval("(document.querySelector('#pipeline-strip .pipe-empty')||{}).textContent||''")
chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt))
chk("...naming the situation rather than showing four zeros",
"no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90]))
chk("...and offering somewhere to go", page.eval(
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"work-package-suite\"]')"))
chk("no zero is rendered as a headline number anywhere in the strip",
page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
print("\n5. a failed request is an error, not a zero and not a memory")
visit("/index.html?project=projA")
before = json.loads(page.eval(CELLS_JS))
chk("the strip has real numbers to lose", len(before) == 4)
page.eval("""(() => {
const real = window.fetch;
window.fetch = function (u, o) {
if (String(u).indexOf('/api/wps/metrics') >= 0) return Promise.reject(new Error('probe offline'));
return real.call(this, u, o);
};
renderPipeline(ProjectData.getActive());
return true;
})()""")
for _ in range(20):
if page.eval("!!document.querySelector('#pipeline-strip .pipe-error')"):
break
time.sleep(0.3)
settle(0.6)
err = page.eval("(document.querySelector('#pipeline-strip .pipe-error')||{}).textContent||''")
chk("an explicit error is shown", bool(err.strip()), repr(err))
chk("...naming the failure", "probe offline" in err, repr(err.strip()[:90]))
chk("...with no cells left showing the numbers from before", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
chk("...and no zeros in their place", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
visit("/index.html?project=projA")
chk("%s: four cells still render" % label, page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 4)
chk("%s: the page does not scroll sideways" % label,
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
chk("%s: every cell is at least a 44px tap target" % label,
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
".every(a => a.getBoundingClientRect().height >= 44)"),
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
".map(a => Math.round(a.getBoundingClientRect().height))"))
page.viewport(1400, 1000)
print("\n no localStorage read sits behind any of these numbers")
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"html", "index.html"), encoding="utf-8").read()
start = src.find("function renderPipeline")
body = src[start:src.find("\n }", start)] if start >= 0 else ""
chk("renderPipeline touches no localStorage", start >= 0 and "localStorage" not in body,
body[:200])
chk("...and reads its numbers from /api/wps/metrics", "/api/wps/metrics" in body)
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-pipeline-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed_two_projects(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nLauncher pipeline strip — B4 surface\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok)
finally:
page.close()
browser.close()
finally:
if server:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
try:
from server.db import engine
engine.dispose()
except Exception:
pass
import shutil
for _ in range(10):
shutil.rmtree(tmpdir, ignore_errors=True)
if not os.path.exists(tmpdir):
break
time.sleep(0.3)
total = len(_PASS) + len(_FAIL)
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
if _FAIL:
for f in _FAIL:
print(" - " + f)
return 1
print("\nResult: " + _c("ALL PASS — four server counts, four shareable links.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())