#!/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 used to be the one with a seam in it: the dashboard was an iframe CHILD of the SOP page, so the filter had to cross that boundary and the probe read it inside the frame. B7/T7.1 dissolved the frame and the cell links straight at the creator, so the filter arrives in the page's own URL. The check still reads the rendered board 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 # 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 arrives, on a page rather than in a frame") onhold_href = [h for h in hrefs if "flag=onhold" in h][0] page.goto(base + "/" + onhold_href.lstrip("/")) for _ in range(40): if page.eval("!!window.wpCreatorReady"): break time.sleep(0.3) settle(2.0) # This used to reach through `#wp-frame`.contentDocument, and had to: the # dashboard was an iframe CHILD of the SOP page, so the filter was handed # across rather than read from the child's own URL, and the only honest place # to check it was inside the frame. B7/T7.1 dissolved the frame - the link now # opens the creator directly and the filter arrives in its own URL, so this is # an ordinary read of an ordinary page. # # What has NOT changed is why the BOARD is read rather than the globals: # `dashFilter` and `currentView` are declared with let in a classic script, so # they are not properties of window. The board itself is still the evidence. inner = json.loads(page.eval("""(() => { try { const dv = document.getElementById('dashboard-view'); const active = document.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 = [...document.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, framed: !!document.getElementById('wp-frame'), }); } 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", (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) chk("...and no iframe was involved (B7/T7.1)", inner.get("framed") is False, 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])) # Re-pointed at T7.1, not relaxed. The link said "Open the creator" and went to # the suite page, because the creator was a tab of it. It is a page now, so the # link goes there directly - and the check still demands a link to the place # the sentence offers, rather than merely a link. chk("...and offering somewhere to go, which is the creator", page.eval( "!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"wp-creation-index\"]')"), page.eval("(document.querySelector('#pipeline-strip .pipe-empty a')||{}).getAttribute" " ? document.querySelector('#pipeline-strip .pipe-empty a').getAttribute('href')" " : '(no link)'")) 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())