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

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
@@ -440,6 +443,13 @@
} }
// Reconcile the active project against the list; clear if it's gone. // Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive(); 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; const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null); if(dropped) ProjectData.setActive(null);
renderProjectPicker(); renderProjectPicker();
@@ -527,10 +537,17 @@
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); }); 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){ 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); 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){ function afterProjectChosen(p){
@@ -577,7 +594,22 @@
reflectSOPStatus(active); 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). // Reflect SOP completion on the tool cards (B4).
// //

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">

View File

@@ -291,15 +291,25 @@ window.addEventListener('DOMContentLoaded',()=>{
updateProjectDisplay(); updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE — // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id> | ?step=N.
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab //
// keep opening the dashboard for the rest of the session. // 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'); const tab = params.get('tab');
_deepLinkWp = params.get('wp') || ''; _deepLinkWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard'; const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard'); if(wantDashboard) switchTool('dashboard', {fromUrl:true});
else if(tab === 'wp' || tab === 'sop') switchTool(tab); else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
else if(_deepLinkWp) switchTool('wp'); 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){ if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull); ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -464,8 +474,15 @@ function repopulateForm(){
} }
// ── TOOL SWITCHING ──────────────────────────────────────────────────────────── // ── TOOL SWITCHING ────────────────────────────────────────────────────────────
function switchTool(tool){ function switchTool(tool, opts){
currentTool = tool; 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 // 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
// creator) but opens it straight to the dashboard view. // creator) but opens it straight to the dashboard view.
const isDash = (tool === 'dashboard'); const isDash = (tool === 'dashboard');
@@ -1148,12 +1165,31 @@ function addSource(){
} }
// ── STEP NAVIGATION ──────────────────────────────────────────────────────────── // ── STEP NAVIGATION ────────────────────────────────────────────────────────────
function goToStep(n){ function goToStep(n, opts){
if(!validateStep(currentStep)) return; 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; 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(); 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(){ function nextStep(){
if(!validateStep(currentStep)) return; if(!validateStep(currentStep)) return;
if(currentStep < 10){ if(currentStep < 10){

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">

View File

@@ -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); } 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; 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)); } 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 loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){ function loadPackageIntoForm(p){
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
@@ -1734,6 +1745,11 @@ function duplicateWP(){
} }
function newPackage(){ 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=<id> 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; 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='';}); ['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=''; 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. // 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 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); setFormChrome(false);
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
document.getElementById('pkg-output').style.display='none'; document.getElementById('pkg-output').style.display='none';
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
currentView='Dashboard'; cmtUpdateCurStep(); 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 // 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. // when you last looked are not evidence that they are correct now.
dashMetrics=null; loadDashMetrics(); dashMetrics=null; loadDashMetrics();
@@ -2045,8 +2070,8 @@ function dashIssue(id){
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
dashRefreshAfterWrite(); dashRefreshAfterWrite();
} }
function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } 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); } function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); urlSyncPackage(p.id); }
// ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── // ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
function openSopModal(){ function openSopModal(){
@@ -2197,6 +2222,28 @@ function bootData(){
else toast('That work package is not on this project (it may have been deleted).'); else toast('That work package is not on this project (it may have been deleted).');
} }
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } 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'); track('app_open');
// The embedding shell needs to know when the packages are actually in hand: the // 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 // frame's `load` event fires long before pullProject() resolves, so anything that

View File

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format <!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. --> timestamps during their own boot. -->
<script src="wp-format.js"></script> <script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">

126
html/wp-url.js Normal file
View File

@@ -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);

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