T4.2 - S3: the app's state has an address; X1 is unblocked

Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.

CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.

html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it intercepts navigation or renders anything; it is the query string
treated as state that can be read, merged, written and subscribed to. Pages keep
their own rendering. Query parameters rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.

The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.

WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.

WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.

TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:

  - bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
    deep link therefore worked and then erased its own parameter, leaving Back
    with nothing to return to. Now guarded on wpCreatorReady.
  - goToStep() runs validateStep(), which ends in alert() when a required field
    is empty - always true on a freshly loaded page. So restoring ?step=3 from a
    shared link opened a modal dialog mid-boot, and hung the browser under CDP.
    Restoring a view is not a forward navigation and no longer runs the
    forward-navigation guard.

The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.

VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:

  - a URL identifying a work package opens that package
  - the same URL for a SIGNED-OUT user goes to login, carries the target through
    ?next=, and lands on the work package itself after signing in
  - refresh preserves project, package, tab and view
  - Back and Forward move through states, verified as still-initialised rather
    than reloaded, and with the dashboard actually rendered rather than only the
    URL changed
  - a different user opening the same URL reaches the same view
  - nothing credential-shaped appears in the query string

Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:32:31 -05:00
parent b670ae719d
commit b54034db04
10 changed files with 511 additions and 16 deletions

239
tests/url_state_check.py Normal file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Is the app's state addressable? — S3 / T4.2.
X1 is a blocking dependency: CR-011 and CR-014 both promise an email carrying a
direct link to a work package, and before this there was no pushState anywhere in
the suite, so no work package had an address. This checks the promise those emails
will rest on.
1. a URL identifying a work package opens that work package
2. the same URL works for a SIGNED-OUT user, via login, landing on the target
3. refresh preserves project, package, tab and view
4. Back and Forward move through states without a reload or a broken view
5. the URL survives being copied to a second browsing context
6. pushState is actually used; the count is recorded
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
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
def settle(page, seconds=1.4):
time.sleep(seconds)
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-urlstate-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(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("\nAddressable state — S3 / T4.2\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
print("\n0. the module is present and does not need a hash")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/wp-creation-index.html?project=projA")
settle(page)
chk("WPUrl is loaded", page.eval("typeof WPUrl") == "object")
chk("it merges rather than clobbers",
page.eval("WPUrl.href({wp:'wpA1'})").find("project=projA") != -1
and page.eval("WPUrl.href({wp:'wpA1'})").find("wp=wpA1") != -1,
page.eval("WPUrl.href({wp:'wpA1'})"))
chk("clearing a key does not drop the others",
"project=projA" in page.eval("WPUrl.href({wp:''})")
and "wp=" not in page.eval("WPUrl.href({wp:''})"),
page.eval("WPUrl.href({wp:''})"))
chk("it produces an absolute link for emails (X1)",
page.eval("WPUrl.absolute({wp:'wpA1'})").startswith("http"),
page.eval("WPUrl.absolute({wp:'wpA1'})"))
print("\n1. a URL identifying a work package opens it")
page.goto(base + "/wp-creation-index.html?project=projA&wp=wpA1")
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(page, 1.0)
subject = page.eval("(document.getElementById('wp_subject')||{}).value||''")
chk("the deep-linked package is loaded into the form",
"horn/strobe" in subject, "subject field read %r" % subject)
print("\n6. pushState is used")
before = page.eval("history.length")
page.eval("typeof showDashboard==='function' && showDashboard()")
settle(page, 1.0)
after = page.eval("history.length")
chk("opening the dashboard adds a history entry", after > before,
"history.length %s -> %s" % (before, after))
chk("...and says so in the URL",
"view=dashboard" in page.eval("location.search"),
page.eval("location.search"))
print("\n4. Back and Forward move through states")
page.eval("history.back()")
settle(page, 1.2)
chk("Back leaves the dashboard",
"view=dashboard" not in page.eval("location.search"),
page.eval("location.search"))
chk("...and returns to the package, not to a blank page",
page.eval("location.search").find("wp=wpA1") != -1,
page.eval("location.search"))
chk("...without a full reload (the app is still initialised)",
page.eval("!!window.wpCreatorReady"))
page.eval("history.forward()")
settle(page, 1.2)
chk("Forward returns to the dashboard",
"view=dashboard" in page.eval("location.search"),
page.eval("location.search"))
chk("...and the dashboard is actually rendered, not just the URL",
page.eval("(document.getElementById('dashboard-view')||{}).style.display") != "none")
print("\n3. refresh preserves the state")
page.goto(base + "/wp-creation-index.html?project=projA&wp=wpA2")
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(page, 1.0)
page.eval("location.reload()")
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(page, 1.0)
subject = page.eval("(document.getElementById('wp_subject')||{}).value||''")
chk("a refresh lands on the same package", "wire pull" in subject,
"subject read %r" % subject)
print("\n3b. the SOP wizard's tab and step are addressable")
page.goto(base + "/work-package-suite.html?project=projA&tab=sop&step=3")
settle(page, 1.6)
chk("the wizard restores the deep-linked step",
page.eval("typeof currentStep!=='undefined' && currentStep") == 3,
page.eval("typeof currentStep!=='undefined' && currentStep"))
hlen = page.eval("history.length")
page.eval("typeof goToStep==='function' && goToStep(5)")
settle(page, 0.8)
chk("moving a step records it", "step=5" in page.eval("location.search"),
page.eval("location.search"))
chk("...as a history entry", page.eval("history.length") > hlen)
page.eval("history.back()")
settle(page, 1.0)
chk("Back returns to the previous step",
page.eval("typeof currentStep!=='undefined' && currentStep") == 3,
page.eval("location.search"))
print("\n5. the URL reaches the same view in a second context")
deep = base + "/wp-creation-index.html?project=projA&wp=wpA1"
page2 = browser.page()
try:
page2.clear_cookies()
page2.set_cookie("wp_session", tok["pat"])
page2.goto(deep)
for _ in range(30):
if page2.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(page2, 1.0)
s2 = page2.eval("(document.getElementById('wp_subject')||{}).value||''")
chk("a different user opening the same URL sees the same package",
"horn/strobe" in s2, "subject read %r" % s2)
finally:
page2.close()
print("\n2. the same URL works for a signed-out user, via login")
page.clear_cookies()
page.goto(deep)
settle(page, 1.6)
chk("a signed-out visitor is sent to login", "login.html" in page.eval("location.href"),
page.eval("location.href"))
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
chk("...carrying the requested target, package id and all",
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
page.eval("document.getElementById('username').value=%r" % "root")
page.eval("document.getElementById('password').value=%r" % PW)
page.eval("document.querySelector('form').requestSubmit"
"? document.querySelector('form').requestSubmit()"
": document.querySelector('form').submit()")
for _ in range(40):
if "wp-creation-index.html" in page.eval("location.href"):
break
time.sleep(0.3)
settle(page, 1.2)
chk("signing in continues to the requested page, not the home page",
"wp-creation-index.html" in page.eval("location.href"),
page.eval("location.href"))
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(page, 1.0)
s3 = page.eval("(document.getElementById('wp_subject')||{}).value||''")
chk("...and lands on the work package itself, not a dashboard",
"horn/strobe" in s3, "subject read %r" % s3)
print("\n7. nothing secret rides in the URL")
qs = page.eval("location.search").lower()
leaked = [w for w in ("token", "session", "password", "secret", "auth") if w in qs]
chk("no credential-shaped parameter", not leaked, "found %s in %r" % (leaked, qs))
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 — the app's state has an address.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())