v1.1 WP-C: browser release coverage

Drives in a real browser the reader workflows v1 proved only through the API
or the component suite, including an export that leaves the browser as a
file. Final run: 91 checks (the 38 existing M11 checks plus 53 new), 0 failed,
0 skipped, on the production build over trusted-LAN HTTPS.

- tools/m11_browser.py: scenarios for Retry and takes, Save Point create /
  restore / Redo, state correction (accepted, and a refused correction with
  its reason), narration length reaching each turn's prompt, failed
  generation (an unserved model blocked up front; a listed model that cannot
  narrate failing in the open) and recovery, and export download from the
  library and from campaign settings, imported into a fresh application.
  Rows are tagged M11 / WP-C and counted separately; --only for development.
  The M11 checks now wait on conditions instead of sleeping.
- tools/m11_webdriver.py: Firefox download preferences, a $HOME-only
  download folder, a download wait that ignores partial, empty, pre-existing
  and still-growing files, centred real clicks, tabs, and condition waits.
- tests/test_v11_c_browser_helpers.py: the download wait, prefs and $HOME
  guard, without a browser.
- frontend: a correction the story refused was presented as "Generation
  failed" with a Retry offer and a typed-input claim. It is now "That
  correction was not applied", not retryable, with the reason kept
  (errors.js, FailureNotice.jsx; 3 regression tests).
- DEVELOPMENT.md: the harness command, download profile and $HOME rule,
  what counts as a finished download, and the no-sleep rule.
- docs: V1.1-PLAN, VERSION v4.4, planning README,
  reports/v1.1/V1.1-WP-C-REPORT.md.

