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:
co-authored by
Claude Opus 5
parent
0c1ba836ba
commit
59b5ebc2d8
@@ -0,0 +1,91 @@
|
||||
"""v1.1 WP-C: the browser harness's download helpers, without a browser.
|
||||
|
||||
`tools/m11_webdriver.wait_for_download` is what decides that an export actually
|
||||
left the browser as a file. It must never call a download finished because a
|
||||
file appeared, because it is still being written, or because it is empty — each
|
||||
of those would make "the export works" a claim the evidence does not support.
|
||||
|
||||
python -m pytest tests/test_v11_c_browser_helpers.py -v
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import m11_webdriver as wd
|
||||
|
||||
|
||||
def later(seconds, action):
|
||||
timer = threading.Timer(seconds, action)
|
||||
timer.start()
|
||||
return timer
|
||||
|
||||
|
||||
def test_a_finished_file_is_returned(tmp_path):
|
||||
later(0.2, lambda: (tmp_path / "campaign.json").write_text('{"format": "x"}'))
|
||||
found = wd.wait_for_download(tmp_path, set(), timeout=5, poll=0.05)
|
||||
assert found == tmp_path / "campaign.json"
|
||||
|
||||
|
||||
def test_a_file_that_was_already_there_is_not_the_download(tmp_path):
|
||||
(tmp_path / "old.json").write_text("{}")
|
||||
with pytest.raises(wd.WebDriverError):
|
||||
wd.wait_for_download(tmp_path, {"old.json"}, timeout=0.6, poll=0.05)
|
||||
|
||||
|
||||
def test_an_empty_file_never_counts(tmp_path):
|
||||
(tmp_path / "empty.json").write_bytes(b"")
|
||||
with pytest.raises(wd.WebDriverError):
|
||||
wd.wait_for_download(tmp_path, set(), timeout=0.6, poll=0.05)
|
||||
|
||||
|
||||
def test_nothing_counts_while_firefox_is_still_writing(tmp_path):
|
||||
"""Firefox writes `<name>.part` beside the final name until it is done."""
|
||||
(tmp_path / "campaign.json").write_text('{"format": "x"}')
|
||||
(tmp_path / "campaign.json.part").write_text("")
|
||||
with pytest.raises(wd.WebDriverError):
|
||||
wd.wait_for_download(tmp_path, set(), timeout=0.6, poll=0.05)
|
||||
later(0.1, lambda: (tmp_path / "campaign.json.part").unlink())
|
||||
assert wd.wait_for_download(tmp_path, set(), timeout=5, poll=0.05).name == "campaign.json"
|
||||
|
||||
|
||||
def test_a_file_that_is_still_growing_is_not_finished(tmp_path):
|
||||
target = tmp_path / "big.json"
|
||||
target.write_text("{")
|
||||
stop = threading.Event()
|
||||
|
||||
def grow():
|
||||
for _ in range(8):
|
||||
if stop.is_set():
|
||||
return
|
||||
with target.open("a") as fh:
|
||||
fh.write("x" * 100)
|
||||
time.sleep(0.05)
|
||||
|
||||
writer = threading.Thread(target=grow)
|
||||
started = time.monotonic()
|
||||
writer.start()
|
||||
found = wd.wait_for_download(tmp_path, set(), timeout=5, poll=0.05, stable_polls=3)
|
||||
writer.join()
|
||||
# Returned only once the size held still, so after the last write.
|
||||
assert found == target
|
||||
assert target.stat().st_size == 1 + 8 * 100
|
||||
assert time.monotonic() - started >= 0.4
|
||||
|
||||
|
||||
def test_the_prefs_save_downloads_unasked_to_the_folder_given(tmp_path):
|
||||
prefs = wd.firefox_download_prefs(tmp_path)
|
||||
assert prefs["browser.download.folderList"] == 2
|
||||
assert prefs["browser.download.dir"] == str(tmp_path)
|
||||
assert prefs["browser.download.useDownloadDir"] is True
|
||||
assert prefs["browser.download.always_ask_before_handling_new_types"] is False
|
||||
assert "application/json" in prefs["browser.helperApps.neverAsk.saveToDisk"]
|
||||
|
||||
|
||||
def test_a_download_folder_must_be_under_home():
|
||||
with pytest.raises(wd.WebDriverError):
|
||||
wd.require_under_home(Path("/tmp/wp-c-downloads"))
|
||||
inside = Path.home() / "v11-evidence" / "wp-c" / "downloads"
|
||||
assert wd.require_under_home(inside) == inside.resolve()
|
||||
+884
-137
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user