The app already showed "✓ All changes saved". That badge belongs to the OUTBOX -
it reports whether saved records have reached the project - and it went green
when the queue emptied, whether or not anything in the form had been saved at
all. So the promise B5 says the app does not keep was being made by a component
that could not know whether it was true.
Two indicators now, each speaking for one thing:
DRAFT .wp-draft-status, mounted in the creator's sticky save bar and the
wizard's step navigation. Driven by WPAutosave's status: "No unsaved
changes" / "Unsaved changes" / "Saving draft…" / "Draft saved at HH:MM"
/ "Draft not saved on this device — <reason>" with a Retry.
OUTBOX the existing badge, reworded so every state names the project:
"Sending N changes to the project…", "Everything sent to the project",
"N changes not yet sent to the project — retrying", "rejected by the
project".
"No unsaved changes" rather than "Saved" for an untouched form: those are
different statements and only the first is true before anything is typed. The
component was getting that wrong in the same way the outbox badge was.
Announced per S10 (T4.5's pattern, arriving one task early because this indicator
needs it to exist): role="status" while things are going well, swapping to
role="alert" on failure. A failed autosave means the safety net is not there, and
waiting for a pause in the screen reader's queue to mention that is too late.
The retry button is only rendered in the failed state - a retry offered when
nothing has failed is a button that does nothing.
Styles live in theme-light.css because both form pages mount the same component,
and a second copy in a page sheet is what wave 3 spent itself removing.
VERIFICATION. tests/autosave_check.py grew to 34 checks, all passing. The B5 ones:
- the indicator reports "No unsaved changes" untouched, then a real save with a
timestamp, and is visually distinct in each state
- a simulated storage failure is visually distinct, names the reason, offers a
retry, and switches to role=alert
- the sync badge no longer RENDERS "All changes saved", and every state it does
render names the project
That last check is deliberately scoped to what the badge renders rather than to
the file text: the old phrase still appears in the comment explaining why it was
changed, and asserting on that would be asserting that the reason cannot be
written down.
Note for wave 9: the outbox badge is styled with inline hexes, including #8a6d00
- the ninth amber from BL-009, independently confirming that entry. It is
BL-005's territory, not this task's.
browser_check 71/71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
15 KiB
Python
312 lines
15 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("\n8. B5 — the save indicator tells the truth about the DRAFT")
|
|
boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1")
|
|
chk("a draft indicator is mounted",
|
|
page.eval("!!document.getElementById('wp-draft-status')"))
|
|
ind = "(document.getElementById('wp-draft-status')||{})"
|
|
chk("it says 'no unsaved changes' on an untouched form",
|
|
"No unsaved changes" in page.eval(ind + ".textContent||''"),
|
|
page.eval(ind + ".textContent||''"))
|
|
chk("...announced politely",
|
|
page.eval(ind + ".getAttribute && document.getElementById('wp-draft-status').getAttribute('role')") == "status")
|
|
page.eval("""(() => {
|
|
const el = document.getElementById('wp_subject');
|
|
el.value = 'B5 PROBE'; el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
return true;
|
|
})()""")
|
|
for _ in range(25):
|
|
if "Draft saved" in page.eval(ind + ".textContent||''"):
|
|
break
|
|
time.sleep(0.3)
|
|
txt = page.eval(ind + ".textContent||''")
|
|
chk("it reports a real save, with a time", "Draft saved at" in txt, txt)
|
|
chk("...and is visually distinct when saved",
|
|
"is-saved" in page.eval(ind + ".className||''"))
|
|
|
|
print("\n8b. a failed save looks different and offers a retry")
|
|
page.eval("""(() => {
|
|
const real = localStorage.setItem.bind(localStorage);
|
|
localStorage.setItem = function(k, v){
|
|
if (String(k).indexOf('wp_draft::') === 0) throw new Error('disk on fire (simulated)');
|
|
return real(k, v);
|
|
};
|
|
const el = document.getElementById('wp_subject');
|
|
el.value = 'B5 PROBE 2'; el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
return true;
|
|
})()""")
|
|
for _ in range(25):
|
|
if "is-failed" in page.eval(ind + ".className||''"):
|
|
break
|
|
time.sleep(0.3)
|
|
cls = page.eval(ind + ".className||''")
|
|
txt = page.eval(ind + ".textContent||''")
|
|
chk("a failed draft save is visually distinct", "is-failed" in cls, cls)
|
|
chk("...names the failure", "disk on fire" in txt, txt[:120])
|
|
chk("...offers a retry",
|
|
page.eval("!!document.querySelector('#wp-draft-status .wp-draft-retry')"))
|
|
chk("...and interrupts rather than waiting for a pause",
|
|
page.eval("document.getElementById('wp-draft-status').getAttribute('role')") == "alert")
|
|
|
|
print("\n8c. the outbox message no longer reads as a draft-save confirmation")
|
|
src = page.eval("(() => fetch('/project-data.js').then(r=>r.text()))()") or ""
|
|
# Only what the badge RENDERS counts. The phrase still appears in the
|
|
# comment explaining why it was changed, and asserting on that would be
|
|
# asserting that the reason cannot be written down.
|
|
rendered = [ln for ln in src.split("\n")
|
|
if ("el.textContent" in ln or "el.innerHTML" in ln)
|
|
and "All changes saved" in ln]
|
|
chk("the badge no longer renders 'All changes saved'", not rendered, rendered[:1])
|
|
chk("...and every state it renders names the project",
|
|
all("the project" in ln
|
|
for ln in src.split("\n")
|
|
if ("el.textContent = '" in ln or "el.innerHTML = '" in ln)
|
|
and "wp-sync-badge" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)),
|
|
[ln.strip()[:70] for ln in src.split("\n")
|
|
if ("el.textContent = '" in ln or "el.innerHTML = '" in ln)
|
|
and "the project" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)])
|
|
|
|
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())
|