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>
372 lines
18 KiB
Python
372 lines
18 KiB
Python
#!/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())
|