diff --git a/e2e/binary/BUILD.bazel b/e2e/binary/BUILD.bazel index ede035ea54f5c456ab5ecf10210c8c69c79debe8..24cf276de710eca3e2a6118cd09458045dd8224e 100644 --- a/e2e/binary/BUILD.bazel +++ b/e2e/binary/BUILD.bazel @@ -15,8 +15,10 @@ py_library( "__init__.py", "conftest.py", "difference.py", + "port.py", "relative.py", "runfile.py", + "sniff.py", "tool.py", ], # FIXME: when `rules_python` hermetic launcher works on Windows diff --git a/e2e/binary/__init__.py b/e2e/binary/__init__.py index f79c189427d55c4680385037f927f24f87a82dec..1798572fc09e0e4c3487ff1a70a38e5a051f1319 100644 --- a/e2e/binary/__init__.py +++ b/e2e/binary/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from binary.difference import Diff from binary.relative import Relative +from binary.sniff import sniff from binary.tool import Tool -__all__ = ("Diff", "Tool", "Relative") +__all__ = ("sniff", "Diff", "Tool", "Relative") diff --git a/e2e/binary/conftest.py b/e2e/binary/conftest.py index f85f9f59e437a5c7ce5d4d2ffca51c494675093c..b83c6382a4b39cefd427fd277fc03af73b0818f7 100644 --- a/e2e/binary/conftest.py +++ b/e2e/binary/conftest.py @@ -2,13 +2,12 @@ from __future__ import annotations from pytest import fixture -from .difference import Diff from .difference import repr as diff from .relative import Relative from .runfile import Runfile, SupportsRlocation, create from .tool import Tool -__all__ = ("Diff", "Relative", "Tool", "Runfile") +__all__ = ("Relative", "Tool", "Runfile") @fixture diff --git a/e2e/binary/greenbean/BUILD.bazel b/e2e/binary/greenbean/BUILD.bazel index d8d31d9756937443430e77a8b8ab048f98aa4623..d34920bb459e1407a042f683724a16e03db42217 100644 --- a/e2e/binary/greenbean/BUILD.bazel +++ b/e2e/binary/greenbean/BUILD.bazel @@ -1,11 +1,22 @@ -load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("@rules_python_pytest//python_pytest:defs.bzl", "py_pytest_test") -# TODO: write an _actual_ test for `greenbean` +py_pytest_test( + name = "pytest", + size = "small", + srcs = ["serve.py"], + data = [ + "expected.html", + "@ape//ape:greenbean", + ], + deps = [ + "//binary:pytest", + ], +) -build_test( +test_suite( name = "greenbean", - size = "small", - tags = ["stub"], - targets = ["@ape//ape:greenbean"], + tests = [ + ":pytest", + ], visibility = ["//:__subpackages__"], ) diff --git a/e2e/binary/greenbean/expected.html b/e2e/binary/greenbean/expected.html new file mode 100644 index 0000000000000000000000000000000000000000..9010ee2d56daba0aec5915a9272bb611b5d5c135 --- /dev/null +++ b/e2e/binary/greenbean/expected.html @@ -0,0 +1,5 @@ + +hello world +

hello world

+

this is a fun webpage +

hosted by greenbean diff --git a/e2e/binary/greenbean/serve.py b/e2e/binary/greenbean/serve.py new file mode 100644 index 0000000000000000000000000000000000000000..9c5d79ba9cc877a8dafcb167c4c33d7c45c62a34 --- /dev/null +++ b/e2e/binary/greenbean/serve.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from os import read +from pathlib import Path +from select import select +from shutil import copyfileobj +from subprocess import PIPE, Popen +from urllib.request import urlopen + +from binary import Diff, Relative, Tool, sniff +from binary.port import listening +from pytest import mark + + +@mark.skipif(listening(8080), reason="port 8080 is in use") +def test_serve(tool: Tool, relative: Relative, tmp_path: Path) -> None: + binary = tool("greenbean") + expected = relative("expected.html") + output = tmp_path / "output.html" + + cmd = (binary, "1") + server = Popen(cmd, stderr=PIPE, encoding="utf8", cwd=tmp_path) + match = sniff(server.stderr.fileno(), r"listening on (http://.+)", timeout=2) + host = match.group(1) + + with urlopen(host) as src, open(output, "w+b") as dst: + assert 200 <= src.status < 300 + copyfileobj(src, dst) + + assert Diff(expected) == Diff(output) diff --git a/e2e/binary/port.py b/e2e/binary/port.py new file mode 100644 index 0000000000000000000000000000000000000000..221eb68a5be486ee2d7e56b0433b9114f4ba0d18 --- /dev/null +++ b/e2e/binary/port.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from socket import AF_INET, SOCK_STREAM, socket + + +def listening(port: int, host: str = "localhost") -> bool: + with socket(AF_INET, SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 diff --git a/e2e/binary/sniff.py b/e2e/binary/sniff.py new file mode 100644 index 0000000000000000000000000000000000000000..cd98538ad4ca70d6b615a6446e230de35d497455 --- /dev/null +++ b/e2e/binary/sniff.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from concurrent.futures import Executor, ThreadPoolExecutor, TimeoutError +from os import read +from re import Match, Pattern, compile +from select import select + + +def consume(fd: int, /) -> bytes: + buffer = b"" + while r := read(fd, 1024): + buffer += r + if len(r) < 1024: + break + return buffer + + +def detect(fd: int, regex: Pattern[str], /, timeout: int) -> Match[str]: + buffer = b"" + while True: + ready, _, _ = select((fd,), (), (), timeout) + if fd not in ready: + raise TimeoutError(f"failed to select on {fd}") + + buffer += consume(fd) + if not buffer: + raise EOFError("no data to sniff") + line, found, buffer = buffer.partition(b"\n") + if not found: + continue + if match := regex.match(line.decode("utf8")): + return match + + +def pooled(pool: Executor, fd: int, regex: Pattern[str], /, timeout: int) -> str: + future = pool.submit(detect, fd, regex, timeout) + return future.result(timeout=timeout) + + +def threaded(fd: int, regex: Pattern[str], /, threads: int, timeout: int) -> str: + with ThreadPoolExecutor(max_workers=threads) as pool: + return pooled(pool, fd, regex, timeout=timeout) + + +def sniff(fd: int, regex: Pattern[str] | str, /, timeout: int) -> str: + if isinstance(regex, str): + regex = compile(regex) + return threaded(fd, regex, timeout=timeout, threads=1)