diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index ae0da09..a2d7149 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -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:
diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md
index c607536..bbb5969 100644
--- a/docs/waves/backlog.md
+++ b/docs/waves/backlog.md
@@ -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
diff --git a/html/index.html b/html/index.html
index 742fceb..96ba243 100644
--- a/html/index.html
+++ b/html/index.html
@@ -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 @@
+
+
+ Work package pipeline
+
+
+
@@ -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 = '
Counting work packages…
';
+
+ 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 = `
No work packages on this project yet.
+
Open the creator to write the first one.
`;
+ return;
+ }
+ strip.innerHTML = PIPE_CELLS.map(c => `
+
+ ${Number(m[c.key] || 0)}
+ ${esc(c.label)}
+ ${esc(c.sub)}
+ `).join('');
+ })
+ .catch(err => {
+ if(ProjectData.getActiveId() !== active.id) return;
+ strip.setAttribute('aria-busy', 'false');
+ strip.innerHTML = `
⚠ Could not load work package counts —
+ ${esc((err && err.message) || 'offline')}. These numbers come from the server;
+ nothing stale is shown in their place.
`;
+ });
}
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }
diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js
index 71c87ce..94e3a11 100644
--- a/html/work-package-suite-app.js
+++ b/html/work-package-suite-app.js
@@ -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 */ }
};
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index 72be0b4..a34bea6 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -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;
}
diff --git a/tests/pipeline_check.py b/tests/pipeline_check.py
new file mode 100644
index 0000000..57bac0d
--- /dev/null
+++ b/tests/pipeline_check.py
@@ -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
, 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())