@ 261101.7

261030.4 · 6182a9aa802e833d238ab04b403b428f42fe37f9

A black-box suite runs the built binary, starting with the 404 coin

make blackbox runs tests/ with pytest and httpx against a server in a throwaway home, on --public and on the form wwwvm's unit uses. Cases are TOML files named after the decision they check.

The coin shows on both forms for a path a served repo lacks. The cache message appears only without --public, for a path that names no repo.

reading declarations

Changes to tests/. All files

tests/.gitignore

new file mode 100644@@ -0,0 +1,2 @@+.venv/+__pycache__/

tests/cases/a-missing-path-s-404-turns-like-a-coin.toml

new file mode 100644@@ -0,0 +1,38 @@+# A path a served repo lacks shows the coin, on either form of server.+servers = ["public", "default"]++[[request]]+path = "/forge.invalid/acct/r/nope.md"+status = 404+contains = ['class="coin"', "not found"]+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/dir/nope.go"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope/"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/README.md/nope"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope.md?log"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope.md?raw"+status = 404+contains = ['class="coin"']+absent = ["cache"]

tests/cases/online-repos-open-on-a-second-address.toml

new file mode 100644@@ -0,0 +1,14 @@+# Without --public a path outside any repo falls back to the cache's own+# listing. This is the message wwwvm's unit shows, since it runs this form.+servers = ["default"]++[[request]]+path = "/forge.invalid/acct/nope"+status = 404+contains = ["No repo answers at", "listed by the cache", "The cache holds only what was read here"]+absent = ['class="coin"']++[[request]]+path = "/forge.invalid/acct"+status = 200+contains = ["r/", "listed by the cache"]

tests/cases/public-serves-only-online-repos-to-the-open-web.toml

new file mode 100644@@ -0,0 +1,13 @@+# A stranger learns nothing of the cache: no word of it, and no repo that+# another visitor read.+servers = ["public"]++[[request]]+path = "/forge.invalid"+status = 502+absent = ["cache", "acct/r"]++[[request]]+path = "/forge.invalid/acct/nope"+status = 502+absent = ["cache"]

tests/harness.py

