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>
246 lines
12 KiB
Python
246 lines
12 KiB
Python
#!/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())
|