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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
108
html/index.html
108
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 @@
|
||||
</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(); }
|
||||
|
||||
@@ -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 */ }
|
||||
};
|
||||
|
||||
@@ -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
371
tests/pipeline_check.py
Normal 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())
|
||||
Reference in New Issue
Block a user