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:
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
126
html/wp-url.js
Normal 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
239
tests/url_state_check.py
Normal 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())
|
||||||
Reference in New Issue
Block a user