#!/usr/bin/env python3 """The launcher's project entry points — B3 (T5.2). B3's warning is about ORDER: the proposal removes the project-picker card, and the first-run empty state was built inside it. Remove the card first and every brand-new account lands on a page whose only instruction is to choose from a list with nothing in it. So the state that matters most here is the one the fixture does not naturally produce — an account with zero projects — and it is tested against a database seeded with none, not by hiding rows in the browser. 1. a brand-new account with zero projects sees a clear path to create one 2. the sample project stays discoverable from that empty state 3. the picker card is gone, and nothing on the page re-creates it 4. switching projects still works from the app bar for users who have projects 5. creating a project from the launcher still works, end to end 6. the rebuilt form is accessible: labelled, keyboard-reachable, and its validation lands at the field rather than in a native dialog (C1) 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 STUB = """ window.__dialogs = []; window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); }; window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; }; window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; }; true """ READY = "!!(document.getElementById('proj-status') && !document.querySelector('.proj-loading'))" def seed_empty(db_path): """One account, no projects at all. This is the state B3 is about, and no existing fixture has it — browser_check's seeds two projects precisely so the scoping assertions have something to scope.""" os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/") os.environ.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production") from server.db import SessionLocal, Base, engine from server import models, auth Base.metadata.create_all(bind=engine) with SessionLocal() as db: db.add(models.User(id="user_new", username="new", email="new@example.test", full_name="New Starter", password_hash=auth.hash_password(PW), role=auth.ROLE_ADMIN)) db.commit() return {u.username: auth.create_token(u) for u in db.query(models.User).all()} def settle(seconds=1.4): time.sleep(seconds) def js_errors(page): """page.js_errors() minus one known false alarm. A project with no SOP yet answers GET /api/sops/latest with 404, correctly — and the browser logs every 404 resource at error level whatever the app does about it. browser_check.py's fixture seeds a SOP to sidestep this; a probe that CREATES a project cannot, because a brand-new project has no SOP by definition. That is exactly the state under test, so the entry is filtered by name rather than the check being dropped.""" return [e for e in page.js_errors() if "/api/sops/latest" not in e] def visible(page, sel): return page.eval( "(() => { const e = document.querySelector(%r); if (!e) return false; " "const r = e.getBoundingClientRect(); return !!(r.width && r.height); })()" % sel) def run_empty(page, base, tok): print("\n1 + 2. a brand-new account with no projects at all") page.clear_cookies() page.set_cookie("wp_session", tok["new"]) page.goto(base + "/index.html", READY) settle(1.6) page.eval(STUB) chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) chk("it says there are no projects yet", visible(page, "#first-run")) chk("...and explains what a project is for, not just that there are none", len((page.eval("(document.getElementById('first-run')||{}).textContent||''")).strip()) > 120) chk("the create form is open, not behind another click", visible(page, "#new-project") and visible(page, "#np_name")) chk("...headed as the first one", "first" in (page.eval( "(document.getElementById('new-project-head')||{}).textContent||''")).lower(), page.eval("(document.getElementById('new-project-head')||{}).textContent||''")) chk("...with no Cancel, because there is nothing to cancel back to", not visible(page, "#np-cancel")) chk("the hero says create, not select", "reate" in (page.eval("(document.getElementById('hero-sub')||{}).textContent||''")), page.eval("(document.getElementById('hero-sub')||{}).textContent||''")) chk("the sample project is offered from the empty state", visible(page, "#sample-offer")) chk("...as a button that says what it does", "sample" in (page.eval( "(document.getElementById('use-sample-btn')||{}).textContent||''")).lower()) chk("no tool cards are shown over a project that does not exist", not visible(page, "#overview")) chk("...and no 'choose a project' prompt with nothing to choose from", not visible(page, "#pick-prompt")) print("\n6. the rebuilt create form is accessible (C1)") labelled = json.loads(page.eval("""JSON.stringify( [...document.querySelectorAll('#np-form input')].map(i => ({ id: i.id, labelled: !!(i.labels && i.labels.length), describedby: i.getAttribute('aria-describedby'), tabindex: i.tabIndex, })))""")) chk("every input has a real label", labelled and all(f["labelled"] for f in labelled), [f for f in labelled if not f["labelled"]]) chk("...and every one is keyboard reachable", labelled and all(f["tabindex"] >= 0 for f in labelled)) chk("the required field points at its error region", any(f["id"] == "np_name" and f["describedby"] == "np_name_err" for f in labelled), labelled) ring = page.eval("""(() => { const i = document.getElementById('np_name'); i.focus(); if (document.activeElement !== i) return 'not focusable'; const cs = getComputedStyle(i); return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0 ? 'ring ' + cs.outlineWidth + ' ' + cs.outlineColor : 'no ring'; })()""") chk("...and shows a focus ring when focused (BL-014's third site)", str(ring).startswith("ring"), ring) print(" submitting it empty says so at the field, not in a dialog") page.eval("document.getElementById('np-form').requestSubmit()") settle(0.6) chk("an empty submit is refused", visible(page, "#first-run")) chk("...with the reason at the field", bool((page.eval("(document.getElementById('np_name_err')||{}).textContent||''")).strip()), page.eval("(document.getElementById('np_name_err')||{}).textContent||''")) chk("...announced through a live region", page.eval("(document.getElementById('np_name_err')||{}).getAttribute('role')") == "alert") chk("...the field marked invalid", page.eval("document.getElementById('np_name').getAttribute('aria-invalid')") == "true") chk("...focus moved to it", page.eval("(document.activeElement||{}).id") == "np_name") chk("...and no native dialog was opened", not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")), page.eval("JSON.stringify(window.__dialogs||[])")) print("\n5. creating the first project works end to end") page.eval("""(() => { const n = document.getElementById('np_name'); n.value = 'First Job'; n.dispatchEvent(new Event('input', {bubbles:true})); document.getElementById('np_number').value = 'FJ-1'; return true; })()""") page.eval("document.getElementById('np-form').requestSubmit()") for _ in range(30): if page.eval("!document.getElementById('overview') || " "getComputedStyle(document.getElementById('overview')).display !== 'none'"): break time.sleep(0.3) settle(1.2) chk("the new project becomes the active one", page.eval("(ProjectData.getActive()||{}).name") == "First Job", page.eval("JSON.stringify(ProjectData.getActive()||{})")) chk("...the tool cards appear", visible(page, "#overview")) chk("...the first-run state stands down", not visible(page, "#first-run")) chk("...and so does the sample offer", not visible(page, "#sample-offer")) chk("...the hero names the project", page.eval("(document.getElementById('hero-title')||{}).textContent") == "First Job") chk("...and the URL carries it, so the address bar is shareable (S3)", "project=" in page.eval("location.search"), page.eval("location.search")) chk("no JavaScript error through the whole first-run flow", not js_errors(page), js_errors(page)) print("\n2b. the sample project is reachable from the empty state") page.eval("localStorage.clear()") page.goto(base + "/index.html", READY) settle(1.6) page.eval(STUB) # One project exists now, so this is the "projects exist, none active" state. chk("with a project on the account but none chosen, the launcher says to choose", visible(page, "#pick-prompt")) chk("...and points at the app bar's switcher, not at a second list on the page", "switcher" in (page.eval( "(document.getElementById('pick-prompt-sub')||{}).textContent||''")).lower()) chk("...offering New project as well", visible(page, "#new-project-btn")) chk("...with the create form closed until asked for", not visible(page, "#new-project")) chk("...and the first-run state not shown to someone who is not new", not visible(page, "#first-run")) page.eval("document.getElementById('new-project-btn').click()") settle(0.6) chk("New project opens the form", visible(page, "#new-project")) chk("...and moves focus into it", page.eval("(document.activeElement||{}).id") == "np_name") chk("...with Cancel available now that there is something to cancel to", visible(page, "#np-cancel")) page.eval("document.getElementById('np-cancel').click()") settle(0.5) chk("Cancel closes it again", not visible(page, "#new-project")) chk("...and brings the choose-a-project prompt back", visible(page, "#pick-prompt")) def run_populated(page, base, tok): print("\n3 + 4. the picker card is gone; the app bar still switches") page.clear_cookies() page.set_cookie("wp_session", tok["root"]) page.goto(base + "/index.html?project=projA", READY) settle(1.8) page.eval(STUB) chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) chk("the picker card is gone", page.eval("!document.getElementById('project-section')")) chk("...and so is its dropdown", page.eval("!document.getElementById('project-select')")) chk("...and nothing else on the page lists projects to pick from", page.eval("""(() => { const inMain = [...document.querySelectorAll('.container select')]; return inMain.filter(s => s.options.length > 1).length === 0; })()"""), page.eval("[...document.querySelectorAll('.container select')].map(s => s.id)")) chk("the app bar carries a project switcher", page.eval("!!document.querySelector('.wp-appbar .wpc-proj-btn')")) chk("...naming the active project", "Job A" in (page.eval( "(document.querySelector('.wpc-proj-name')||{}).textContent||''")), page.eval("(document.querySelector('.wpc-proj-name')||{}).textContent||''")) page.eval("document.querySelector('.wpc-proj-btn').click()") settle(0.6) chk("...it opens", page.eval( "document.querySelector('.wpc-proj-btn').getAttribute('aria-expanded')") == "true") chk("...and lists both projects on the account", page.eval("document.querySelectorAll('.wpc-pop .wpc-item').length") == 2, page.eval("document.querySelectorAll('.wpc-pop .wpc-item').length")) chk("...its footer offers a new project rather than a list that moved", "New project" in (page.eval( "(document.querySelector('.wpc-pop-foot')||{}).textContent||''")), page.eval("(document.querySelector('.wpc-pop-foot')||{}).textContent||''")) chk("...pointing at the launcher's form", "#new-project" in (page.eval( "(document.querySelector('.wpc-foot-btn')||{}).getAttribute('href')||''") or ""), page.eval("(document.querySelector('.wpc-foot-btn')||{}).getAttribute('href')||''")) page.eval("""[...document.querySelectorAll('.wpc-pop .wpc-item')] .find(b => /Job B/.test(b.textContent)).click()""") for _ in range(30): if "projB" in page.eval("location.search"): break time.sleep(0.3) settle(1.6) chk("switching from the bar changes project", "projB" in page.eval("location.search"), page.eval("location.search")) chk("...and the launcher follows it", page.eval("(document.getElementById('hero-title')||{}).textContent") == "Job B", page.eval("(document.getElementById('hero-title')||{}).textContent")) chk("...with the tool cards still shown", visible(page, "#overview")) chk("...and no first-run or choose-a-project state on a page that has both", not visible(page, "#first-run") and not visible(page, "#pick-prompt")) print("\n the app bar's 'New project' link lands on the form") page.goto(base + "/index.html#new-project", READY) settle(1.6) chk("arriving at #new-project opens the create form", visible(page, "#new-project")) chk("...and not the first-run state, on an account that has projects", not visible(page, "#first-run")) print("\n both widths") for w, label in ((390, "390px"), (1440, "1440px")): page.viewport(w, 900, mobile=(w == 390)) page.goto(base + "/index.html?project=projA", READY) settle(1.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: the switcher is reachable" % label, page.eval("""(() => { const b = document.querySelector('.wpc-proj-btn'); if (!b) return false; const r = b.getBoundingClientRect(); return r.width > 0 && r.right <= window.innerWidth + 1; })()""")) page.viewport(1400, 1000) def one_run(seeder, body, title): exe = cdp.find_browser() if not exe: print("no headless-capable browser found; set WP_BROWSER.") return 2 tmpdir = tempfile.mkdtemp(prefix="wpsuite-launcher-") db_path = os.path.join(tmpdir, "check.db") server = None try: tok = seeder(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("\n%s\nTarget: %s" % (title, base)) browser = cdp.Browser(exe) page = browser.page() # Trap 5, in reverse: without focus emulation the headless document is # not the focused one, :focus-visible never matches, and every control # reports NO ring — a false red where a11y_check.py would get a false # green. Same switch, same reason. page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True}) try: body(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) return 0 PHASES = { "empty": (seed_empty, run_empty, "Launcher — a brand-new account (B3)"), "populated": (seed, run_populated, "Launcher — an account with projects (B3)"), } def phase_main(name): seeder, body, title = PHASES[name] rc = one_run(seeder, body, title) if rc == 2: return 2 total = len(_PASS) + len(_FAIL) print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total)) for f in _FAIL: print(" - " + f) print("RESULT %d %d" % (len(_PASS), total)) return 1 if _FAIL else 0 def main(): # The two states are DATABASE states, not view states — faking "no projects" # in the browser would test the fake — so each phase needs its own database. # # And each needs its own PROCESS. server/db.py builds its engine at import # time from DATABASE_URL, so a second seed() in the same interpreter still # points at the first phase's database, which has been deleted by then. That # surfaces as "unable to open database file", which reads like a broken test # environment rather than what it is. if len(sys.argv) > 1 and sys.argv[1] in PHASES: return phase_main(sys.argv[1]) passed = total = 0 worst = 0 for name in ("empty", "populated"): proc = subprocess.run([sys.executable, os.path.abspath(__file__), name], capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) for line in proc.stdout.splitlines(): if line.startswith("RESULT "): _, p, t = line.split() passed += int(p) total += int(t) continue print(line) if proc.stderr.strip(): print(proc.stderr.strip()[-2000:]) worst = max(worst, proc.returncode) # A fresh browser per phase, and a pause between: started back to back # they exhaust the headless browser's ports and abort with "browser would # not start", which looks like a code fault and is not one. time.sleep(2.0) print("\n%s\n%d/%d checks passed." % ("=" * 54, passed, total)) if worst: return worst print("\nResult: " + _c("ALL PASS — new accounts have a way in; switching moved to the bar.", "32") + "\n") return 0 if __name__ == "__main__": sys.exit(main())