Open for the owner: "Correct" on an Important Facts row is always refused
(K1), and a partly refused correction is not reachable from the reader UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvegagkhuCZoFPdv4M1egY
This commit is contained in:
JesseMarkowitz
2026-09-15 18:29:45 -04:00
co-authored by Claude Opus 5
parent 0c1ba836ba
commit 59b5ebc2d8
11 changed files with 1711 additions and 157 deletions
+135 -3
View File
@@ -15,6 +15,12 @@ what M9 recorded as "this machine cannot drive a file into the browser". The
narrower and more useful statement is that it refuses `/tmp`: a path under the
user's home works. `stage()` exists to put evidence files there, so knowledge
import can be exercised through the real file input rather than in two halves.
**Downloads (v1.1 WP-C).** The same snap Firefox saves a download without any
dialog when its profile says where to, and the folder is under `$HOME`.
`firefox_download_prefs` is that profile, `require_under_home` refuses a folder
the sandbox would not let it write, and `wait_for_download` decides when a file
has actually finished arriving — never the click that started it.
"""
from __future__ import annotations
@@ -33,6 +39,10 @@ GECKODRIVER = shutil.which("geckodriver") or "/snap/bin/geckodriver"
#: Where files the browser must open are staged. Under $HOME because the snap
#: sandbox denies /tmp; see the module docstring.
STAGE = Path.home() / "m11-evidence"
#: The W3C key an element reference is returned under.
ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"
#: What Firefox names a download while it is still arriving.
PARTIAL_SUFFIXES = (".part",)
def stage(name: str, body: str | bytes) -> str:
@@ -51,14 +61,86 @@ def free_port() -> int:
return s.getsockname()[1]
def geckodriver_version() -> str:
try:
out = subprocess.run([GECKODRIVER, "--version"], capture_output=True, text=True, timeout=30)
return (out.stdout.splitlines() or ["?"])[0].strip()
except (OSError, subprocess.SubprocessError):
return "?"
class WebDriverError(RuntimeError):
pass
# ------------------------------------------------------------------ downloads
def require_under_home(path: Path) -> Path:
"""`path`, resolved, if it is inside the user's home; otherwise refuse.
The snap sandbox will not write elsewhere, and a download folder under
`/tmp` would also put evidence where a reboot deletes it.
"""
resolved = Path(path).expanduser().resolve()
home = Path.home().resolve()
if resolved != home and home not in resolved.parents:
raise WebDriverError(f"{resolved} is not under {home}; the browser cannot write there")
return resolved
def firefox_download_prefs(directory: Path) -> dict:
"""Profile preferences that save every download to `directory`, unasked."""
return {
"browser.download.folderList": 2, # 2 = the folder named below
"browser.download.dir": str(directory),
"browser.download.useDownloadDir": True,
"browser.download.start_downloads_in_tmp_dir": False,
"browser.download.always_ask_before_handling_new_types": False,
"browser.helperApps.neverAsk.saveToDisk": "application/json,application/octet-stream",
"browser.download.manager.showWhenStarting": False,
"browser.download.alwaysOpenPanel": False,
"browser.download.panel.shown": True,
}
def wait_for_download(directory: Path, before: set[str], *, timeout: float = 60,
poll: float = 0.2, stable_polls: int = 3) -> Path:
"""The file a download wrote into `directory`, once it has finished.
Finished means all of these, at once:
- a name that was not in `before` (the listing taken before the click);
- no in-progress file (`*.part`) left in the folder;
- more than zero bytes;
- the same size for `stable_polls` consecutive polls.
A first appearance is not a finished download, and a zero-byte or partial
file never counts. Raises `WebDriverError` when nothing finishes in time.
"""
deadline = time.monotonic() + timeout
last: dict[str, int] = {}
steady: dict[str, int] = {}
while time.monotonic() < deadline:
names = {p.name for p in directory.iterdir()} if directory.exists() else set()
partial = any(n.endswith(PARTIAL_SUFFIXES) for n in names)
fresh = sorted(n for n in names - before if not n.endswith(PARTIAL_SUFFIXES))
for name in fresh:
size = (directory / name).stat().st_size
steady[name] = steady.get(name, 0) + 1 if last.get(name) == size else 1
last[name] = size
if not partial and size > 0 and steady[name] >= stable_polls:
return directory / name
time.sleep(poll)
listing = sorted(p.name for p in directory.iterdir()) if directory.exists() else []
raise WebDriverError(f"no finished download in {directory} within {timeout}s; saw {listing}")
# -------------------------------------------------------------------- browser
class Browser:
"""One headless Firefox, driven over the wire protocol."""
def __init__(self, *, headless: bool = True, log: Path | None = None):
def __init__(self, *, headless: bool = True, log: Path | None = None,
download_dir: Path | None = None):
self.port = free_port()
handle = open(log, "ab") if log else subprocess.DEVNULL
self.proc = subprocess.Popen(
@@ -68,9 +150,15 @@ class Browser:
self.base = f"http://127.0.0.1:{self.port}"
self._wait_for_driver()
args = ["-headless"] if headless else []
options: dict = {"args": args}
self.download_dir = None
if download_dir is not None:
self.download_dir = require_under_home(download_dir)
self.download_dir.mkdir(parents=True, exist_ok=True)
options["prefs"] = firefox_download_prefs(self.download_dir)
answer = self._call("POST", "/session", {"capabilities": {"alwaysMatch": {
"browserName": "firefox",
"moz:firefoxOptions": {"args": args},
"moz:firefoxOptions": options,
# Never silently accept a bad certificate: the endpoint policy and
# the TLS trust union are release claims (H12, A06), and a browser
# that ignored certificates would hide a failure of either.
@@ -78,6 +166,7 @@ class Browser:
}}})["value"]
self.session = answer["sessionId"]
self.version = answer["capabilities"].get("browserVersion", "?")
self.capabilities = answer["capabilities"]
# ------------------------------------------------------------- plumbing
@@ -123,6 +212,9 @@ class Browser:
def go(self, url: str) -> None:
self._call("POST", self._s("/url"), {"url": url})
def reload(self) -> None:
self._call("POST", self._s("/refresh"), {})
@property
def url(self) -> str:
return self._call("GET", self._s("/url"))["value"]
@@ -138,6 +230,13 @@ class Browser:
return self._call("POST", self._s("/execute/sync"),
{"script": script, "args": list(args)})["value"]
def element_by_js(self, script: str, *args):
"""An element a script returns, as a reference `click` can use, or None."""
value = self.js(script, *args)
if isinstance(value, dict) and ELEMENT_KEY in value:
return value[ELEMENT_KEY]
return None
def find(self, css: str, *, required=True):
try:
answer = self._call("POST", self._s("/element"),
@@ -163,6 +262,17 @@ class Browser:
return self._call("GET", self._s(f"/element/{element}/property/{name}"))["value"]
def click(self, element: str) -> None:
"""A real click, on an element first scrolled to the middle of the view.
WebDriver scrolls a target only as far as its edge, and the play page's
composer is fixed to the bottom of the window: a control just under it
(a failure notice's details, a turn's Inspect button) is then covered,
and the click is intercepted. A reader scrolls it clear first; so does
this (v1.1 WP-C).
"""
self._call("POST", self._s("/execute/sync"), {
"script": "arguments[0].scrollIntoView({block: 'center', inline: 'nearest'})",
"args": [{ELEMENT_KEY: element}]})
self._call("POST", self._s(f"/element/{element}/click"), {})
def clear(self, element: str) -> None:
@@ -183,6 +293,21 @@ class Browser:
answer = self._call("GET", self._s("/element/active"))
return list(answer["value"].values())[0]
# -------------------------------------------------------------- windows
@property
def window(self) -> str:
return self._call("GET", self._s("/window"))["value"]
def new_tab(self) -> str:
return self._call("POST", self._s("/window/new"), {"type": "tab"})["value"]["handle"]
def switch_to(self, handle: str) -> None:
self._call("POST", self._s("/window"), {"handle": handle})
def close_window(self) -> None:
self._call("DELETE", self._s("/window"))
# ------------------------------------------------------------- waiting
def wait_for(self, css: str, *, timeout=90, gone=False):
@@ -196,12 +321,19 @@ class Browser:
f"{'still present' if gone else 'never appeared'}: {css}")
def wait_until(self, script: str, *, timeout=90, what=""):
if self.wait_js(script, timeout=timeout):
return True
raise WebDriverError(f"condition never held: {what or script}")
def wait_js(self, script: str, *, timeout=90) -> bool:
"""Whether `script` became true within `timeout`. For a check to record,
where `wait_until` is for a precondition that must hold."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.js(f"return ({script})"):
return True
time.sleep(0.25)
raise WebDriverError(f"condition never held: {what or script}")
return False
class Site: