"""v1.1 WP-D criterion 3: the backup completes **through the UI**. python -m tools.wpd_backup_ui --out [--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: `…`. 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
: 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())