new file mode 100644@@ -0,0 +1,154 @@+"""Run the binary named by TEST_BIN in a throwaway home and ask it for pages."""++import os+import signal+import socket+import subprocess+import time+from pathlib import Path++import httpx++GIT_DATE = "2026-01-02T03:04:05Z"++# Every repo the seeded cache holds. The host never resolves, so no case+# reaches a network.+SEEDED = {"forge.invalid/acct/r": {"README.md": "# r\n"}}+++def binary():+    path = os.environ.get("TEST_BIN")+    if not path:+        raise RuntimeError("TEST_BIN must name the binary; make blackbox sets it")+    return Path(path).resolve()+++def environment(home):+    """The whole environment of a run: nothing leaks in from the caller's."""+    return {+        "PATH": os.environ["PATH"],+        "HOME": str(home),+        "XDG_CACHE_HOME": str(home / "cache"),+        "XDG_CONFIG_HOME": str(home / "config"),+        "XDG_STATE_HOME": str(home / "state"),+        "GIT_CONFIG_GLOBAL": os.devnull,+        "GIT_CONFIG_NOSYSTEM": "1",+        "TZ": "UTC",+    }+++def git(cwd, *args):+    env = {+        "PATH": os.environ["PATH"],+        "GIT_CONFIG_GLOBAL": os.devnull,+        "GIT_CONFIG_NOSYSTEM": "1",+        "GIT_AUTHOR_NAME": "a",+        "GIT_AUTHOR_EMAIL": "a@forge.invalid",+        "GIT_AUTHOR_DATE": GIT_DATE,+        "GIT_COMMITTER_NAME": "a",+        "GIT_COMMITTER_EMAIL": "a@forge.invalid",+        "GIT_COMMITTER_DATE": GIT_DATE,+    }+    done = subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True)+    return done.stdout.strip()+++def seed(cache, key, files):+    """Lay out one repo as the binary leaves it after a clone.++    A public server fetches only over https from the open internet, so a+    case with no network cannot have it clone. This is the one place the+    suite knows the cache's layout.+    """+    source = cache.parent / "sources" / key+    source.mkdir(parents=True)+    git(source, "init", "-q", "-b", "main")+    for name, text in files.items():+        (source / name).parent.mkdir(parents=True, exist_ok=True)+        (source / name).write_text(text)+    git(source, "add", ".")+    git(source, "commit", "-q", "-m", "first")+    head = git(source, "rev-parse", "HEAD")+    repo = cache / key+    repo.mkdir(parents=True)+    git(repo, "clone", "-q", "--bare", str(source), ".git")+    name = ".wt-" + head[:12]+    git(repo / ".git", "worktree", "add", "-q", "--detach", "--", str(repo / name), head)+    (repo / ".tree").symlink_to(name)+    (repo / ".fetched").touch()+    (repo / ".used").touch()+++def free_port():+    with socket.socket() as s:+        s.bind(("127.0.0.1", 0))+        return s.getsockname()[1]+++class Server:+    """One running binary. origin is where its repos answer."""++    def __init__(self, home, argv, origin, listens):+        self.origin = origin+        self.log = home / "stderr"+        self.proc = subprocess.Popen(+            [str(binary()), *argv],+            cwd=home,+            env=environment(home),+            stdin=subprocess.DEVNULL,+            stdout=subprocess.DEVNULL,+            stderr=self.log.open("w"),+        )+        self._wait(listens)++    def _wait(self, addr):+        end = time.monotonic() + 10+        while time.monotonic() < end:+            if self.proc.poll() is not None:+                raise RuntimeError(f"exit {self.proc.returncode}: {self.log.read_text()}")+            try:+                socket.create_connection(addr, timeout=0.2).close()+                return+            except OSError:+                time.sleep(0.02)+        raise TimeoutError(f"nothing listens on {addr}: {self.log.read_text()}")++    def get(self, path):+        """The page at path once any read it set going has ended."""+        end = time.monotonic() + 30+        while True:+            page = httpx.get(self.origin + path, trust_env=False, timeout=30)+            if page.status_code != 202 or time.monotonic() > end:+                return page+            time.sleep(0.1)++    def stop(self):+        self.proc.send_signal(signal.SIGTERM)+        try:+            self.proc.wait(10)+        except subprocess.TimeoutExpired:+            self.proc.kill()+            self.proc.wait()+++# public: the flag the README names for the open web.+# default: a reader's server, as wwwvm's unit runs it, whose online repos+# answer on the .2 beside the user's default address.+def start(form, home):+    """Start the binary in one of the forms a server runs in."""+    cache = home / "cache" / binary().name+    for key, files in SEEDED.items():+        seed(cache, key, files)+    port = free_port()+    if form == "public":+        return Server(home, ["--public", "--disk=1G", "-a", f"127.0.0.1:{port}"],+                      f"http://127.0.0.1:{port}", ("127.0.0.1", port))+    if form == "default":+        n = os.getuid() - 1000+        if not 0 <= n <= 255:+            raise OSError(f"uid {os.getuid()} has no default address")+        tree = home / "tree"+        tree.mkdir()+        return Server(home, ["--foreground", "--no-open", "--no-bug", "--disk=1G", "-a", f"127.31.{n}.1:{port}", str(tree)],+                      f"http://127.31.{n}.2:{port}", (f"127.31.{n}.2", port))+    raise ValueError(form)

tests/pyproject.toml

new file mode 100644@@ -0,0 +1,13 @@+[project]+name = "blackbox-tests"+version = "0"+description = "Black-box tests of the binary; see doc/testing.md"+requires-python = ">=3.11"+dependencies = ["httpx==0.28.1", "pytest==8.4.2"]++[tool.uv]+package = false++[tool.pytest.ini_options]+testpaths = ["."]+addopts = "-q -ra -p no:cacheprovider"

