From b54034db04ac4b7f14a691733ff4bcc251357896 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Sat, 15 Aug 2026 19:32:31 -0500 Subject: [PATCH] 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) --- html/admin.html | 3 + html/field.html | 3 + html/index.html | 38 +++++- html/users.html | 3 + html/work-package-suite-app.js | 54 ++++++-- html/work-package-suite.html | 3 + html/wp-creation-app.js | 55 +++++++- html/wp-creation-index.html | 3 + html/wp-url.js | 126 +++++++++++++++++ tests/url_state_check.py | 239 +++++++++++++++++++++++++++++++++ 10 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 html/wp-url.js create mode 100644 tests/url_state_check.py diff --git a/html/admin.html b/html/admin.html index 87cbf03..e03d5a9 100644 --- a/html/admin.html +++ b/html/admin.html @@ -8,6 +8,9 @@ + + diff --git a/html/field.html b/html/field.html index 1830d72..1a1bac7 100644 --- a/html/field.html +++ b/html/field.html @@ -8,6 +8,9 @@ + + diff --git a/html/index.html b/html/index.html index e0d6fe7..744bc88 100644 --- a/html/index.html +++ b/html/index.html @@ -8,6 +8,9 @@ + + @@ -440,6 +443,13 @@ } // Reconcile the active project against the list; clear if it's gone. const active = ProjectData.getActive(); + // S3: if a project is active but the URL does not say so, make the URL say + // so — with replace, not push, because the user did not navigate here. This + // is what makes "copy the address bar" produce a link that lands somebody + // else on the same project rather than on whatever they last had open. + if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){ + WPUrl.replace({ project: active.id }); + } const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null; if(dropped) ProjectData.setActive(null); renderProjectPicker(); @@ -527,10 +537,17 @@ ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); }); } + // S3: which project you are in is addressable, so a launcher link carries it + // and Back returns to the project you were looking at before. + function urlSyncProject(id, replace){ + if(typeof WPUrl === 'undefined') return; + (replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' }); + } + function selectProject(id){ - if(!id){ ProjectData.setActive(null); applyActiveProject(); return; } + if(!id){ ProjectData.setActive(null); urlSyncProject('', false); applyActiveProject(); return; } const p = _projects.find(x => x.id === id); - if(p){ ProjectData.setActive(p); applyActiveProject(); } + if(p){ ProjectData.setActive(p); urlSyncProject(p.id, false); applyActiveProject(); } } function afterProjectChosen(p){ @@ -577,7 +594,22 @@ reflectSOPStatus(active); } - function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); } + function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectPicker(); applyActiveProject(); } + + // Back / Forward between projects. + if(typeof WPUrl !== 'undefined'){ + WPUrl.onChange(function(state, viaPop){ + if(!viaPop) return; + const want = state.project || ''; + if(want === (ProjectData.getActiveId() || '')) return; + if(!want){ ProjectData.setActive(null); } + else { + const p = (_projects||[]).find(x=>x.id===want); + ProjectData.setActive(p || { id: want }); + } + renderProjectPicker(); applyActiveProject(); + }); + } // Reflect SOP completion on the tool cards (B4). // diff --git a/html/users.html b/html/users.html index 15293be..783a176 100644 --- a/html/users.html +++ b/html/users.html @@ -8,6 +8,9 @@ + + diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index a8bbedc..d0111be 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -291,15 +291,25 @@ window.addEventListener('DOMContentLoaded',()=>{ updateProjectDisplay(); loadProjectUsers(); // team pickers: who's on this project applyBimFlag(); // hide the BIM section unless an admin enabled it - // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=. Consumed ONCE — - // leaving ?view=dashboard in the URL used to make the Work Package Creation tab - // keep opening the dashboard for the rest of the session. + // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp= | ?step=N. + // + // These used to be consumed ONCE, because a leftover ?view=dashboard made the + // Work Package Creation tab keep reopening the dashboard for the rest of the + // session. Since T4.2 the URL is not a one-shot instruction — it TRACKS the + // current state and switchTool() clears `view` when you leave the dashboard — + // so the param can be honoured every time and a refresh lands where you were. + // + // fromUrl on every call: the URL already says this, so restoring it must not + // add a history entry. Without that, the first Back after loading a deep link + // would just return you to the same view. const tab = params.get('tab'); _deepLinkWp = params.get('wp') || ''; const wantDashboard = params.get('view') === 'dashboard'; - if(wantDashboard) switchTool('dashboard'); - else if(tab === 'wp' || tab === 'sop') switchTool(tab); - else if(_deepLinkWp) switchTool('wp'); + if(wantDashboard) switchTool('dashboard', {fromUrl:true}); + else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true}); + else if(_deepLinkWp) switchTool('wp', {fromUrl:true}); + const bootStep = parseInt(params.get('step'), 10); + if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true}); } if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ ProjectData.pullProject(projId).then(afterPull).catch(afterPull); @@ -464,8 +474,15 @@ function repopulateForm(){ } // ── TOOL SWITCHING ──────────────────────────────────────────────────────────── -function switchTool(tool){ +function switchTool(tool, opts){ currentTool = tool; + // S3: which tool is open is addressable state. `fromUrl` is set when we are + // 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)){ + WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' } + : { tab: tool, view: '' }); + } // 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded // creator) but opens it straight to the dashboard view. const isDash = (tool === 'dashboard'); @@ -1148,12 +1165,31 @@ function addSource(){ } // ── STEP NAVIGATION ──────────────────────────────────────────────────────────── -function goToStep(n){ - if(!validateStep(currentStep)) return; +function goToStep(n, opts){ + const fromUrl = !!(opts && opts.fromUrl); + // Restoring a step from the URL is not a forward navigation, so it must not run + // the forward-navigation guard. validateStep() ends in alert() when a required + // field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared + // link to step 3 opened a modal dialog before the page had finished booting. + if(!fromUrl && !validateStep(currentStep)) return; currentStep = n; + // S3: the step you are on survives a refresh and a shared link. + if(typeof WPUrl !== 'undefined' && !fromUrl) WPUrl.push({ step: n > 1 ? n : '' }); updateStepUI(); } +// S3: Back / Forward across tools and steps. The URL is the state, so restoring is +// "read it and show that" rather than a bespoke undo stack. +if(typeof WPUrl !== 'undefined'){ + WPUrl.onChange(function(state, viaPop){ + if(!viaPop) return; + const wantTool = state.view === 'dashboard' ? 'dashboard' : (state.tab || 'sop'); + if(wantTool !== currentTool) switchTool(wantTool, {fromUrl:true}); + const step = parseInt(state.step, 10); + if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true}); + }); +} + function nextStep(){ if(!validateStep(currentStep)) return; if(currentStep < 10){ diff --git a/html/work-package-suite.html b/html/work-package-suite.html index ebb1cf0..12dffd5 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -8,6 +8,9 @@ + + diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index a75665b..a2511ad 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -1649,7 +1649,18 @@ function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } function clearSaved(){ if(!savedPackages.length) return; if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } -function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); } +function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); } + +// S3: the open package IS the page's state, so it belongs in the URL. This is the +// address CR-011 and CR-014 send people to (X1), so it has to survive being pasted +// into an email and opened by someone whose browser has never seen this project - +// which is why the project id rides along. +function urlSyncPackage(id, opts){ + if(typeof WPUrl === 'undefined') return; + const patch = { wp: id || '', view: '' }; + if(activeProjectId) patch.project = activeProjectId; + (opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch); +} function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadPackageIntoForm(p){ pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly @@ -1734,6 +1745,11 @@ function duplicateWP(){ } function newPackage(){ + // Only clear the address once the app is up. bootSOP() calls this during boot, so + // without the guard a deep link to ?wp= had its own parameter deleted by the + // page it was opening - the link worked, then erased itself, and Back had nothing + // to return to. + if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true}); editingId=null; ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; @@ -1844,12 +1860,21 @@ function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todaySt // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. function countableWPs(){ return WPData.list().filter(p=>!p.split); } -function showDashboard(){ +// Restoring the dashboard because the user pressed Back: same rendering, no new +// history entry. +function showDashboardFromUrl(){ showDashboard({fromUrl:true}); } +function showDashboard(opts){ + const fromUrl = !!(opts && opts.fromUrl); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display='none'; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; currentView='Dashboard'; cmtUpdateCurStep(); + if(typeof WPUrl !== 'undefined' && !fromUrl){ + const patch = { view: 'dashboard', wp: '' }; + if(activeProjectId) patch.project = activeProjectId; + WPUrl.push(patch); + } // Ask the server every time the dashboard is opened. Counts that were correct // when you last looked are not evidence that they are correct now. dashMetrics=null; loadDashMetrics(); @@ -2045,8 +2070,8 @@ function dashIssue(id){ WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); dashRefreshAfterWrite(); } -function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } -function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } +function dashView(i){ if(savedPackages[i]){ renderPackage(savedPackages[i]); urlSyncPackage(savedPackages[i].id); } } +function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); urlSyncPackage(p.id); } // ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── function openSopModal(){ @@ -2197,6 +2222,28 @@ function bootData(){ else toast('That work package is not on this project (it may have been deleted).'); } if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } + + // 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 + // Back would immediately add a new entry and the button would appear to do nothing. + if(typeof WPUrl !== 'undefined'){ + WPUrl.onChange(function(state, viaPop){ + if(!viaPop) return; + if(state.view === 'dashboard'){ + if(currentView !== 'Dashboard') showDashboardFromUrl(); + return; + } + const want = state.wp || ''; + if(want){ + const ix = savedPackages.findIndex(x => x.id === want); + if(ix >= 0){ hideDashboard(); currentView='Form'; editingId=savedPackages[ix].id; + loadPackageIntoForm(savedPackages[ix]); renderWpNav(); } + } else if(currentView === 'Dashboard'){ + hideDashboard(); setFormChrome(true); currentView='Form'; + document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display=''); + } + }); + } track('app_open'); // The embedding shell needs to know when the packages are actually in hand: the // frame's `load` event fires long before pullProject() resolves, so anything that diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 75f7b8d..5033625 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -8,6 +8,9 @@ + + diff --git a/html/wp-url.js b/html/wp-url.js new file mode 100644 index 0000000..8727e62 --- /dev/null +++ b/html/wp-url.js @@ -0,0 +1,126 @@ +/* Addressable state — S3 / T4.2. + --------------------------------------------------------------------------- + Before this file there was no pushState anywhere in the suite. Every page read + its query string once at boot and never wrote one again, so: + + • you could not send anyone a link to WP07 — the URL said the same thing + whatever you were looking at; + • a refresh dropped you back at the default view; + • Back left the app entirely, because the app had never added a history entry. + + CR-011 and CR-014 both promise an email containing a direct link to a work + package (X1). Those emails cannot exist until a work package has an address, + which is what this provides. + + WHAT IT IS NOT: a router. Nothing here 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, not a hash: the server serves these paths already, so a hash + would be a workaround for a problem this app does not have, and hashes are not + sent to the server — which matters the day a link needs to be resolved before + the page boots. + + Nothing secret goes in the URL. It is copied into emails, chat and tickets. +*/ +(function (window, document) { + 'use strict'; + + var listeners = []; + var LAST = serialize(current()); + + function current() { + var out = {}; + try { + new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; }); + } catch (e) {} + return out; + } + + function serialize(state) { + var keys = Object.keys(state).filter(function (k) { + return state[k] !== '' && state[k] != null && state[k] !== false; + }).sort(); + var sp = new URLSearchParams(); + keys.forEach(function (k) { sp.set(k, String(state[k])); }); + return sp.toString(); + } + + // Merge a patch over the current state. Undefined/null/'' removes a key, so a + // caller can clear `wp` without having to know what else is in the URL — the + // usual reason ad-hoc URL building loses the active project. + function merge(patch) { + var next = current(); + Object.keys(patch || {}).forEach(function (k) { + var v = patch[k]; + if (v === undefined || v === null || v === '' || v === false) delete next[k]; + else next[k] = v; + }); + return next; + } + + function href(patch) { + var qs = serialize(merge(patch)); + return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; + } + + function apply(patch, opts) { + opts = opts || {}; + var next = merge(patch); + var qs = serialize(next); + if (qs === LAST && !opts.force) return false; // nothing to record + var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; + try { + if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url); + else window.history.pushState({ wpurl: qs }, '', url); + } catch (e) { + return false; // file:// and the like + } + LAST = qs; + return true; + } + + function notify(state, viaPop) { + listeners.forEach(function (fn) { + try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ } + }); + } + + window.addEventListener('popstate', function () { + LAST = serialize(current()); + notify(current(), true); + }); + + window.WPUrl = { + // Read one parameter, or everything. + get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); }, + all: current, + + /* Record a state change in history. Merges over what is already there. + WPUrl.push({ wp: id }) -> new history entry, Back returns + WPUrl.push({ wp: '' }) -> clears it + WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry + replace() is for normalising on load or for a change the user did not ask + for; push() is for one they did, because Back should undo exactly the + things they chose to do. */ + push: function (patch) { return apply(patch, { replace: false }); }, + replace: function (patch) { return apply(patch, { replace: true }); }, + + // A URL string for the same merge, without navigating. For hrefs and for the + // links that go into CR-011 / CR-014 emails. + href: href, + absolute: function (patch) { + return window.location.origin + href(patch); + }, + + /* Subscribe to state changes. Called on Back/Forward with viaPop === true. + Returns an unsubscribe function. */ + onChange: function (fn) { + listeners.push(fn); + return function () { + var i = listeners.indexOf(fn); + if (i >= 0) listeners.splice(i, 1); + }; + }, + }; +})(window, document); diff --git a/tests/url_state_check.py b/tests/url_state_check.py new file mode 100644 index 0000000..436b294 --- /dev/null +++ b/tests/url_state_check.py @@ -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())