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) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:56:40 -05:00
parent b54034db04
commit ab3c9e52d1
6 changed files with 556 additions and 4 deletions

View File

@@ -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){}

View File

@@ -11,6 +11,8 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">

172
html/wp-autosave.js Normal file
View File

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

View File

@@ -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 = `<div class="dash-panel-title">Unsaved work from ${esc(when)}</div>
<div class="field-hint">This browser was holding changes to <strong>${esc(d.data.number || d.data.subject || 'a work package')}</strong> that were never saved. Nothing has been sent to the project.</div>
<div class="material-actions">
<button class="btn btn-primary" id="draft-restore">Restore them</button>
<button class="btn btn-ghost" id="draft-discard">Discard</button>
</div>`;
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

View File

@@ -11,6 +11,8 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">

245
tests/autosave_check.py Normal file
View File

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