tests/test_cases.py

new file mode 100644@@ -0,0 +1,60 @@+"""Each file in cases/ names the decision it checks and the pages that hold it."""++import re+import shutil+import tempfile+import tomllib+from pathlib import Path++import pytest++import harness++CASES = Path(__file__).parent / "cases"+++def requests():+    for file in sorted(CASES.glob("*.toml")):+        case = tomllib.loads(file.read_text())+        for req in case["request"]:+            for form in req.get("servers", case["servers"]):+                yield pytest.param(form, req, id=f"{file.stem}-{form}-{req['path']}")+++@pytest.fixture(scope="session")+def servers():+    """One server of each form, started when a case first asks for it."""+    running = {}+    homes = []++    def get(form):+        if form not in running:+            home = Path(tempfile.mkdtemp(prefix="blackbox-"))+            homes.append(home)+            try:+                running[form] = harness.start(form, home)+            except OSError as err:+                pytest.skip(f"{form}: {err}")+        return running[form]++    yield get+    for server in running.values():+        server.stop()+    for home in homes:+        shutil.rmtree(home, ignore_errors=True)+++def visible(html):+    """The page without its script and style, whose words a reader never sees."""+    return re.sub(r"<(script|style)\b.*?</\1>", "", html, flags=re.S | re.I)+++@pytest.mark.parametrize("form, req", requests())+def test_page(servers, form, req):+    page = servers(form).get(req["path"])+    text = visible(page.text)+    assert page.status_code == req["status"], text+    for want in req.get("contains", []):+        assert want in text+    for ban in req.get("absent", []):+        assert ban.lower() not in text.lower()

tests/uv.lock

new file mode 100644@@ -0,0 +1,156 @@+version = 1+revision = 3+requires-python = ">=3.11"++[[package]]+name = "anyio"+version = "4.15.1"+source = { registry = "https://pypi.org/simple" }+dependencies = [+    { name = "idna" },+    { name = "typing-extensions", marker = "python_full_version < '3.15'" },+]+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },+]++[[package]]+name = "blackbox-tests"+version = "0"+source = { virtual = "." }+dependencies = [+    { name = "httpx" },+    { name = "pytest" },+]++[package.metadata]+requires-dist = [+    { name = "httpx", specifier = "==0.28.1" },+    { name = "pytest", specifier = "==8.4.2" },+]++[[package]]+name = "certifi"+version = "2026.10.23"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },+]++[[package]]+name = "colorama"+version = "0.4.6"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },+]++[[package]]+name = "h11"+version = "0.16.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },+]++[[package]]+name = "httpcore"+version = "1.0.9"+source = { registry = "https://pypi.org/simple" }+dependencies = [+    { name = "certifi" },+    { name = "h11" },+]+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },+]++[[package]]+name = "httpx"+version = "0.28.1"+source = { registry = "https://pypi.org/simple" }+dependencies = [+    { name = "anyio" },+    { name = "certifi" },+    { name = "httpcore" },+    { name = "idna" },+]+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },+]++[[package]]+name = "idna"+version = "3.20"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },+]++[[package]]+name = "iniconfig"+version = "2.3.1"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },+]++[[package]]+name = "packaging"+version = "26.3"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },+]++[[package]]+name = "pluggy"+version = "1.7.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },+]++[[package]]+name = "pygments"+version = "2.21.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },+]++[[package]]+name = "pytest"+version = "8.4.2"+source = { registry = "https://pypi.org/simple" }+dependencies = [+    { name = "colorama", marker = "sys_platform == 'win32'" },+    { name = "iniconfig" },+    { name = "packaging" },+    { name = "pluggy" },+    { name = "pygments" },+]+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },+]++[[package]]+name = "typing-extensions"+version = "4.16.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }+wheels = [+    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },+]