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.
354 lines
14 KiB
Python
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())
|