From ab3c9e52d127977ad5ecb27878f51fe3ee0bfbd5 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Sat, 15 Aug 2026 21:56:40 -0500 Subject: [PATCH] T4.3 - S2: autosave, an unsaved-work guard that means it, and draft recovery The work package form is ~4,700px tall and had no autosave and no unsaved-work guard. The only beforeunload listener in the app was analytics dwell tracking, so a mis-click, a closed tab or a crash lost everything typed since the last Save. html/wp-autosave.js separates three things this app was conflating: THE DRAFT what you have typed. Saved locally, continuously, by this file. THE RECORD what you explicitly Saved, which goes to the project. THE OUTBOX project-data.js, which gets the RECORD to the server reliably. This module owns the first only and never writes to the server. A draft is "unfinished work this browser is holding for you"; pushing unfinished work into a shared project is a different feature with different consequences. The guard fires only when the form differs from what was loaded. "Do not fire the guard when nothing has changed" is in the task because a dialog that appears on every exit gets clicked through within a day, and is then worse than no dialog. WIRED: the creator's package form and the SOP wizard's state. Both autosave on a 1200ms debounce, on section/step change, and on visibilitychange - the last being what makes recovery survive a killed tab, since a crash never fires beforeunload. The wizard's guard is ADDED alongside trackStepDwell, not in place of it; both fire and the analytics one does not preventDefault. THREE BUGS FOUND WHILE BUILDING THIS, all by the probe rather than by reading: - Dirtiness cannot be "does the form match savedPackages". Those records come back from the server through serverToPkg() in a LEANER shape - 264 characters against the form's 1,820 - so a freshly loaded, untouched form differed from its own record and every single exit would have prompted. Dirtiness is now measured against a baseline snapshot taken when the form is populated. - currentView is 'Work Package Form', not 'Form'. My first guard compared against 'Form' and therefore returned false always: autosave was wired, registered, and quietly dead. T4.2 had also introduced currentView='Form' in its popstate handler; that is fixed here too, since it would have broken this and anything else keyed off the view. - settled() has to cancel the pending debounce. A save follows typing, so there is nearly always a write already scheduled; without cancelling it the write lands a second later and resurrects the draft that was just settled - and the next load offers to recover work that is already saved. VERIFICATION. tests/autosave_check.py, 23 checks, all passing: - typing autosaves unprompted; the draft holds what was typed; it is scoped to project AND package; and it does NOT appear in the outbox - an untouched form is not dirty and arms no guard; a typed-in one does - the draft survives a killed tab and is OFFERED back rather than applied silently, saying plainly that nothing reached the project, via role=status - restoring puts the work back in the form - an explicit save settles the draft, and the probe asserts the save actually landed first - otherwise the rest of that section proves nothing - a simulated QuotaExceededError is reported as 'failed' with its reason, not swallowed; a silent autosave failure is a safety net that is not there - trackStepDwell still records an event Two notes for later waves. The fixture's SOP defines no WP types, so savePackage() legitimately refuses until the probe supplies one - worth knowing before someone reads that as a bug. And native dialogs hung the headless browser twice more in this task; with 79 of them in the app, any restore or save path that reaches one will hang a test rather than fail visibly. S6/S7 in wave 9. browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, url_state 23/23. Co-Authored-By: Claude Opus 5 (1M context) --- html/work-package-suite-app.js | 33 +++++ html/work-package-suite.html | 2 + html/wp-autosave.js | 172 +++++++++++++++++++++++ html/wp-creation-app.js | 106 +++++++++++++- html/wp-creation-index.html | 2 + tests/autosave_check.py | 245 +++++++++++++++++++++++++++++++++ 6 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 html/wp-autosave.js create mode 100644 tests/autosave_check.py diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index d0111be..daa4d6d 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -333,8 +333,39 @@ window.addEventListener('DOMContentLoaded',()=>{ } }); }); +// Analytics dwell. S2 adds a SECOND beforeunload listener in wp-autosave.js for the +// unsaved-work guard; both fire, and this one does not preventDefault, so the two do +// not interact. The task says to add the guard "alongside the analytics listener +// rather than replacing it" - this is the listener it means. window.addEventListener('beforeunload', trackStepDwell); +// ── AUTOSAVE (S2) ──────────────────────────────────────────────────────────── +// The wizard already wrote its state to localStorage on save; what it lacked was +// writing it WITHOUT being asked, and telling anyone when that failed. +function sopDraftId(){ + const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || 'none'; + return 'sop-wizard::' + pid; +} +function sopIsDirty(){ + if(currentTool !== 'sop') return false; + try { + collectStepData(); + return JSON.stringify(state) !== _sopSavedFingerprint; + } catch(e){ return false; } +} +let _sopSavedFingerprint = ''; +function sopMarkSaved(){ try { _sopSavedFingerprint = JSON.stringify(state); } catch(e){} } + +document.addEventListener('DOMContentLoaded', function(){ + if(typeof WPAutosave === 'undefined') return; + sopMarkSaved(); + WPAutosave.register({ + id: 'sop-wizard', scope: document, draftId: sopDraftId, + collect: function(){ collectStepData(); return state; }, + isDirty: sopIsDirty, + }); +}); + function initializeWPTypes(){ state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''})); renderWPTypes(); @@ -1173,6 +1204,7 @@ function goToStep(n, opts){ // link to step 3 opened a modal dialog before the page had finished booting. if(!fromUrl && !validateStep(currentStep)) return; currentStep = n; + if(typeof WPAutosave !== 'undefined') WPAutosave.flush('step'); // 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(); @@ -1395,6 +1427,7 @@ function completeSOP(){ try { localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop)); localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state)); + if(typeof WPAutosave !== 'undefined'){ sopMarkSaved(); WPAutosave.settled(sopDraftId()); } localStorage.setItem(SK('wp_suite_sop_complete'), '1'); } catch(e){} diff --git a/html/work-package-suite.html b/html/work-package-suite.html index 12dffd5..8e1201f 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -11,6 +11,8 @@ + + diff --git a/html/wp-autosave.js b/html/wp-autosave.js new file mode 100644 index 0000000..c03d4ef --- /dev/null +++ b/html/wp-autosave.js @@ -0,0 +1,172 @@ +/* Autosave, unsaved-work guard and draft recovery — S2 / T4.3. + --------------------------------------------------------------------------- + The work package form is roughly 4,700px tall and there was no autosave and no + unsaved-work guard on it. The only beforeunload listener in the app was + analytics dwell tracking (work-package-suite-app.js), so a mis-click, a closed + tab or a crash lost everything typed since the last explicit Save. + + Three separate things, often confused: + + THE DRAFT what you have typed, saved here, locally, continuously. + THE RECORD what you have explicitly Saved, which goes to the server. + THE OUTBOX project-data.js, which gets the RECORD to the server reliably. + + This file owns the first only. It never writes to the server: a draft is + "unfinished work this browser is holding for you", and pushing unfinished work + to a shared project is a different feature with different consequences. + + The guard fires only when the form actually differs from the record. A dialog + that appears on every exit gets clicked through within a day and is worse than + no dialog, which is why `Do not: fire the guard when nothing has changed` is + part of the task rather than a nicety. +*/ +(function (window, document) { + 'use strict'; + + var DRAFT_PREFIX = 'wp_draft::'; + var DEBOUNCE_MS = 1200; + + var reg = null; // the single registered surface for this page + var timer = null; + var status = { state: 'idle', at: null, error: null }; + var statusSubs = []; + var guardInstalled = false; + + function now() { return new Date().toISOString(); } + + function emit() { + statusSubs.forEach(function (fn) { + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + }); + } + + function setStatus(state, extra) { + status = Object.assign({ state: state, at: status.at, error: null }, extra || {}); + emit(); + } + + function draftKey(id) { return DRAFT_PREFIX + id; } + + function readDraft(id) { + try { return JSON.parse(window.localStorage.getItem(draftKey(id)) || 'null'); } + catch (e) { return null; } + } + + function writeDraft(id, payload) { + // A failed write is the one case that MUST be surfaced rather than swallowed: + // it means the safety net is not there, and the user is the only one who can + // act on that (close a tab, free some quota, save explicitly now). + window.localStorage.setItem(draftKey(id), JSON.stringify(payload)); + } + + function clearDraft(id) { + try { window.localStorage.removeItem(draftKey(id)); } catch (e) {} + } + + function save(reason) { + if (!reg) return false; + if (!reg.isDirty()) { setStatus('idle'); return false; } + setStatus('saving'); + try { + writeDraft(reg.draftId(), { + v: 1, at: now(), reason: reason || 'debounce', + entity: reg.id, data: reg.collect(), + }); + setStatus('saved', { at: now() }); + return true; + } catch (e) { + // QuotaExceededError, private-mode storage, a locked profile. + setStatus('failed', { error: (e && e.message) || String(e) }); + return false; + } + } + + function schedule(reason) { + if (!reg) return; + clearTimeout(timer); + timer = setTimeout(function () { save(reason || 'debounce'); }, DEBOUNCE_MS); + } + + function installGuard() { + if (guardInstalled) return; + guardInstalled = true; + // ADDED alongside the analytics dwell listener, never replacing it. Both fire; + // beforeunload supports multiple listeners and the analytics one does not + // preventDefault, so the two do not interact. + window.addEventListener('beforeunload', function (e) { + if (!reg || !reg.isDirty()) return undefined; // nothing unsaved: stay silent + save('unload'); // one last draft write + e.preventDefault(); + e.returnValue = ''; // required by Chrome + return ''; + }); + // A crash or a killed tab never fires beforeunload. `visibilitychange` to + // hidden does, and it is the last reliable moment to write the draft - which + // is what makes the recovery survive "kill the tab and reopen". + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') save('hidden'); + }); + } + + window.WPAutosave = { + /* Register the page's editable surface. + + id stable name for the surface, e.g. 'wp-form' + scope element to watch for input/change (defaults to document) + draftId () => storage id, usually project + entity so two projects do + not share one draft + collect () => a JSON-serialisable snapshot of the form + isDirty () => does the form differ from the last explicitly saved record + restore (data) => put a recovered snapshot back into the form + */ + register: function (opts) { + reg = { + id: opts.id, + scope: opts.scope || document, + draftId: opts.draftId || function () { return opts.id; }, + collect: opts.collect, + isDirty: opts.isDirty, + restore: opts.restore, + }; + reg.scope.addEventListener('input', function () { schedule('input'); }); + reg.scope.addEventListener('change', function () { schedule('change'); }); + installGuard(); + return window.WPAutosave; + }, + + // Autosave now rather than on the debounce - for a step or section change, + // where the user has visibly moved on and expects the previous part kept. + flush: function (reason) { clearTimeout(timer); return save(reason || 'flush'); }, + + isDirty: function () { return !!(reg && reg.isDirty()); }, + status: function () { return Object.assign({}, status); }, + onStatus: function (fn) { + statusSubs.push(fn); + try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {} + return function () { + var i = statusSubs.indexOf(fn); + if (i >= 0) statusSubs.splice(i, 1); + }; + }, + + /* Recovery. Returns the stored draft for an id, or null. The caller decides + whether to offer it - only it knows whether the draft is actually newer + than the record, and offering to restore work that is already saved is its + own kind of alarming. */ + peek: function (id) { return readDraft(id); }, + discard: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + /* Called after an explicit Save succeeded: the record now holds this work, so + the draft is no longer protecting anything and keeping it would make the + next load offer to "recover" work that is already saved. + + clearTimeout FIRST. A save typically follows typing, so there is usually a + debounced write already scheduled; without cancelling it, that write lands + a second after the draft was cleared and resurrects it — and the next load + offers to recover work that is already saved, which is the exact thing this + method exists to prevent. */ + settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + + _key: draftKey, + }; +})(window, document); diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index a2511ad..bcda1cf 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -1158,6 +1158,10 @@ function savePackage(view){ const ix=savedPackages.findIndex(p=>p.id===pkg.id); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status}); + wpMarkFormClean(); + // The record now holds this work, so the draft is no longer protecting anything. + // Left in place it would make the next load offer to "recover" work already saved. + if(typeof WPAutosave!=='undefined') WPAutosave.settled(wpDraftId()); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server document.getElementById('loadingOverlay').classList.add('active'); setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400); @@ -1261,6 +1265,8 @@ function printPackage(){ // ── VIEWS ──────────────────────────────────────────────────────────────────── function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); } +// Populating the form establishes the clean state it is later compared against. +function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); } function showForm(){ hideDashboard(); // This clears every card's inline display, which also clears the "hidden" set by @@ -1706,7 +1712,7 @@ function loadPackageIntoForm(p){ pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows(); pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows(); pkgHolds=(p.holds||[]).map(h=>({...h})); - updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); + updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated(); } function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints(); tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }}); @@ -1769,7 +1775,7 @@ function newPackage(){ pkgHolds=[]; pkgOverrides={}; set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); prevStatus='Draft'; - updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package'); + updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package'); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function exportPackages(){ @@ -2129,6 +2135,96 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList onStatusChange(v); })); + +// ── AUTOSAVE / UNSAVED-WORK GUARD / RECOVERY (S2) ──────────────────────────── +// A draft is per project AND per package, so two projects cannot overwrite each +// other's recovery and a new package does not inherit the last one's draft. +function wpDraftId(){ return 'wp-form::' + (activeProjectId || 'none') + '::' + (editingId || 'new'); } + +// Volatile fields move on their own (timestamps, the id assigned at collect time) +// and would make an untouched form look edited, which is exactly the false-positive +// that makes an unsaved-work dialog worthless. +function wpFormFingerprint(pkg){ + if(!pkg) return ''; + const copy = Object.assign({}, pkg); + ['id','updatedAt','createdAt','issuedAt'].forEach(k=>delete copy[k]); + try { return JSON.stringify(copy); } catch(e){ return ''; } +} + +// The baseline is taken when the form is populated and again when it is saved, so +// "dirty" means "changed since then". Comparing the form against savedPackages +// instead does not work: those records come back from the server through +// serverToPkg() in a leaner shape, so a freshly loaded, untouched form differed +// from its own record and every exit would have prompted - which is precisely the +// dialog-that-gets-clicked-through the task forbids. +let _wpFormBaseline = null; +function wpMarkFormClean(){ + try { _wpFormBaseline = wpFormFingerprint(collectPackage()); } catch(e){ _wpFormBaseline = null; } +} + +function wpFormIsDirty(){ + // Only the form can be dirty. On the dashboard or the printed view nothing is + // being edited, so there is nothing to warn about. + if(currentView !== 'Work Package Form') return false; + if(_wpFormBaseline === null) return false; + const el = document.getElementById('wp_subject'); + if(!el) return false; + let live; + try { live = collectPackage(); } catch(e){ return false; } + return wpFormFingerprint(live) !== _wpFormBaseline; +} + +function initAutosave(){ + if(typeof WPAutosave === 'undefined') return; + WPAutosave.register({ + id: 'wp-form', + scope: document, + draftId: wpDraftId, + collect: collectPackage, + isDirty: wpFormIsDirty, + }); + // Section changes are a "you have visibly moved on" moment, so flush rather than + // wait out the debounce. + document.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click', ()=>WPAutosave.flush('section'))); + offerDraftRecovery(); +} + +// Recovery. Offered only when the draft is genuinely ahead of the record - being +// asked to recover work that is already saved is its own kind of alarming. +function offerDraftRecovery(){ + if(typeof WPAutosave === 'undefined') return; + const id = wpDraftId(); + const d = WPAutosave.peek(id); + if(!d || !d.data) return; + const saved = editingId ? savedPackages.find(p=>p.id===editingId) : null; + if(saved && wpFormFingerprint(d.data) === wpFormFingerprint(saved)){ WPAutosave.discard(id); return; } + if(!d.data.subject && !d.data.number && !d.data.type){ WPAutosave.discard(id); return; } + showDraftRecoveryBar(id, d); +} + +function showDraftRecoveryBar(id, d){ + const host = document.querySelector('.main'); + if(!host) return; + const old = document.getElementById('draft-recovery'); if(old) old.remove(); + const when = (function(){ try { return new Date(d.at).toLocaleString(); } catch(e){ return d.at; } })(); + const bar = document.createElement('div'); + bar.id = 'draft-recovery'; + bar.className = 'dash-panel'; + bar.setAttribute('role','status'); + bar.innerHTML = `
Unsaved work from ${esc(when)}
+
This browser was holding changes to ${esc(d.data.number || d.data.subject || 'a work package')} that were never saved. Nothing has been sent to the project.
+
+ + +
`; + host.insertBefore(bar, host.firstChild); + document.getElementById('draft-restore').onclick = ()=>{ + loadPackageIntoForm(Object.assign({}, d.data, {id: (editingId || d.data.id)})); + bar.remove(); toast('Unsaved work restored — Save draft to keep it.'); + }; + document.getElementById('draft-discard').onclick = ()=>{ WPAutosave.discard(id); bar.remove(); }; +} + // ── BOOT ───────────────────────────────────────────────────────────────────── // Resolve the active project BEFORE loading the store so namespaced keys resolve. (function seedProject(){ @@ -2236,14 +2332,16 @@ function bootData(){ const want = state.wp || ''; if(want){ const ix = savedPackages.findIndex(x => x.id === want); - if(ix >= 0){ hideDashboard(); currentView='Form'; editingId=savedPackages[ix].id; + if(ix >= 0){ hideDashboard(); editingId=savedPackages[ix].id; loadPackageIntoForm(savedPackages[ix]); renderWpNav(); } } else if(currentView === 'Dashboard'){ - hideDashboard(); setFormChrome(true); currentView='Form'; + hideDashboard(); setFormChrome(true); currentView='Work Package Form'; document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display=''); } }); } + initAutosave(); + wpMarkFormClean(); 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 5033625..7eb9888 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -11,6 +11,8 @@ + + diff --git a/tests/autosave_check.py b/tests/autosave_check.py new file mode 100644 index 0000000..1f620e0 --- /dev/null +++ b/tests/autosave_check.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Does unsaved work survive? — S2 / T4.3 (and B5 / T4.4's status). + +The work package form is ~4,700px tall and had no autosave and no unsaved-work +guard: the only beforeunload listener in the app was analytics dwell tracking. A +mis-click lost everything typed since the last explicit Save. + + 1. typing autosaves a draft, without being asked + 2. typing then closing prompts; NOT typing then closing does not + 3. the draft survives a killed tab (no beforeunload) and is offered back + 4. restoring puts the work back in the form + 5. an explicit save settles the draft, so nothing offers to "recover" saved work + 6. autosave failure is surfaced rather than swallowed + 7. the analytics dwell listener still fires + +Exit 0 all passed, 1 a failure, 2 could not run. +""" +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 # noqa: E402 + + +def boot(page, base, tok, url): + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + url) + for _ in range(40): + if page.eval("!!window.wpCreatorReady"): + break + time.sleep(0.3) + time.sleep(1.0) + + +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-autosave-") + 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("\nAutosave and the unsaved-work guard — S2 / T4.3\nTarget: %s" % base) + + browser = cdp.Browser(exe) + page = browser.page() + try: + print("\n0. the module is registered on the form page") + boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1") + chk("WPAutosave is loaded", page.eval("typeof WPAutosave") == "object") + chk("an untouched form is not dirty", page.eval("WPAutosave.isDirty()") is False, + page.eval("JSON.stringify(WPAutosave.status())")) + + print("\n2a. not typing then leaving does NOT prompt") + chk("no guard while the form is untouched", + page.eval("WPAutosave.isDirty()") is False) + + print("\n1. typing autosaves a draft without being asked") + page.eval("""(() => { + const el = document.getElementById('wp_subject'); + el.value = 'AUTOSAVE PROBE — typed but never saved'; + el.dispatchEvent(new Event('input', {bubbles:true})); + return true; + })()""") + chk("the form is now dirty", page.eval("WPAutosave.isDirty()") is True) + for _ in range(25): + if page.eval("WPAutosave.status().state") == "saved": + break + time.sleep(0.3) + st = page.eval("WPAutosave.status().state") + chk("a draft is written on the debounce, unprompted", st == "saved", "status=%r" % st) + key = page.eval("WPAutosave._key(wpDraftId())") + stored = page.eval("localStorage.getItem(%r)" % key) + chk("the draft holds what was typed", + bool(stored) and "AUTOSAVE PROBE" in stored, (stored or "")[:120]) + chk("the draft is scoped to project AND package", + "projA" in key and "wpA1" in key, key) + chk("it did NOT go to the server (a draft is not a record)", + page.eval("""(() => { + const q = (localStorage.getItem('wp_outbox_v1')||''); + return q.indexOf('AUTOSAVE PROBE') === -1; + })()""")) + + print("\n2b. typing then leaving DOES prompt") + chk("the guard is armed while work is unsaved", + page.eval("WPAutosave.isDirty()") is True) + + print("\n3. the draft survives a killed tab") + # No beforeunload: navigate the tab away as a crash would, relying on + # the visibilitychange write. Then reopen the same package. + page.eval("document.dispatchEvent(new Event('visibilitychange'))") + time.sleep(0.4) + boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1") + still = page.eval("localStorage.getItem(%r)" % key) + chk("the draft is still there after reopening", + bool(still) and "AUTOSAVE PROBE" in still, (still or "")[:80]) + chk("...and is offered back, not applied silently", + page.eval("!!document.getElementById('draft-recovery')")) + txt = page.eval("(document.getElementById('draft-recovery')||{}).textContent||''") + chk("...saying plainly that nothing reached the project", + "Nothing has been sent to the project" in txt, txt[:140]) + chk("...and announcing itself", + page.eval("(document.getElementById('draft-recovery')||{}).getAttribute" + "&&document.getElementById('draft-recovery').getAttribute('role')") == "status") + + print("\n4. restoring puts the work back") + page.eval("document.getElementById('draft-restore').click()") + time.sleep(0.8) + val = page.eval("(document.getElementById('wp_subject')||{}).value||''") + chk("the typed text is back in the form", "AUTOSAVE PROBE" in val, val[:80]) + chk("the recovery bar is gone once used", + page.eval("!document.getElementById('draft-recovery')")) + + print("\n5. an explicit save settles the draft") + # savePackage() can end in confirm() (the early-release gate) or alert() + # (a missing required field). A native dialog blocks the page and hangs + # CDP, so the probe answers them. This is the app's 79-native-dialog + # problem showing up in a test rather than a defect in this task — S6/S7 + # in wave 9 is where those get replaced. + page.eval("window.confirm = () => true; window.alert = () => {}; true") + # savePackage() returns early unless subject AND type are set, and with + # alert() stubbed that early return is silent - so make the form valid + # first, then assert the save actually landed before asserting anything + # about the draft. + # The browser_check fixture's SOP defines no WP types, so the type + # select holds only its placeholder and savePackage() correctly refuses. + # That is the fixture, not the app: give the form a valid type so the + # save path can actually be exercised. + page.eval("""(() => { + const t = document.getElementById('wp_type'); + if (t && !t.value) { + const o = document.createElement('option'); + o.value = 'Conduit Install'; o.textContent = 'Conduit Install'; + t.appendChild(o); t.value = 'Conduit Install'; + t.dispatchEvent(new Event('change', {bubbles:true})); + } + return (document.getElementById('wp_subject')||{}).value + ' | ' + (t||{}).value; + })()""") + page.eval("typeof savePackage==='function' && savePackage(false)") + time.sleep(1.2) + chk("the save actually landed (otherwise the rest proves nothing)", + page.eval("""(() => savedPackages.some(p => + (p.subject||'').indexOf('AUTOSAVE PROBE') !== -1))()"""), + page.eval("JSON.stringify(savedPackages.map(p=>p.subject))")[:160]) + after = page.eval("localStorage.getItem(%r)" % key) + chk("the draft is cleared once the record holds the work", after in (None, "null"), + repr(after)[:80]) + boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1") + chk("...so nothing offers to recover work that is already saved", + page.eval("!document.getElementById('draft-recovery')")) + + print("\n6. autosave failure is surfaced, not swallowed") + page.eval("""(() => { + const real = localStorage.setItem.bind(localStorage); + localStorage.setItem = function(k, v){ + if (String(k).indexOf('wp_draft::') === 0) { + const e = new Error('QuotaExceededError (simulated)'); e.name='QuotaExceededError'; throw e; + } + return real(k, v); + }; + const el = document.getElementById('wp_subject'); + el.value = 'SECOND EDIT, storage is full'; + el.dispatchEvent(new Event('input', {bubbles:true})); + return true; + })()""") + for _ in range(25): + if page.eval("WPAutosave.status().state") == "failed": + break + time.sleep(0.3) + st = page.eval("JSON.stringify(WPAutosave.status())") + chk("a failed autosave reports 'failed'", + page.eval("WPAutosave.status().state") == "failed", st) + chk("...and carries the reason", "simulated" in (st or ""), st) + + print("\n7. the analytics dwell listener still fires") + boot(page, base, tok, "/work-package-suite.html?project=projA&tab=sop") + time.sleep(0.8) + chk("the wizard still has its analytics dwell tracker", + page.eval("typeof trackStepDwell === 'function'")) + chk("...and the wizard registered autosave too", + page.eval("typeof WPAutosave === 'object' && typeof sopDraftId === 'function'")) + before = page.eval("""(() => { + try { return (JSON.parse(localStorage.getItem('wp_suite_analytics_v1')||'{}').events||[]).length; } + catch(e){ return -1; } + })()""") + # trackStepDwell only records a dwell longer than 400ms, so give it one. + time.sleep(0.9) + page.eval("typeof trackStepDwell==='function' && trackStepDwell()") + time.sleep(0.4) + after_n = page.eval("""(() => { + try { return (JSON.parse(localStorage.getItem('wp_suite_analytics_v1')||'{}').events||[]).length; } + catch(e){ return -1; } + })()""") + chk("calling it records an event (it was not replaced by the guard)", + after_n > before, "%s -> %s" % (before, after_n)) + 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 — unsaved work survives.", "32") + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())