Third Python implementation in the latency-tool progression. Mirrors latprobe's feature set but replaces raw sockets with httpx, gaining HTTP/2 support and redirect following. Package layout (hxprobe/hxprobe/): - probe.py: asyncio + httpx.AsyncClient with a _TimingStream transport wrapper that captures DNS/connect/TLS/TTFB/transfer phase timings via httpx event hooks (get_connection_stats, request_started, etc.). VerboseDetail captures resolved IP, TLS version/cipher/bits, cert CN/ expiry/issuer (from httpx's SSLObject), and response headers. Options: timeout, verbose, follow_redirects, http2 - aggregate.py: summarize() → per-phase min/avg/max (same schema as latprobe) - cli.py: run(args,stdout,stderr)->int; argparse with redirect_stdout/ redirect_stderr + SystemExit catch; -n/--count, -c/--concurrency, --timeout, --fail, --json, -v/--verbose, --no-follow-redirects, --no-http2, -f/--file (URL list from file, mutually exclusive with args); multi-URL summary footer (tally + exit label) when len(urls) > 1; worst-exit-code logic mirrors Go/latprobe; JSON output is bare array - duration.py: same parse_duration() as latprobe - Exit codes: 0 ok, 1 usage, 2 dns, 3 connect, 4 timeout, 5 tls, 6 http≥400 Tests (hxprobe/tests/): - test_probe.py: 18 hermetic tests using anyio + in-process ASGI servers - test_cli.py: 36 hermetic tests (success, failures, JSON, verbose, -f flag, run-summary footer, worst-code accumulation) - test_integration.py: pytest-marked @integration (excluded from hx-test) Toolchain: uv + ruff + pytest; pyproject.toml with [dependency-groups]; hxprobe/Makefile standalone (help, deps, run, lint, fmt, test, test-integration, check, clean); .python-version pins 3.14 Configs: 8 fixture files mirroring python/configs/ (all-ok through mixed) USAGE.md: 16 runnable examples with real captured output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
"""Integration tests against live internet services for hxprobe.
|
|
|
|
Run with:
|
|
make hx-test-integration
|
|
cd hxprobe && uv run pytest tests -m integration -v
|
|
|
|
Marked with `pytest.mark.integration` (see module-level `pytestmark` below)
|
|
so it's excluded from the default `hx-test`/`make test` gate, which runs
|
|
`pytest -m "not integration"` — these hit the real internet.
|
|
|
|
The point of this file is specifically hxprobe's headline capabilities: real
|
|
HTTP/2 negotiation and redirect-following.
|
|
"""
|
|
|
|
import io
|
|
import socket
|
|
import unittest
|
|
|
|
import pytest
|
|
|
|
from hxprobe.cli import EXIT_DNS, EXIT_OK, run
|
|
from hxprobe.probe import Options, measure
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _online() -> bool:
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.settimeout(3)
|
|
s.connect(("8.8.8.8", 53))
|
|
s.close()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
_NEEDS_NET = unittest.skipUnless(_online(), "no internet connectivity")
|
|
|
|
|
|
def _run(args: list[str]) -> tuple[int, str, str]:
|
|
out, err = io.StringIO(), io.StringIO()
|
|
code = run(args, out, err)
|
|
return code, out.getvalue(), err.getvalue()
|
|
|
|
|
|
@_NEEDS_NET
|
|
class TestHTTP2Negotiation(unittest.TestCase):
|
|
"""The headline Go-parity feature: real ALPN HTTP/2, not forced HTTP/1.1."""
|
|
|
|
def test_negotiates_h2_against_cloudflare(self):
|
|
r = measure("https://example.com", Options(verbose=True))
|
|
self.assertIsNone(r.err, r.err)
|
|
self.assertEqual(r.detail.http_version, "HTTP/2")
|
|
|
|
def test_no_http2_flag_forces_http1(self):
|
|
code, out, _ = _run(["--no-http2", "-v", "https://example.com"])
|
|
self.assertEqual(code, EXIT_OK)
|
|
self.assertIn("HTTP/1.1", out)
|
|
self.assertNotIn("HTTP/2", out)
|
|
|
|
|
|
@_NEEDS_NET
|
|
class TestRedirectsLive(unittest.TestCase):
|
|
"""The other headline Go-parity feature: redirects followed by default."""
|
|
|
|
def test_follows_http_to_https_redirect(self):
|
|
code, out, _ = _run(["-v", "http://github.com"])
|
|
self.assertEqual(code, EXIT_OK)
|
|
self.assertIn("200", out)
|
|
self.assertIn("redirect", out)
|
|
|
|
def test_no_follow_redirects_reports_redirect_status(self):
|
|
code, out, _ = _run(["--no-follow-redirects", "http://github.com"])
|
|
self.assertEqual(code, EXIT_OK)
|
|
self.assertNotIn("redirect_count", out) # text mode never shows the raw key
|
|
self.assertTrue(any(code_str in out for code_str in ("301", "302", "307", "308")))
|
|
|
|
|
|
@_NEEDS_NET
|
|
class TestSuccess(unittest.TestCase):
|
|
def test_https_all_phases_present(self):
|
|
r = measure("https://example.com")
|
|
self.assertIsNone(r.err, r.err)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertTrue(r.dns.present)
|
|
self.assertTrue(r.connect.present)
|
|
self.assertTrue(r.tls.present)
|
|
self.assertTrue(r.ttfb.present)
|
|
self.assertTrue(r.transfer.present)
|
|
self.assertTrue(r.total.present)
|
|
|
|
def test_http_no_tls_phase(self):
|
|
r = measure("http://example.com")
|
|
self.assertIsNone(r.err, r.err)
|
|
self.assertFalse(r.tls.present)
|
|
|
|
def test_timings_are_positive(self):
|
|
r = measure("https://example.com")
|
|
for attr in ("dns", "connect", "tls", "ttfb", "transfer", "total"):
|
|
ph = getattr(r, attr)
|
|
if ph.present:
|
|
self.assertGreater(ph.ms, 0, f"{attr}.ms should be > 0")
|
|
|
|
|
|
@_NEEDS_NET
|
|
class TestDNSFailure(unittest.TestCase):
|
|
def test_invalid_tld_phase(self):
|
|
r = measure("http://no.such.host.invalid")
|
|
self.assertEqual(r.fail_phase, "dns")
|
|
self.assertIsNotNone(r.err)
|
|
self.assertFalse(r.dns.present)
|
|
self.assertFalse(r.connect.present)
|
|
self.assertTrue(r.total.present)
|
|
|
|
def test_cli_dns_failure_exit_code(self):
|
|
code, out, _ = _run(["http://no.such.host.invalid"])
|
|
self.assertEqual(code, EXIT_DNS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|