|
4 | 4 | """ |
5 | 5 |
|
6 | 6 | import base64 |
| 7 | +import os |
7 | 8 |
|
8 | 9 | import httpx |
9 | 10 | import pytest |
@@ -415,6 +416,117 @@ async def test_recording_start_stop_and_mapping(): |
415 | 416 | await box.aclose() |
416 | 417 |
|
417 | 418 |
|
| 419 | +@respx.mock |
| 420 | +async def test_recording_download_mp4(tmp_path): |
| 421 | + box = await make_async_box(respx.mock) |
| 422 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 423 | + return_value=httpx.Response( |
| 424 | + # Content-type parameters must not defeat the MP4 detection. |
| 425 | + 200, |
| 426 | + content=b"mp4-bytes", |
| 427 | + headers={"content-type": "video/mp4; some=param"}, |
| 428 | + ) |
| 429 | + ) |
| 430 | + |
| 431 | + # Parent directories are created as needed. |
| 432 | + dest = await box.browser.recordings.download( |
| 433 | + "recording-1", path=str(tmp_path / "recordings" / "nested" / "demo.mp4") |
| 434 | + ) |
| 435 | + |
| 436 | + assert dest == str(tmp_path / "recordings" / "nested" / "demo.mp4") |
| 437 | + with open(dest, "rb") as fh: |
| 438 | + assert fh.read() == b"mp4-bytes" |
| 439 | + await box.aclose() |
| 440 | + |
| 441 | + |
| 442 | +@respx.mock |
| 443 | +async def test_recording_download_default_extension_follows_content_type(tmp_path, monkeypatch): |
| 444 | + monkeypatch.chdir(tmp_path) |
| 445 | + box = await make_async_box(respx.mock) |
| 446 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 447 | + return_value=httpx.Response( |
| 448 | + 200, content=b"ts-bytes", headers={"content-type": "video/mp2t"} |
| 449 | + ) |
| 450 | + ) |
| 451 | + |
| 452 | + # Legacy recordings without an MP4 remux stream raw MPEG-TS. |
| 453 | + dest = await box.browser.recordings.download("recording-1") |
| 454 | + |
| 455 | + assert dest == "./box-recording-recording-1.ts" |
| 456 | + with open(dest, "rb") as fh: |
| 457 | + assert fh.read() == b"ts-bytes" |
| 458 | + await box.aclose() |
| 459 | + |
| 460 | + |
| 461 | +@respx.mock |
| 462 | +async def test_recording_download_rejects_unexpected_content_type(tmp_path): |
| 463 | + box = await make_async_box(respx.mock) |
| 464 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 465 | + return_value=httpx.Response( |
| 466 | + 200, content=b"<html>nope</html>", headers={"content-type": "text/html"} |
| 467 | + ) |
| 468 | + ) |
| 469 | + |
| 470 | + dest = str(tmp_path / "demo.mp4") |
| 471 | + with pytest.raises(BoxError, match="Unexpected recording content type: text/html"): |
| 472 | + await box.browser.recordings.download("recording-1", path=dest) |
| 473 | + assert not os.path.exists(dest) |
| 474 | + await box.aclose() |
| 475 | + |
| 476 | + |
| 477 | +@respx.mock |
| 478 | +async def test_recording_download_surfaces_backend_error(tmp_path): |
| 479 | + box = await make_async_box(respx.mock) |
| 480 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 481 | + return_value=httpx.Response(409, json={"error": "recording is not ready for download"}) |
| 482 | + ) |
| 483 | + |
| 484 | + with pytest.raises(BoxError, match="recording is not ready for download"): |
| 485 | + await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4")) |
| 486 | + await box.aclose() |
| 487 | + |
| 488 | + |
| 489 | +@respx.mock |
| 490 | +async def test_recording_download_preserves_existing_file_on_failure(tmp_path): |
| 491 | + box = await make_async_box(respx.mock) |
| 492 | + dest = tmp_path / "demo.mp4" |
| 493 | + dest.write_bytes(b"existing-recording") |
| 494 | + |
| 495 | + # Headers arrive, then the body aborts mid-stream after a partial chunk. |
| 496 | + async def _partial_then_error(): |
| 497 | + yield b"partial" |
| 498 | + raise httpx.ReadError("connection reset") |
| 499 | + |
| 500 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 501 | + return_value=httpx.Response( |
| 502 | + 200, |
| 503 | + headers={"content-type": "video/mp4"}, |
| 504 | + content=_partial_then_error(), |
| 505 | + ) |
| 506 | + ) |
| 507 | + |
| 508 | + # Interrupted streams surface as BoxError, like _request(). |
| 509 | + with pytest.raises(BoxError, match="connection reset"): |
| 510 | + await box.browser.recordings.download("recording-1", path=str(dest)) |
| 511 | + |
| 512 | + # The existing file at dest must survive intact, and no temp file is left behind. |
| 513 | + assert dest.read_bytes() == b"existing-recording" |
| 514 | + assert [p.name for p in tmp_path.iterdir()] == ["demo.mp4"] |
| 515 | + await box.aclose() |
| 516 | + |
| 517 | + |
| 518 | +@respx.mock |
| 519 | +async def test_recording_download_wraps_transport_timeout(tmp_path): |
| 520 | + box = await make_async_box(respx.mock) |
| 521 | + respx.get(f"{BASE}/browser/recordings/recording-1/download").mock( |
| 522 | + side_effect=httpx.ConnectTimeout("timed out") |
| 523 | + ) |
| 524 | + |
| 525 | + with pytest.raises(BoxError, match="Request timeout"): |
| 526 | + await box.browser.recordings.download("recording-1", path=str(tmp_path / "demo.mp4")) |
| 527 | + await box.aclose() |
| 528 | + |
| 529 | + |
418 | 530 | @respx.mock |
419 | 531 | async def test_stale_handle_does_not_stop_newer_recording(): |
420 | 532 | box = await make_async_box(respx.mock) |
|
0 commit comments