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())