Files
interactive-story/backend/tools/wpd_backup_ui.py
T
JesseMarkowitz 87a40326a2 v1.1: harden recovery and control boundaries
WP-D and WP-E complete the planned v1.1 implementation packages.

WP-D — recovery honesty:
- backups verify the completed copy with PRAGMA integrity_check
- corruption missed by quick_check is detected by the full check
- existing good backups remain protected
- oversized exports are still delivered but declare whether this version can
  import them, while the 20 MB import limit remains unchanged
- backup was exercised through the real browser UI on both the normal campaign
  database and a campaign-shaped database over 100 MB

WP-E — control-boundary contrast:
- interactive control boundaries meet the WCAG 1.4.11 3:1 target
- the contrast audit is now a failing gate rather than an advisory
- rendered browser measurements pass for the composer, controls, tabs and nav
- text contrast and focus visibility remain intact
- owner reviewed and approved the before/after screenshots

Reports:
- planning/reports/v1.1/V1.1-WP-D-REPORT.md
- planning/reports/v1.1/V1.1-WP-E-REPORT.md

All planned v1.1 work packages A-E are now complete. Release validation has not
yet begun.
2026-09-16 05:37:13 -04:00

354 lines
14 KiB
Python

"""v1.1 WP-D criterion 3: the backup completes **through the UI**.
python -m tools.wpd_backup_ui --out <dir under $HOME> [--case real|large|both] [--show]
Run from `backend/`, with `frontend/dist` already built.
WP-D's first pass drove `POST /api/backups` — the endpoint the *Back up now*
button calls — on a 2.3 MB campaign database and a 117 MB one. That is evidence
about the implementation, and the plan's criterion 3 asks for something else:
that the backup *completes through the UI* on both. A reader does not call an
endpoint. This drives the reader-facing control in a real Firefox, against the
production build served by FastAPI, exactly as WP-C's harness does.
**No narrator and no inference.** A backup needs neither, so nothing here
touches a model host.
What it refuses to call a pass, per the brief:
- the click does nothing (no toast, no file);
- the request fails (an error toast);
- no backup file appears on disk;
- the finished copy does not pass a full `PRAGMA integrity_check`;
- the UI reports an error.
Every wait is on a condition the page or the filesystem can show. Nothing here
sleeps and then asserts.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tools.m11_webdriver import ( # noqa: E402
Browser, Site, geckodriver_version, require_under_home,
)
BACKEND = Path(__file__).resolve().parent.parent
HOME = Path.home()
#: The two databases criterion 3 names. Both are copied before use: the first is
#: the M11 evidence campaign and must not be written to, and the second is the
#: 117 MB application database built for WP-D's timing.
DEFAULT_REAL = HOME / "m11-evidence/m04-final/campaign.db"
DEFAULT_LARGE = HOME / "v11-evidence/wp-d/endpoint/campaign-100mb/campaign.db"
BLOCK = '[data-testid="database-backup"]'
BUTTON = f'{BLOCK} button.primary'
class Checks:
"""Results, with the discipline that an unrun check is not a passing one."""
def __init__(self) -> None:
self.rows: list[dict] = []
def record(self, case: str, name: str, ok: bool, detail: str = "") -> bool:
self.rows.append({"case": case, "check": name,
"result": "PASS" if ok else "FAIL", "detail": detail})
print(f" {'ok ' if ok else 'FAIL'} {case:6} {name}"
+ (f" — {detail}" if detail else ""), flush=True)
return ok
@property
def failed(self) -> list[dict]:
return [r for r in self.rows if r["result"] == "FAIL"]
# ----------------------------------------------------------------- helpers
def integrity_of(path: Path) -> tuple[str, float]:
"""The full check on a finished copy, and what it cost, measured here.
The application runs its own `integrity_check` before keeping the file; this
is an independent second opinion on the artefact the UI produced, and it is
where criterion 3's 'time for integrity_check' comes from.
"""
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
started = time.perf_counter()
rows = connection.execute("PRAGMA integrity_check").fetchall()
elapsed = time.perf_counter() - started
finally:
connection.close()
return ", ".join(str(r[0]) for r in rows), elapsed
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
def backups_in(db_path: Path) -> dict[str, int]:
directory = db_path.parent / "backups"
if not directory.exists():
return {}
return {p.name: p.stat().st_size for p in directory.iterdir() if p.is_file()}
def wait_for_new_backup(db_path: Path, before: set[str], *, timeout: float = 300,
poll: float = 0.2) -> Path | None:
"""The backup file the application wrote, once it is really there.
A file condition, not a sleep: a name that was not there before, not a
`.partial`, and a size that has stopped growing.
"""
directory = db_path.parent / "backups"
deadline = time.monotonic() + timeout
sizes: dict[str, int] = {}
while time.monotonic() < deadline:
if directory.exists():
for path in directory.iterdir():
if not path.is_file() or path.name in before:
continue
if path.name.endswith(".partial"):
continue
size = path.stat().st_size
if size > 0 and sizes.get(path.name) == size:
return path
sizes[path.name] = size
time.sleep(poll)
return None
def toasts(browser: Browser) -> list[dict]:
"""What the page is telling the reader — the message apart from its mark.
A toast renders a decorative mark before the message:
`<span class="toast-mark" aria-hidden="true">❖</span><span>…</span>`. So the
button's `textContent` begins with that character, and the first run of this
tool matched `textContent.startswith('Backup written:')` and reported six
*passing* behaviours as failures — the UI had said exactly what it should,
and the assertion was reading the mark. `message` is the message span alone;
`text` is kept whole for the evidence record.
"""
return browser.js("""
const host = document.querySelector('.toast-host');
if (!host) return [];
return [...host.querySelectorAll('button.toast')].map(b => {
const span = b.querySelector('span:not(.toast-mark)');
return {
error: b.classList.contains('toast-error'),
message: (span ? span.textContent : b.textContent).trim(),
text: b.textContent.trim(),
};
});
""") or []
def open_backup_panel(browser: Browser, site: Site, checks: Checks, case: str) -> bool:
"""Reach the control the way a reader does: the nav link, then the panel."""
browser.go(site.url)
browser.wait_for(".topnav", timeout=60)
link = browser.find('.nav-links a[href="/settings"]', required=False)
if link is None:
return checks.record(case, "the Settings link is in the navigation", False)
browser.click(link)
opened = browser.wait_js(f"!!document.querySelector('{BLOCK}')", timeout=60)
if not checks.record(case, "Settings opens from the navigation link", opened,
browser.url):
return False
summary = browser.find(f"{BLOCK} summary", required=False)
if summary is None:
return checks.record(case, "the backup panel has a disclosure", False)
browser.click(summary)
# The panel is a <details>: it loads what is already on disk when it opens,
# so waiting for the directory line proves the application answered.
shown = browser.wait_js(
f"document.querySelector('{BLOCK}').open === true"
f" && !!document.querySelector('{BUTTON}')", timeout=30)
checks.record(case, "the backup panel opens and shows its control", shown)
listed = browser.wait_js(f"!!document.querySelector('{BLOCK} code')", timeout=30)
checks.record(case, "the panel reports where backups are written", listed)
return shown
def click_back_up_now(browser: Browser, site: Site, db: Path, checks: Checks,
case: str, label: str) -> dict:
"""One press of the button, judged by what the page and the disk then show."""
before = set(backups_in(db))
# Clear anything still on screen, so the toast this press produces is the
# one that is read back rather than a leftover from the previous press.
browser.js("""
for (const b of document.querySelectorAll('.toast-host button.toast')) b.click();
return true;
""")
button = browser.find(BUTTON, required=False)
if button is None:
checks.record(case, f"{label}: the control is on the page", False)
return {}
started = time.perf_counter()
browser.click(button)
# Either outcome ends the wait, so a failure is reported as a failure rather
# than as a timeout.
settled = browser.wait_js(
"(() => { const t = [...document.querySelectorAll('.toast-host button.toast')];"
" return t.length > 0; })()", timeout=300)
elapsed = time.perf_counter() - started
shown = toasts(browser)
errors = [t for t in shown if t["error"]]
written = [t for t in shown if t["message"].startswith("Backup written:")]
checks.record(case, f"{label}: the click produced a visible result", settled,
json.dumps(shown)[:200])
checks.record(case, f"{label}: the UI reports no error",
not errors, json.dumps(errors)[:300])
checks.record(case, f"{label}: the UI reports the backup was written",
bool(written), json.dumps(written)[:200])
idle = browser.wait_js(
"(() => { const b = document.querySelector(%s);"
" return !!b && b.textContent.trim() === 'Back up now'; })()"
% json.dumps(BUTTON), timeout=120)
checks.record(case, f"{label}: the control returns from 'Backing up…'", idle)
produced = wait_for_new_backup(db, before)
checks.record(case, f"{label}: a backup file was physically produced",
produced is not None, str(produced))
if produced is None:
return {"seconds": round(elapsed, 3), "toasts": shown}
named = any(produced.name in t["message"] for t in written)
checks.record(case, f"{label}: the UI names the file that appeared", named,
f"{produced.name} — {written[0]['message'] if written else ''}")
relisted = browser.wait_js(
"[...document.querySelectorAll('%s .backup-list code')]"
".some(c => c.textContent.trim() === %s)" % (BLOCK, json.dumps(produced.name)),
timeout=60)
checks.record(case, f"{label}: the new backup appears in the panel's list", relisted)
verdict, integrity_seconds = integrity_of(produced)
checks.record(case, f"{label}: the finished copy passes full integrity_check",
verdict == "ok", f"{verdict} in {integrity_seconds * 1000:.1f} ms")
return {
"file": str(produced),
"bytes": produced.stat().st_size,
"sha256_16": digest(produced),
"seconds": round(elapsed, 3),
"integrity": verdict,
"integrity_seconds": round(integrity_seconds, 4),
"toasts": shown,
}
def run_case(case: str, source: Path, out: Path, checks: Checks, *, show: bool,
twice: bool) -> dict:
print(f"\n=== {case}: {source} ===", flush=True)
work = out / case
shutil.rmtree(work, ignore_errors=True)
(work / "data").mkdir(parents=True)
db = work / "data" / "campaign.db"
copy_started = time.perf_counter()
shutil.copy2(source, db)
copy_seconds = time.perf_counter() - copy_started
size = db.stat().st_size
print(f" copied {size:,} bytes in {copy_seconds:.2f}s -> {db}", flush=True)
site = Site(BACKEND, db, work / "server.log")
browser = Browser(headless=not show, log=work / "geckodriver.log")
result: dict = {"database": str(source), "bytes": size,
"served_at": site.url, "firefox": browser.version}
try:
if not open_backup_panel(browser, site, checks, case):
return result
result["first"] = click_back_up_now(browser, site, db, checks, case, "backup")
browser.screenshot(work / "back-up-now.png")
if twice and result["first"].get("file"):
kept = Path(result["first"]["file"])
before_bytes, before_digest = kept.stat().st_size, digest(kept)
result["second"] = click_back_up_now(browser, site, db, checks, case,
"second backup")
still_there = kept.exists()
checks.record(case, "the earlier backup still exists", still_there)
if still_there:
checks.record(
case, "and is byte-for-byte what it was",
kept.stat().st_size == before_bytes and digest(kept) == before_digest,
f"{before_bytes:,} bytes, sha256:{before_digest}")
verdict, _ = integrity_of(kept)
checks.record(case, "and still passes integrity_check", verdict == "ok",
verdict)
if result["second"].get("file"):
checks.record(case, "the second backup is a different file",
result["second"]["file"] != result["first"]["file"],
Path(result["second"]["file"]).name)
finally:
browser.quit()
site.stop()
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", required=True,
help="evidence directory, which must be under $HOME")
parser.add_argument("--case", default="both", choices=["real", "large", "both"])
parser.add_argument("--show", action="store_true", help="run Firefox visibly")
parser.add_argument("--real-db", default=str(DEFAULT_REAL))
parser.add_argument("--large-db", default=str(DEFAULT_LARGE))
args = parser.parse_args()
out = require_under_home(Path(args.out).expanduser())
out.mkdir(parents=True, exist_ok=True)
if not (BACKEND.parent / "frontend/dist/index.html").exists():
print("frontend/dist is not built", file=sys.stderr)
return 2
checks = Checks()
started = datetime.now()
results: dict[str, dict] = {}
wanted = [("real", Path(args.real_db).expanduser(), True)] if args.case != "large" else []
if args.case != "real":
wanted.append(("large", Path(args.large_db).expanduser(), False))
print(f"WP-D criterion 3 — the backup through the UI. geckodriver "
f"{geckodriver_version()}", flush=True)
for case, source, twice in wanted:
if not source.exists():
checks.record(case, "the database is present", False, str(source))
continue
results[case] = run_case(case, source, out, checks, show=args.show, twice=twice)
report = {
"started": started.isoformat(timespec="seconds"),
"seconds": round((datetime.now() - started).total_seconds()),
"cases": results,
"checks": checks.rows,
"passed": len([r for r in checks.rows if r["result"] == "PASS"]),
"failed": len(checks.failed),
}
(out / "backup-ui-report.json").write_text(json.dumps(report, indent=2))
print(f"\n{report['passed']} passed, {report['failed']} failed "
f"-> {out / 'backup-ui-report.json'}")
return 1 if checks.failed else 0
if __name__ == "__main__":
raise SystemExit(main())