| 1 | """Instrument tests for agent_loop_hosted_v1 (no network, no credentials).
|
| 2 |
|
| 3 | The counting proxy is exercised against a loopback plaintext HTTP server
|
| 4 | (``use_tls=False``) so request counting, keep-alive framing, chunked bodies,
|
| 5 | Host rewriting, header injection and CONNECT tunnels are all checked locally.
|
| 6 | """
|
| 7 |
|
| 8 | from __future__ import annotations
|
| 9 |
|
| 10 | import http.client
|
| 11 | import socket
|
| 12 | import subprocess
|
| 13 | import sys
|
| 14 | import tempfile
|
| 15 | import threading
|
| 16 | import unittest
|
| 17 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 18 | from pathlib import Path
|
| 19 |
|
| 20 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
| 21 |
|
| 22 | import agent_loop_hosted as alh # noqa: E402
|
| 23 | from oakbench.httpcount import HttpCountingProxy, RequestRecord, route_shape # noqa: E402
|
| 24 |
|
| 25 |
|
| 26 | class _Handler(BaseHTTPRequestHandler):
|
| 27 | protocol_version = "HTTP/1.1"
|
| 28 | seen_hosts: list = []
|
| 29 | seen_injected: list = []
|
| 30 |
|
| 31 | def log_message(self, *a): # noqa: D401
|
| 32 | pass
|
| 33 |
|
| 34 | def do_GET(self): # noqa: N802
|
| 35 | _Handler.seen_hosts.append(self.headers.get("Host"))
|
| 36 | _Handler.seen_injected.append(self.headers.get("X-Bench-Auth"))
|
| 37 | if self.path.startswith("/chunked"):
|
| 38 | self.send_response(200)
|
| 39 | self.send_header("Transfer-Encoding", "chunked")
|
| 40 | self.end_headers()
|
| 41 | for part in (b"hello ", b"world"):
|
| 42 | self.wfile.write(b"%x\r\n%s\r\n" % (len(part), part))
|
| 43 | self.wfile.write(b"0\r\n\r\n")
|
| 44 | return
|
| 45 | body = b"x" * 1000
|
| 46 | self.send_response(200)
|
| 47 | self.send_header("Content-Length", str(len(body)))
|
| 48 | self.end_headers()
|
| 49 | self.wfile.write(body)
|
| 50 |
|
| 51 | def do_POST(self): # noqa: N802
|
| 52 | n = int(self.headers.get("Content-Length", "0"))
|
| 53 | data = self.rfile.read(n)
|
| 54 | self.send_response(200)
|
| 55 | self.send_header("Content-Length", str(len(data)))
|
| 56 | self.end_headers()
|
| 57 | self.wfile.write(data)
|
| 58 |
|
| 59 |
|
| 60 | class HttpCountingProxyTest(unittest.TestCase):
|
| 61 | def setUp(self):
|
| 62 | _Handler.seen_hosts = []
|
| 63 | _Handler.seen_injected = []
|
| 64 | self.srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
| 65 | self.port = self.srv.server_address[1]
|
| 66 | threading.Thread(target=self.srv.serve_forever, daemon=True).start()
|
| 67 | self.proxy = HttpCountingProxy("127.0.0.1", upstream_port=self.port, use_tls=False,
|
| 68 | inject_headers={"X-Bench-Auth": "secret"}).start()
|
| 69 |
|
| 70 | def tearDown(self):
|
| 71 | self.proxy.stop()
|
| 72 | self.srv.shutdown()
|
| 73 |
|
| 74 | def test_counts_keepalive_requests_and_bodies(self):
|
| 75 | host, port = self.proxy.address
|
| 76 | c = http.client.HTTPConnection(host, port, timeout=5)
|
| 77 | c.request("GET", "/api/o/r/branches?token=abc")
|
| 78 | self.assertEqual(len(c.getresponse().read()), 1000)
|
| 79 | c.request("POST", "/api/o/r/commits/info", body=b"y" * 300)
|
| 80 | self.assertEqual(c.getresponse().read(), b"y" * 300)
|
| 81 | c.request("GET", "/chunked")
|
| 82 | self.assertEqual(c.getresponse().read(), b"hello world")
|
| 83 | c.close()
|
| 84 | s = self.proxy.snapshot()
|
| 85 | self.assertEqual(s.requests, 3)
|
| 86 | self.assertEqual(s.connections, 1)
|
| 87 | self.assertEqual([r.method for r in s.records], ["GET", "POST", "GET"])
|
| 88 | # query strings are never recorded (tokens can live there)
|
| 89 | self.assertEqual(s.records[0].path, "/api/o/r/branches")
|
| 90 | self.assertGreaterEqual(s.records[1].request_bytes, 300)
|
| 91 | self.assertGreater(s.response_bytes, 1300)
|
| 92 | # Host rewritten to the upstream; injected header delivered, never recorded
|
| 93 | self.assertTrue(all(h.startswith("127.0.0.1:") for h in _Handler.seen_hosts))
|
| 94 | self.assertEqual(_Handler.seen_injected, ["secret", "secret"])
|
| 95 | self.assertNotIn("secret", repr(s.records))
|
| 96 |
|
| 97 | def test_connect_tunnel_counts_bytes_only(self):
|
| 98 | host, port = self.proxy.address
|
| 99 | sock = socket.create_connection((host, port), timeout=5)
|
| 100 | sock.sendall(f"CONNECT 127.0.0.1:{self.port} HTTP/1.1\r\nHost: x\r\n\r\n".encode())
|
| 101 | self.assertIn(b"200", sock.recv(1024))
|
| 102 | sock.sendall(b"GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
| 103 | data = b""
|
| 104 | while True:
|
| 105 | b = sock.recv(65536)
|
| 106 | if not b:
|
| 107 | break
|
| 108 | data += b
|
| 109 | sock.close()
|
| 110 | self.assertTrue(data.endswith(b"x" * 1000))
|
| 111 | import time
|
| 112 | time.sleep(0.1)
|
| 113 | s = self.proxy.snapshot()
|
| 114 | self.assertEqual(s.requests, 0) # tunnel contents are opaque: no request count
|
| 115 | self.assertEqual(len(s.tunnels), 1)
|
| 116 | self.assertEqual(s.tunnel_hosts()["127.0.0.1"]["connections"], 1)
|
| 117 | self.assertGreater(s.tunnel_bytes, 1000)
|
| 118 |
|
| 119 | def test_route_shape_collapses_hashes(self):
|
| 120 | self.assertEqual(route_shape("/api/o/r/commits/" + "ab" * 32), "/api/o/r/commits/:hash")
|
| 121 | self.assertEqual(route_shape("/api/o/r/runs/12"), "/api/o/r/runs/:n")
|
| 122 |
|
| 123 |
|
| 124 | class SerialRoundsTest(unittest.TestCase):
|
| 125 | def _r(self, s, e):
|
| 126 | return RequestRecord("GET", "/", 200, 0, 0, s, 1, e, 1)
|
| 127 |
|
| 128 | def test_parallel_requests_share_a_round(self):
|
| 129 | self.assertEqual(alh.serial_rounds([self._r(0, 10), self._r(1, 9), self._r(2, 12)]), 1)
|
| 130 |
|
| 131 | def test_sequential_requests_each_cost_a_round(self):
|
| 132 | self.assertEqual(alh.serial_rounds([self._r(0, 10), self._r(11, 20), self._r(21, 30)]), 3)
|
| 133 |
|
| 134 | def test_empty_is_zero_rounds(self):
|
| 135 | self.assertEqual(alh.serial_rounds([]), 0)
|
| 136 |
|
| 137 |
|
| 138 | class GitTraceParseTest(unittest.TestCase):
|
| 139 | def test_http2_request_counted_once(self):
|
| 140 | # Real libcurl HTTP/2 trace shape: info lines AND a Send-header request
|
| 141 | # line for the same request (regression: these were counted twice).
|
| 142 | text = (
|
| 143 | "== Info: [HTTP/2] [1] [:method: GET]\n"
|
| 144 | "== Info: [HTTP/2] [1] [:path: /o/r.git/info/refs?service=git-upload-pack]\n"
|
| 145 | "=> Send header: GET /o/r.git/info/refs?service=git-upload-pack HTTP/2\n"
|
| 146 | "<= Recv header: HTTP/2 401\n"
|
| 147 | "== Info: [HTTP/2] [3] [authorization: Basic <redacted>]\n"
|
| 148 | "== Info: [HTTP/2] [3] [:method: GET]\n"
|
| 149 | "== Info: [HTTP/2] [3] [:path: /o/r.git/info/refs?service=git-upload-pack]\n"
|
| 150 | "=> Send header: GET /o/r.git/info/refs?service=git-upload-pack HTTP/2\n"
|
| 151 | "== Info: [HTTP/2] [5] [:method: POST]\n"
|
| 152 | "== Info: [HTTP/2] [5] [:path: /o/r.git/git-upload-pack]\n"
|
| 153 | "=> Send header: POST /o/r.git/git-upload-pack HTTP/2\n"
|
| 154 | "=> Send header: POST /o/r.git/git-receive-pack HTTP/1.1\n"
|
| 155 | )
|
| 156 | with tempfile.NamedTemporaryFile("w", delete=False) as fh:
|
| 157 | fh.write(text)
|
| 158 | n, routes = alh.parse_git_trace(fh.name)
|
| 159 | self.assertEqual(n, 4)
|
| 160 | self.assertEqual(routes, {"GET /info/refs": 2, "POST /git-upload-pack": 1, "POST /git-receive-pack": 1})
|
| 161 |
|
| 162 |
|
| 163 | class OracleTest(unittest.TestCase):
|
| 164 | def test_workspace_digest_matches_git_blob_ids_and_ignores_vcs_dirs(self):
|
| 165 | with tempfile.TemporaryDirectory() as d:
|
| 166 | root = Path(d)
|
| 167 | subprocess.run(["git", "init", "-q", str(root)], check=True)
|
| 168 | (root / "a.txt").write_text("alpha\n")
|
| 169 | (root / "sub").mkdir()
|
| 170 | (root / "sub" / "b.txt").write_text("beta\n")
|
| 171 | (root / ".oak").mkdir()
|
| 172 | (root / ".oak" / "x").write_text("ignored")
|
| 173 | env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x", "PATH": "/usr/bin:/bin:/opt/homebrew/bin"}
|
| 174 | subprocess.run(["git", "-C", d, "add", "a.txt", "sub"], check=True)
|
| 175 | subprocess.run(["git", "-C", d, "commit", "-q", "-m", "x"], check=True, env=env)
|
| 176 | self.assertEqual(alh.workspace_digest(root), alh.fixture_tree(root, "HEAD"))
|
| 177 | ok, _ = alh.acquire_oracle(root, root)
|
| 178 | self.assertTrue(ok)
|
| 179 | (root / "a.txt").write_text("tampered\n")
|
| 180 | ok, detail = alh.acquire_oracle(root, root)
|
| 181 | self.assertFalse(ok)
|
| 182 | self.assertIn("differ=1", detail)
|
| 183 |
|
| 184 | def test_task_patch_is_three_files_about_fifty_lines(self):
|
| 185 | p = alh.task_patch("t")
|
| 186 | self.assertEqual(len(p), 3)
|
| 187 | lines = sum(text.count("\n") for _k, text in p.values())
|
| 188 | self.assertTrue(45 <= lines <= 55, lines)
|
| 189 | self.assertTrue(set(p) <= alh.TASK_PATHS)
|
| 190 |
|
| 191 |
|
| 192 | class SeedFixtureTest(unittest.TestCase):
|
| 193 | def test_seed_is_deterministic_sliced_and_has_merges(self):
|
| 194 | import seed_agent_loop_fixture as seed
|
| 195 | with tempfile.TemporaryDirectory() as d:
|
| 196 | src = Path(d) / "src"
|
| 197 | for i in range(230):
|
| 198 | p = src / f"dir{i % 7}" / f"f{i}.txt"
|
| 199 | p.parent.mkdir(parents=True, exist_ok=True)
|
| 200 | p.write_text(f"file {i}\n")
|
| 201 | (src / ".github").mkdir()
|
| 202 | (src / ".github" / "ci.yml").write_text("excluded\n")
|
| 203 | heads = []
|
| 204 | for name in ("a", "b"):
|
| 205 | g = Path(d) / name
|
| 206 | seed.seed_git(src, g, commits=40, seed=7, merge_every=5, files=5)
|
| 207 | out = subprocess.run(["git", "-C", str(g), "rev-parse", "main"], capture_output=True, text=True).stdout.strip()
|
| 208 | heads.append(out)
|
| 209 | count = int(subprocess.run(["git", "-C", str(g), "rev-list", "--count", "main"], capture_output=True, text=True).stdout)
|
| 210 | merges = int(subprocess.run(["git", "-C", str(g), "rev-list", "--count", "--merges", "main"], capture_output=True, text=True).stdout)
|
| 211 | self.assertEqual(count, 40)
|
| 212 | self.assertGreater(merges, 0)
|
| 213 | files = subprocess.run(["git", "-C", str(g), "ls-files"], capture_output=True, text=True).stdout.split()
|
| 214 | self.assertNotIn(".github/ci.yml", files)
|
| 215 | # every commit in the import slices adds <= IMPORT_SLICE files
|
| 216 | first = subprocess.run(["git", "-C", str(g), "rev-list", "--reverse", "main"], capture_output=True, text=True).stdout.split()[0]
|
| 217 | n0 = len(subprocess.run(["git", "-C", str(g), "ls-tree", "-r", "--name-only", first], capture_output=True, text=True).stdout.split())
|
| 218 | self.assertLessEqual(n0, seed.IMPORT_SLICE)
|
| 219 | self.assertEqual(heads[0], heads[1])
|
| 220 |
|
| 221 |
|
| 222 | if __name__ == "__main__":
|
| 223 | unittest.main()
|