From 5002f93fd593ab5d99067915fef88cbc96db3be9 Mon Sep 17 00:00:00 2001 From: Matt Clarkson Date: Wed, 5 Feb 2025 12:18:50 +0000 Subject: [PATCH 1/3] test(e2e): add `sniff` helper Can be used to sniff for text in a stream. Useful for determining listening port for servers. --- e2e/binary/BUILD.bazel | 1 + e2e/binary/__init__.py | 3 ++- e2e/binary/conftest.py | 3 +-- e2e/binary/sniff.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 e2e/binary/sniff.py diff --git a/e2e/binary/BUILD.bazel b/e2e/binary/BUILD.bazel index ede035ea..a2fcbcb2 100644 --- a/e2e/binary/BUILD.bazel +++ b/e2e/binary/BUILD.bazel @@ -17,6 +17,7 @@ py_library( "difference.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 f79c1894..1798572f 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 f85f9f59..b83c6382 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/sniff.py b/e2e/binary/sniff.py new file mode 100644 index 00000000..d6ef7c4e --- /dev/null +++ b/e2e/binary/sniff.py @@ -0,0 +1,44 @@ +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) + line, found, buffer = buffer.partition(b"\n") + 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) -- GitLab From fedfff3c882c60649fc427fc65cb7e5e313959b2 Mon Sep 17 00:00:00 2001 From: Matt Clarkson Date: Wed, 5 Feb 2025 12:31:18 +0000 Subject: [PATCH 2/3] test(e2e): add `port.listening` helper Can be used to skip tests when a port is in use. --- e2e/binary/BUILD.bazel | 1 + e2e/binary/port.py | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 e2e/binary/port.py diff --git a/e2e/binary/BUILD.bazel b/e2e/binary/BUILD.bazel index a2fcbcb2..24cf276d 100644 --- a/e2e/binary/BUILD.bazel +++ b/e2e/binary/BUILD.bazel @@ -15,6 +15,7 @@ py_library( "__init__.py", "conftest.py", "difference.py", + "port.py", "relative.py", "runfile.py", "sniff.py", diff --git a/e2e/binary/port.py b/e2e/binary/port.py new file mode 100644 index 00000000..221eb68a --- /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 -- GitLab From e6ffb76c6ef69d222d12a9763ec73dd144a42c0c Mon Sep 17 00:00:00 2001 From: Matt Clarkson Date: Wed, 5 Feb 2025 11:12:13 +0000 Subject: [PATCH 3/3] test(greenbean): add serve test --- e2e/binary/greenbean/BUILD.bazel | 23 +++++++++++++++++------ e2e/binary/greenbean/expected.html | 5 +++++ e2e/binary/greenbean/serve.py | 30 ++++++++++++++++++++++++++++++ e2e/binary/sniff.py | 4 ++++ 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 e2e/binary/greenbean/expected.html create mode 100644 e2e/binary/greenbean/serve.py diff --git a/e2e/binary/greenbean/BUILD.bazel b/e2e/binary/greenbean/BUILD.bazel index d8d31d97..d34920bb 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 00000000..9010ee2d --- /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 00000000..9c5d79ba --- /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/sniff.py b/e2e/binary/sniff.py index d6ef7c4e..cd98538a 100644 --- a/e2e/binary/sniff.py +++ b/e2e/binary/sniff.py @@ -23,7 +23,11 @@ def detect(fd: int, regex: Pattern[str], /, timeout: int) -> Match[str]: 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 -- GitLab