|
3 | 3 |
|
4 | 4 | import httpx |
5 | 5 | import pytest |
| 6 | +from pydantic import ValidationError |
6 | 7 |
|
7 | 8 | import hyperbrowser.client.managers.async_manager.sandbox as async_sandbox_module |
8 | 9 | import hyperbrowser.client.managers.sync_manager.sandbox as sync_sandbox_module |
|
11 | 12 | SandboxManager as AsyncSandboxManager, |
12 | 13 | ) |
13 | 14 | from hyperbrowser.client.managers.sync_manager.sandbox import SandboxManager |
14 | | -from hyperbrowser.models import SandboxImageBuildUpload |
| 15 | +from hyperbrowser.models import SandboxImageBuild, SandboxImageBuildUpload |
| 16 | + |
| 17 | + |
| 18 | +def _image_build( |
| 19 | + status: str, |
| 20 | + *, |
| 21 | + error_code: str = "", |
| 22 | + error_message: str = "", |
| 23 | +) -> SandboxImageBuild: |
| 24 | + return SandboxImageBuild( |
| 25 | + id="build-123", |
| 26 | + imageName="custom", |
| 27 | + status=status, |
| 28 | + errorCode=error_code, |
| 29 | + errorMessage=error_message, |
| 30 | + ) |
| 31 | + |
| 32 | + |
| 33 | +def test_image_build_model_rejects_invalid_status_casing(): |
| 34 | + with pytest.raises(ValidationError): |
| 35 | + _image_build(" Completed ") |
15 | 36 |
|
16 | 37 |
|
17 | 38 | def test_build_docker_image_from_dockerfile_targets_linux_amd64(monkeypatch, tmp_path): |
@@ -61,6 +82,71 @@ def fake_run(args, check, stdout, stderr, text): |
61 | 82 | image_build.package_docker_image("local/app:latest") |
62 | 83 |
|
63 | 84 |
|
| 85 | +def test_package_docker_container_reaps_export_process_on_read_failure( |
| 86 | + monkeypatch, |
| 87 | + tmp_path, |
| 88 | +): |
| 89 | + removed = [] |
| 90 | + |
| 91 | + class BrokenStdout: |
| 92 | + def __init__(self): |
| 93 | + self.closed = False |
| 94 | + |
| 95 | + def read(self, size): |
| 96 | + raise RuntimeError("read failed") |
| 97 | + |
| 98 | + def close(self): |
| 99 | + self.closed = True |
| 100 | + |
| 101 | + class FakeProcess: |
| 102 | + def __init__(self): |
| 103 | + self.stdout = BrokenStdout() |
| 104 | + self.terminated = False |
| 105 | + self.killed = False |
| 106 | + self.waits = 0 |
| 107 | + self.return_code = None |
| 108 | + |
| 109 | + def poll(self): |
| 110 | + return self.return_code |
| 111 | + |
| 112 | + def terminate(self): |
| 113 | + self.terminated = True |
| 114 | + self.return_code = -15 |
| 115 | + |
| 116 | + def kill(self): |
| 117 | + self.killed = True |
| 118 | + self.return_code = -9 |
| 119 | + |
| 120 | + def wait(self, timeout=None): |
| 121 | + self.waits += 1 |
| 122 | + if self.return_code is None: |
| 123 | + self.return_code = 0 |
| 124 | + return self.return_code |
| 125 | + |
| 126 | + fake_process = FakeProcess() |
| 127 | + |
| 128 | + def fake_popen(args, stdout, stderr): |
| 129 | + return fake_process |
| 130 | + |
| 131 | + monkeypatch.setattr(image_build.subprocess, "Popen", fake_popen) |
| 132 | + monkeypatch.setattr(image_build, "_remove_docker_container", removed.append) |
| 133 | + |
| 134 | + with pytest.raises(RuntimeError, match="read failed"): |
| 135 | + image_build._package_docker_container( |
| 136 | + "local/app:latest", |
| 137 | + "container-123", |
| 138 | + {}, |
| 139 | + platform="linux/amd64", |
| 140 | + temp_dir=str(tmp_path), |
| 141 | + ) |
| 142 | + |
| 143 | + assert fake_process.stdout.closed is True |
| 144 | + assert fake_process.terminated is True |
| 145 | + assert fake_process.killed is False |
| 146 | + assert fake_process.waits == 1 |
| 147 | + assert removed == ["container-123"] |
| 148 | + |
| 149 | + |
64 | 150 | def test_upload_image_build_artifact_streams_file_with_content_length( |
65 | 151 | monkeypatch, |
66 | 152 | tmp_path, |
@@ -140,6 +226,48 @@ def fake_build(**kwargs): |
140 | 226 | assert removed == ["temp:tag"] |
141 | 227 |
|
142 | 228 |
|
| 229 | +def test_sync_wait_for_image_build_returns_completed_status(monkeypatch): |
| 230 | + manager = SandboxManager( |
| 231 | + SimpleNamespace( |
| 232 | + timeout=30, |
| 233 | + config=SimpleNamespace(runtime_proxy_override=None), |
| 234 | + ) |
| 235 | + ) |
| 236 | + monkeypatch.setattr( |
| 237 | + manager, |
| 238 | + "get_image_build", |
| 239 | + lambda build_id: _image_build("completed"), |
| 240 | + ) |
| 241 | + |
| 242 | + build = manager.wait_for_image_build("build-123", poll_interval=0, timeout=1) |
| 243 | + |
| 244 | + assert build.status == "completed" |
| 245 | + |
| 246 | + |
| 247 | +def test_sync_wait_for_image_build_raises_detailed_failed_status(monkeypatch): |
| 248 | + manager = SandboxManager( |
| 249 | + SimpleNamespace( |
| 250 | + timeout=30, |
| 251 | + config=SimpleNamespace(runtime_proxy_override=None), |
| 252 | + ) |
| 253 | + ) |
| 254 | + monkeypatch.setattr( |
| 255 | + manager, |
| 256 | + "get_image_build", |
| 257 | + lambda build_id: _image_build( |
| 258 | + "failed", |
| 259 | + error_code="E_BAD", |
| 260 | + error_message="backend failure", |
| 261 | + ), |
| 262 | + ) |
| 263 | + |
| 264 | + with pytest.raises( |
| 265 | + RuntimeError, |
| 266 | + match=r"image build failed \[E_BAD\]: backend failure", |
| 267 | + ): |
| 268 | + manager.wait_for_image_build("build-123", poll_interval=0, timeout=1) |
| 269 | + |
| 270 | + |
143 | 271 | @pytest.mark.anyio |
144 | 272 | async def test_async_dockerfile_image_build_cleans_temp_tag_on_build_failure( |
145 | 273 | monkeypatch, |
@@ -175,3 +303,55 @@ def fake_build(**kwargs): |
175 | 303 | ) |
176 | 304 |
|
177 | 305 | assert removed == ["temp:tag"] |
| 306 | + |
| 307 | + |
| 308 | +@pytest.mark.anyio |
| 309 | +async def test_async_wait_for_image_build_returns_completed_status(monkeypatch): |
| 310 | + manager = AsyncSandboxManager( |
| 311 | + SimpleNamespace( |
| 312 | + timeout=30, |
| 313 | + config=SimpleNamespace(runtime_proxy_override=None), |
| 314 | + ) |
| 315 | + ) |
| 316 | + |
| 317 | + async def fake_get_image_build(build_id): |
| 318 | + return _image_build("completed") |
| 319 | + |
| 320 | + monkeypatch.setattr(manager, "get_image_build", fake_get_image_build) |
| 321 | + |
| 322 | + build = await manager.wait_for_image_build( |
| 323 | + "build-123", |
| 324 | + poll_interval=0, |
| 325 | + timeout=1, |
| 326 | + ) |
| 327 | + |
| 328 | + assert build.status == "completed" |
| 329 | + |
| 330 | + |
| 331 | +@pytest.mark.anyio |
| 332 | +async def test_async_wait_for_image_build_raises_detailed_failed_status(monkeypatch): |
| 333 | + manager = AsyncSandboxManager( |
| 334 | + SimpleNamespace( |
| 335 | + timeout=30, |
| 336 | + config=SimpleNamespace(runtime_proxy_override=None), |
| 337 | + ) |
| 338 | + ) |
| 339 | + |
| 340 | + async def fake_get_image_build(build_id): |
| 341 | + return _image_build( |
| 342 | + "failed", |
| 343 | + error_code="E_BAD", |
| 344 | + error_message="backend failure", |
| 345 | + ) |
| 346 | + |
| 347 | + monkeypatch.setattr(manager, "get_image_build", fake_get_image_build) |
| 348 | + |
| 349 | + with pytest.raises( |
| 350 | + RuntimeError, |
| 351 | + match=r"image build failed \[E_BAD\]: backend failure", |
| 352 | + ): |
| 353 | + await manager.wait_for_image_build( |
| 354 | + "build-123", |
| 355 | + poll_interval=0, |
| 356 | + timeout=1, |
| 357 | + ) |
0 commit comments