blob: 3e2b0b9457b2cd830ebb44ffccc2a611d6643417 [file] [edit]
"""Docker-free tests for the E2E harness's configuration and lifecycle."""
import importlib
import importlib.util
import json
import os
import pytest
@pytest.fixture
def support():
assert importlib.util.find_spec("tests.e2e.support") is not None, (
"E2E support is missing"
)
return importlib.import_module("tests.e2e.support")
@pytest.fixture
def external_config(tmp_path):
auth = tmp_path / "auth.json"
auth.write_text("{}")
path = tmp_path / "e2e.json"
data = {
"gerrit_base_url": "https://gerrit.example.com",
"known_cl": "123",
"known_user": "reader@example.com",
"gerrit_config_path": "auth.json",
}
path.write_text(json.dumps(data))
return path, data
def test_external_config_resolves_auth_path_and_defaults_branch(
support, external_config
):
path, _ = external_config
result = support.load_external_config(path)
assert result["gerrit_config_path"] == str(path.parent / "auth.json")
assert result["test_branch"] == "main"
assert not result.get("test_project")
@pytest.mark.parametrize(
"missing", ["gerrit_base_url", "known_cl", "known_user", "gerrit_config_path"]
)
def test_external_config_requires_read_inputs(support, external_config, missing):
path, data = external_config
del data[missing]
path.write_text(json.dumps(data))
with pytest.raises(ValueError, match=missing):
support.load_external_config(path)
def test_external_config_rejects_missing_auth_file(support, external_config):
path, _ = external_config
(path.parent / "auth.json").unlink()
with pytest.raises(ValueError, match="auth.json"):
support.load_external_config(path)
def test_external_backend_never_constructs_container(
support, external_config, tmp_path
):
path, _ = external_config
def forbidden():
pytest.fail("external mode attempted to construct a container")
with support.backend_config(
"external", path, tmp_path, container_factory=forbidden
) as config:
assert config["known_cl"] == "123"
def test_temporary_config_restores_environment_after_failure(
support, monkeypatch, tmp_path
):
monkeypatch.setenv("GERRIT_CONFIG_PATH", "original.json")
with pytest.raises(RuntimeError):
with support.temporary_config(
{"gerrit_config_path": "temporary.json"}, tmp_path
):
assert os.environ["GERRIT_CONFIG_PATH"] == "temporary.json"
raise RuntimeError("test failed")
assert os.environ["GERRIT_CONFIG_PATH"] == "original.json"
def test_temporary_config_restores_absent_environment(support, monkeypatch, tmp_path):
monkeypatch.delenv("GERRIT_CONFIG_PATH", raising=False)
with support.temporary_config({"gerrit_config_path": "temporary.json"}, tmp_path):
assert os.environ["GERRIT_CONFIG_PATH"] == "temporary.json"
assert "GERRIT_CONFIG_PATH" not in os.environ
def test_readiness_retries_then_returns(support, monkeypatch):
responses = iter([OSError("starting"), {"_account_id": 1000000}])
def probe():
result = next(responses)
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(support.time, "sleep", lambda _: None)
assert support.wait_for_ready(probe, timeout=1)["_account_id"] == 1000000
def test_readiness_timeout_preserves_last_error(support, monkeypatch):
ticks = iter([0, 0, 2])
monkeypatch.setattr(support.time, "monotonic", lambda: next(ticks))
monkeypatch.setattr(support.time, "sleep", lambda _: None)
def probe():
raise OSError("connection refused")
with pytest.raises(RuntimeError, match="connection refused"):
support.wait_for_ready(probe, timeout=1)
class FakeContainer:
def __init__(self, fail_start=False):
self.stopped = False
self.fail_start = fail_start
def start(self):
if self.fail_start:
raise RuntimeError("startup failed")
return self
def stop(self):
self.stopped = True
def get_logs(self):
return b"Gerrit startup log", b"diagnostic stderr"
@pytest.mark.parametrize("fail_start", [False, True])
def test_container_cleanup_and_logs_on_failure(support, tmp_path, fail_start):
container = FakeContainer(fail_start)
with pytest.raises(RuntimeError):
with support.running_gerrit(lambda: container, tmp_path):
raise RuntimeError("seeding failed")
assert container.stopped
assert "Gerrit startup log" in (tmp_path / "gerrit-container.log").read_text()
def test_container_cleanup_on_success(support, tmp_path):
container = FakeContainer()
with support.running_gerrit(lambda: container, tmp_path) as running:
assert running is container
assert container.stopped
@pytest.mark.parametrize("host", ["192.0.2.1", "docker.example.com", "0.0.0.0"])
def test_container_endpoint_rejects_non_loopback(support, host):
with pytest.raises(ValueError, match="loopback"):
support.container_url(host, "32768")
def test_container_endpoint_formats_ipv6(support):
assert support.container_url("::1", "32768") == "http://[::1]:32768"
def test_container_factory_binds_http_to_loopback(support):
from unittest.mock import patch
# Only Docker client construction is mocked: inspect the real container config.
with patch("testcontainers.core.container.DockerClient"):
container = support.make_container()
assert container.ports["8080/tcp"] == ("127.0.0.1", 0)
def test_start_failure_is_not_masked_by_unavailable_logs(support, tmp_path):
class NoLogsContainer(FakeContainer):
def get_logs(self):
raise RuntimeError("container does not exist yet")
container = NoLogsContainer(fail_start=True)
with pytest.raises(RuntimeError, match="startup failed"):
with support.running_gerrit(lambda: container, tmp_path):
pytest.fail("startup unexpectedly succeeded")
assert container.stopped
assert (
"container does not exist yet"
in (tmp_path / "gerrit-container.log").read_text()
)
@pytest.mark.parametrize(
"arguments", [[], ["tests/e2e"], ["tests/e2e", "--e2e-backend=external"]]
)
def test_collection_does_not_import_testcontainers(tmp_path, arguments):
import subprocess
import sys
from pathlib import Path
# A real subprocess with an import guard catches eager Docker initialization.
(tmp_path / "sitecustomize.py").write_text(
"import sys\n"
"class Guard:\n"
" def find_spec(self, fullname, path=None, target=None):\n"
" if fullname.split('.')[0] == 'testcontainers':\n"
" raise RuntimeError('Testcontainers imported during collection')\n"
"sys.meta_path.insert(0, Guard())\n"
)
root = Path(__file__).resolve().parents[2]
env = {**os.environ, "PYTHONPATH": str(tmp_path), "PYTEST_ADDOPTS": ""}
result = subprocess.run(
[sys.executable, "-m", "pytest", "--collect-only", "-q", *arguments],
cwd=root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
if not arguments:
assert "test_e2e_query_changes" not in result.stdout
else:
assert "test_e2e_query_changes" in result.stdout
def test_external_missing_config_fails_without_docker(tmp_path):
import subprocess
import sys
from pathlib import Path
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
"tests/e2e",
"--e2e-backend=external",
f"--e2e-config={tmp_path / 'absent.json'}",
"-q",
"--tb=short",
],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 1
assert "Cannot load E2E configuration" in result.stdout
assert "Container E2E failed" not in result.stdout
def test_container_config_bypasses_proxy_for_real_curl(support, monkeypatch, tmp_path):
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"loopback reached")
def log_message(self, *args):
pass
monkeypatch.setenv("http_proxy", "http://127.0.0.1:1")
monkeypatch.setenv("NO_PROXY", "corporate.example")
monkeypatch.delenv("no_proxy", raising=False)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with support.temporary_config({"mcp_config": {}}, tmp_path):
result = subprocess.run(
[
"curl",
"--silent",
"--show-error",
"--fail",
"--max-time",
"3",
f"http://127.0.0.1:{server.server_port}/",
],
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == 0, result.stderr
assert result.stdout == "loopback reached"
assert os.environ["NO_PROXY"] == "corporate.example"
assert "no_proxy" not in os.environ
finally:
server.shutdown()
server.server_close()
thread.join()
def test_external_config_preserves_proxy_settings(support, monkeypatch, tmp_path):
monkeypatch.setenv("no_proxy", "lower.example")
monkeypatch.setenv("NO_PROXY", "upper.example")
with support.temporary_config({"gerrit_config_path": "external.json"}, tmp_path):
assert os.environ["no_proxy"] == "lower.example"
assert os.environ["NO_PROXY"] == "upper.example"
def test_external_read_only_fixture_skips_write_test():
from tests.e2e.conftest import test_project
with pytest.raises(pytest.skip.Exception, match="dedicated 'test_project'"):
test_project.__wrapped__({})
@pytest.mark.asyncio
async def test_created_change_cleanup_abandons_owned_changes(monkeypatch):
from tests.e2e import test_main
status = {"123": "NEW"}
async def create(**kwargs):
return [{"type": "text", "text": "Successfully created new change 123."}]
async def abandon(**kwargs):
status[kwargs["change_id"]] = "ABANDONED"
return [{"type": "text", "text": "Successfully abandoned CL 123."}]
monkeypatch.setattr(test_main.main, "create_change", create)
monkeypatch.setattr(test_main.main, "abandon_change", abandon)
fixture = test_main.created_change.__wrapped__("https://gerrit.example.com")
create_change, _ = await anext(fixture)
await create_change("test", "subject")
# pytest finalizes the fixture even if the consuming test failed.
with pytest.raises(StopAsyncIteration):
await anext(fixture)
assert status["123"] == "ABANDONED"
@pytest.mark.asyncio
async def test_created_change_cleanup_reports_abandonment_failure(monkeypatch):
from tests.e2e import test_main
async def create(**kwargs):
return [{"type": "text", "text": "Successfully created new change 123."}]
async def abandon(**kwargs):
raise RuntimeError("permission denied")
monkeypatch.setattr(test_main.main, "create_change", create)
monkeypatch.setattr(test_main.main, "abandon_change", abandon)
fixture = test_main.created_change.__wrapped__("https://gerrit.example.com")
create_change, _ = await anext(fixture)
await create_change("test", "subject")
with pytest.raises(pytest.fail.Exception, match="permission denied"):
await anext(fixture)
@pytest.mark.asyncio
async def test_finished_change_is_not_abandoned_again(monkeypatch):
from tests.e2e import test_main
async def create(**kwargs):
return [{"type": "text", "text": "Successfully created new change 123."}]
async def forbidden(**kwargs):
pytest.fail("already-abandoned change was abandoned again")
monkeypatch.setattr(test_main.main, "create_change", create)
monkeypatch.setattr(test_main.main, "abandon_change", forbidden)
fixture = test_main.created_change.__wrapped__("https://gerrit.example.com")
create_change, finished = await anext(fixture)
_, number = await create_change("test", "subject")
finished(number)
with pytest.raises(StopAsyncIteration):
await anext(fixture)