# SPDX-License-Identifier: AGPL-3.0-or-later
"""Shared prune-intermediates core — used by the CLI and the WebUI button."""
from __future__ import annotations
from pathlib import Path
import pytest
from autosplat import cleanup
from autosplat.cleanup import NotExported
def _make_done_capture(tmp_path: Path) -> Path:
capture_dir = tmp_path / "2026-05-27_cap"
(capture_dir / "output").mkdir(parents=True)
(capture_dir / "output" / "scene.ply").write_bytes(b"PLY" * 1000)
(capture_dir / "frames").mkdir()
(capture_dir / "frames" / "frame_00001.jpg").write_bytes(b"\xff" * 2048)
(capture_dir / "colmap" / "sparse" / "0").mkdir(parents=True)
(capture_dir / "colmap" / "database.db").write_bytes(b"\x00" * 4096)
(capture_dir / "training").mkdir()
(capture_dir / "training" / "step_1000.ply").write_bytes(b"PLY" * 500)
return capture_dir
def test_prune_intermediates_removes_dirs_and_reports_bytes(tmp_path: Path) -> None:
capture_dir = _make_done_capture(tmp_path)
result = cleanup.prune_intermediates(capture_dir)
assert not (capture_dir / "frames").exists()
assert not (capture_dir / "colmap").exists()
assert not (capture_dir / "training").exists()
assert (capture_dir / "output" / "scene.ply").exists()
assert result.bytes_reclaimed == 2048 + 4096 + 1500
assert {p.name for p in result.removed} == {"frames", "colmap", "training"}
def test_prune_intermediates_dry_run_touches_nothing(tmp_path: Path) -> None:
capture_dir = _make_done_capture(tmp_path)
result = cleanup.prune_intermediates(capture_dir, dry_run=True)
assert (capture_dir / "frames").is_dir()
assert (capture_dir / "colmap").is_dir()
assert (capture_dir / "training").is_dir()
assert result.bytes_reclaimed > 0
def test_prune_intermediates_raises_when_export_not_done(tmp_path: Path) -> None:
capture_dir = tmp_path / "2026-05-27_unfinished"
(capture_dir / "frames").mkdir(parents=True)
with pytest.raises(NotExported):
cleanup.prune_intermediates(capture_dir)
assert (capture_dir / "frames").exists()
def test_prune_intermediates_nothing_to_remove(tmp_path: Path) -> None:
capture_dir = tmp_path / "2026-05-27_already_clean"
(capture_dir / "output").mkdir(parents=True)
(capture_dir / "output" / "scene.ply").write_bytes(b"PLY")
result = cleanup.prune_intermediates(capture_dir)
assert result.removed == []
assert result.bytes_reclaimed == 0
def test_prune_removes_rescue_probes_and_brush_dataset(tmp_path: Path) -> None:
# Regression: INTERMEDIATE_SUBDIRS listed only frames/colmap/training, so
# 11,2 GB of bisection-rescue leftovers were never reclaimed. C1: the
# rescue *probes* are where that mass sits — `rescue/clips/*.mp4` are the
# bisected capture's recorded origin and are kept (see
# test_prune_keeps_rescue_clips).
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
for sub in ("frames", "colmap", "brush_dataset"):
(capture / sub).mkdir()
(capture / sub / "blob").write_bytes(b"x" * 10)
(capture / "rescue" / "probes").mkdir(parents=True)
(capture / "rescue" / "probes" / "blob").write_bytes(b"x" * 10)
result = cleanup.prune_intermediates(capture)
assert {p.name for p in result.removed} == {"frames", "colmap", "probes", "brush_dataset"}
assert result.bytes_reclaimed == 40
assert not (capture / "rescue" / "probes").exists()
assert not (capture / "brush_dataset").exists()
assert (capture / "output" / "scene.ply").exists()
def test_prune_keeps_rescue_clips(tmp_path: Path) -> None:
"""C1: `rescue/clips/*.mp4` are the leaf-clip sources a bisected capture
records in pipeline.log as its origin — `add-video`/`resume` re-extract
from them. `cleanup-rescue` keeps them by default and CAPTURE-GUIDE says
dropping them breaks both; the automatic paths must not override that."""
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "rescue" / "clips").mkdir(parents=True)
(capture / "rescue" / "clips" / "0.mp4").write_bytes(b"m" * 10)
(capture / "rescue" / "probes" / "0").mkdir(parents=True)
(capture / "rescue" / "probes" / "0" / "blob").write_bytes(b"x" * 10)
cleanup.prune_intermediates(capture)
assert (capture / "rescue" / "clips" / "0.mp4").exists()
assert not (capture / "rescue" / "probes").exists()
def test_prune_removes_share_tmp_but_keeps_bundles(tmp_path: Path) -> None:
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "share" / "_tmp").mkdir(parents=True)
(capture / "share" / "tok").mkdir()
cleanup.prune_intermediates(capture)
assert not (capture / "share" / "_tmp").exists()
assert (capture / "share" / "tok").exists()
def test_prune_is_idempotent(tmp_path: Path) -> None:
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "colmap").mkdir()
cleanup.prune_intermediates(capture)
second = cleanup.prune_intermediates(capture)
assert second.removed == []
assert second.bytes_reclaimed == 0
def test_sweep_expired_reclaims_through_a_symlinked_capture(tmp_path: Path) -> None:
"""P1: `list_capture_dirs` no longer filters symlinks, so a symlinked
capture (offloaded to an external disk) must be swept just like a
regular one. Containment holds because `prune_intermediates` resolves
both `capture_dir` (the symlink) and its targets before comparing."""
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
real_capture = tmp_path / "external_disk" / "2026-06-01_offloaded"
(real_capture / "output").mkdir(parents=True)
(real_capture / "output" / "scene.ply").write_bytes(b"ply\n")
(real_capture / "colmap").mkdir()
(real_capture / "colmap" / "blob").write_bytes(b"x" * 5)
old = (now - timedelta(days=40)).timestamp()
for p in [real_capture, *real_capture.rglob("*")]:
os.utime(p, (old, old))
captures_dir = tmp_path / "captures"
captures_dir.mkdir()
link = captures_dir / "2026-06-01_offloaded"
link.symlink_to(real_capture, target_is_directory=True)
results = cleanup.sweep_expired(captures_dir, grace_days=30, now=now)
assert len(results) == 1
assert not (real_capture / "colmap").exists()
assert (real_capture / "output" / "scene.ply").exists()
def test_sweep_expired_reclaims_only_past_grace(tmp_path: Path) -> None:
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
for name, age in (("2026-07-01_fresh", 2), ("2026-06-01_old", 40)):
capture = tmp_path / name
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "colmap").mkdir()
(capture / "colmap" / "blob").write_bytes(b"x" * 5)
mtime = (now - timedelta(days=age)).timestamp()
# Age the intermediates too, matching the PLY: a real capture this
# old has no recently-touched files either, unlike a rebuild in
# flight (see test_sweep_expired_skips_recently_active_capture).
os.utime(capture / "output" / "scene.ply", (mtime, mtime))
os.utime(capture / "colmap" / "blob", (mtime, mtime))
results = cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert len(results) == 1
assert (tmp_path / "2026-07-01_fresh" / "colmap").exists()
assert not (tmp_path / "2026-06-01_old" / "colmap").exists()
def test_prune_partial_failures_are_reported(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Finding 2: Partial failures have no test. Verify that when shutil.rmtree
# fails on one target, it's reported in .failed, others are still removed,
# and bytes_reclaimed counts only successful targets.
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "frames").mkdir()
(capture / "frames" / "blob").write_bytes(b"x" * 20)
(capture / "colmap").mkdir()
(capture / "colmap" / "blob").write_bytes(b"x" * 30)
(capture / "training").mkdir()
(capture / "training" / "blob").write_bytes(b"x" * 40)
import shutil
original_rmtree = shutil.rmtree
def rmtree_with_failure(path: str | Path) -> None:
path_obj = Path(path)
# Fail only on colmap
if path_obj.name == "colmap":
raise OSError("Permission denied")
original_rmtree(path_obj)
monkeypatch.setattr(shutil, "rmtree", rmtree_with_failure)
result = cleanup.prune_intermediates(capture)
# colmap should be in failed, not in removed
assert capture / "colmap" in result.failed
assert capture / "colmap" not in result.removed
# frames and training should be removed
assert capture / "frames" not in result.failed
assert capture / "frames" in result.removed
assert capture / "training" not in result.failed
assert capture / "training" in result.removed
# colmap should still exist (removal failed)
assert (capture / "colmap").exists()
# frames and training should be gone
assert not (capture / "frames").exists()
assert not (capture / "training").exists()
# bytes_reclaimed should only count frames (20) + training (40), NOT colmap (30)
assert result.bytes_reclaimed == 60
def test_sweep_expired_skips_recently_active_capture(tmp_path: Path) -> None:
# Finding 1 (task 7 review): reclaim eligibility only looks at PLY mtime,
# which stays old for the whole duration of an add-video/resume rebuild.
# A capture otherwise fully past grace, but with a colmap/ file touched
# seconds ago (an in-flight rebuild), must survive the sweep.
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
capture = tmp_path / "2026-06-01_rebuilding"
(capture / "output").mkdir(parents=True)
ply = capture / "output" / "scene.ply"
ply.write_bytes(b"ply\n")
old = (now - timedelta(days=40)).timestamp()
os.utime(ply, (old, old))
(capture / "colmap").mkdir()
blob = capture / "colmap" / "blob"
blob.write_bytes(b"x" * 5)
recent = (now - timedelta(seconds=5)).timestamp()
os.utime(blob, (recent, recent))
results = cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert results == []
assert (capture / "colmap").exists()
def test_sweep_expired_reclaims_when_everything_aged(tmp_path: Path) -> None:
# Same shape of capture as above, but with the colmap/ file also aged —
# no rebuild in flight, so it must be reclaimed.
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
capture = tmp_path / "2026-06-01_stale"
(capture / "output").mkdir(parents=True)
ply = capture / "output" / "scene.ply"
ply.write_bytes(b"ply\n")
old = (now - timedelta(days=40)).timestamp()
os.utime(ply, (old, old))
(capture / "colmap").mkdir()
blob = capture / "colmap" / "blob"
blob.write_bytes(b"x" * 5)
os.utime(blob, (old, old))
results = cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert len(results) == 1
assert not (capture / "colmap").exists()
def test_sweep_expired_handles_enumeration_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Finding 1: sweep_expired must never raise, even if find_capture_dirs fails.
# Verify it returns [] and logs the error instead of propagating OSError.
from datetime import UTC, datetime
from autosplat import lifecycle
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
def find_capture_dirs_failing(_: Path) -> list[Path]:
raise OSError("Permission denied during directory enumeration")
monkeypatch.setattr(lifecycle, "find_capture_dirs", find_capture_dirs_failing)
# Should not raise, should return empty list
result = cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert result == []
def test_prune_intermediates_rejects_target_outside_capture_dir(tmp_path: Path) -> None:
"""Round-2 review finding: `targets=` was accepted verbatim with no
check it actually sits under `capture_dir` — an unvalidated deletion
parameter on a function that calls `shutil.rmtree` on its contents. A
caller mistake (or a future refactor) handing in an unrelated path
would rmtree it without complaint."""
capture_dir = _make_done_capture(tmp_path)
outside = tmp_path / "not-part-of-this-capture"
outside.mkdir()
(outside / "precious.txt").write_bytes(b"do not delete me")
with pytest.raises(ValueError, match="not inside capture_dir"):
cleanup.prune_intermediates(capture_dir, targets=[outside])
assert outside.exists()
assert (outside / "precious.txt").exists()
def test_prune_intermediates_accepts_target_nested_under_capture_dir(tmp_path: Path) -> None:
"""The containment check must not reject legitimate nested targets —
only `capture_dir` itself and anything outside it."""
capture_dir = _make_done_capture(tmp_path)
nested = capture_dir / "colmap" / "sparse" / "0"
result = cleanup.prune_intermediates(capture_dir, targets=[nested])
assert result.removed == [nested]
assert not nested.exists()
def test_prune_never_deletes_a_target_burying_a_nested_export(tmp_path: Path) -> None:
"""C3: the spec's hard safety rule ("a directory containing the only PLY
of a capture is never touched") was enforced only in `migrate-storage`,
which spent three fix rounds getting it right. `prune_intermediates` — used
by `sweep_expired`, the review-triggered prune and `cleanup-intermediates`
— applied no such filter, so a shape `migrate-storage` protects was
destroyed by "Confirm review" or by waiting 30 days."""
capture = tmp_path / "2026-07-01_x"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
nested_ply = capture / "rescue" / "probes" / "probe_0" / "output" / "scene.ply"
nested_ply.parent.mkdir(parents=True)
nested_ply.write_bytes(b"ply\n")
(capture / "colmap").mkdir()
(capture / "colmap" / "blob").write_bytes(b"x" * 100)
result = cleanup.prune_intermediates(capture)
assert nested_ply.exists()
assert (capture / "rescue" / "probes").exists()
# Targeted filter, not a blanket refusal: colmap/ holds no result of its
# own and is reclaimed normally alongside it.
assert not (capture / "colmap").exists()
assert [p.name for p in result.removed] == ["colmap"]
def test_sweep_expired_never_deletes_a_target_burying_a_nested_export(tmp_path: Path) -> None:
"""Same rule, via the automatic grace sweep — the path that reaches a
capture with no human in the loop at all."""
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
capture = tmp_path / "2026-06-01_old"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
nested_ply = capture / "rescue" / "probes" / "probe_0" / "output" / "scene.ply"
nested_ply.parent.mkdir(parents=True)
nested_ply.write_bytes(b"ply\n")
(capture / "colmap").mkdir()
(capture / "colmap" / "blob").write_bytes(b"x" * 5)
old = (now - timedelta(days=40)).timestamp()
for p in [capture, *capture.rglob("*")]:
os.utime(p, (old, old))
cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert nested_ply.exists()
assert not (capture / "colmap").exists()
def test_sweep_expired_only_touches_captures_the_ui_lists(tmp_path: Path) -> None:
"""I1: the startup sweep enumerated via `find_capture_dirs` — no name
filter, and recursive — while the UI lists only top-level dirs matching
`YYYY-MM-DD_*`. So `2026-05-25_max_strasse_rescue/2026-05-25_A_vor1`
(6,4 GB on the real tree) was swept though the UI never showed it, and
never warned "cleanup in N days" for it. Worse: every existing capture is
already past 30 days, so the first `autosplat webui` launch after merge
would have reclaimed ~9,6 GB in a daemon thread with only log lines,
pre-empting `migrate-storage` — the explicit, previewable command built
for exactly that backlog.
The sweep is now bounded to what the UI lists. Anything else is left to
`migrate-storage`, which still finds it.
"""
import os
from datetime import UTC, datetime, timedelta
from autosplat import lifecycle
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
nested = tmp_path / "2026-05-25_max_strasse_rescue" / "2026-05-25_A_vor1"
(nested / "output").mkdir(parents=True)
(nested / "output" / "scene.ply").write_bytes(b"ply\n")
(nested / "colmap").mkdir()
(nested / "colmap" / "blob").write_bytes(b"x" * 100)
unconventional = tmp_path / "scratch_no_date_prefix"
(unconventional / "output").mkdir(parents=True)
(unconventional / "output" / "scene.ply").write_bytes(b"ply\n")
(unconventional / "colmap").mkdir()
(unconventional / "colmap" / "blob").write_bytes(b"x" * 100)
old = (now - timedelta(days=40)).timestamp()
for p in [tmp_path, *tmp_path.rglob("*")]:
os.utime(p, (old, old))
results = cleanup.sweep_expired(tmp_path, grace_days=30, now=now)
assert results == []
assert (nested / "colmap").exists()
assert (unconventional / "colmap").exists()
# ...but migrate-storage's recursive enumeration still sees them.
assert nested in lifecycle.find_capture_dirs(tmp_path)
assert unconventional in lifecycle.find_capture_dirs(tmp_path)
def test_sweep_expired_still_reclaims_a_top_level_listed_capture(tmp_path: Path) -> None:
"""The bound must not shrink the sweep to nothing: a conforming top-level
capture — exactly what the UI lists and warns about — is still swept."""
import os
from datetime import UTC, datetime, timedelta
now = datetime(2026, 7, 28, 12, 0, tzinfo=UTC)
capture = tmp_path / "2026-06-01_old"
(capture / "output").mkdir(parents=True)
(capture / "output" / "scene.ply").write_bytes(b"ply\n")
(capture / "colmap").mkdir()
(capture / "colmap" / "blob").write_bytes(b"x" * 5)
old = (now - timedelta(days=40)).timestamp()
for p in [capture, *capture.rglob("*")]:
os.utime(p, (old, old))
assert len(cleanup.sweep_expired(tmp_path, grace_days=30, now=now)) == 1
assert not (capture / "colmap").exists()
# --- I4: the suite-wide safety backstop ------------------------------------
#
# These live in `tests/test_cleanup.py` on purpose: it is one of the files the
# old `tests/webui/conftest.py`-scoped guard did NOT cover, and it calls
# `sweep_expired` directly. A near-miss already occurred once (a dry-run
# preview launched against the real $HOME).
def test_guard_blocks_sweep_outside_the_pytest_temp_root(
tmp_path_factory: pytest.TempPathFactory,
storage_guard_violations: list[str],
) -> None:
# Synthesized nonexistent sibling of pytest's own temp root, never a real
# path: this test used to point at `Path.home() / "AutoSplat" / "captures"`
# — the maintainer's real 19 GB — and was safe only for as long as the
# guard fires. A test that guards deletion must not itself name live data.
from datetime import UTC, datetime
base_temp = tmp_path_factory.getbasetemp().resolve()
outside = base_temp.parent / "not-a-real-pytest-tmp-dir-captures"
assert not outside.exists()
with pytest.raises(RuntimeError, match="outside the pytest temp root"):
cleanup.sweep_expired(outside, grace_days=30, now=datetime.now(UTC))
assert storage_guard_violations
def test_guard_blocks_prune_intermediates_outside_the_pytest_temp_root(
tmp_path_factory: pytest.TempPathFactory,
storage_guard_violations: list[str],
) -> None:
# A synthesized nonexistent sibling of pytest's own temp root, never a
# real path — a prior version of this test used a real path on the
# maintainer's machine (`Path.home() / "AutoSplat" / ...`), safe only
# because the guard fires before anything touches it. Landmine removed.
base_temp = tmp_path_factory.getbasetemp().resolve()
outside = base_temp.parent / "not-a-real-pytest-tmp-dir-2026-05-26_max_strasse"
with pytest.raises(RuntimeError, match="outside the pytest temp root"):
cleanup.prune_intermediates(outside)
assert storage_guard_violations
def test_guard_blocks_migration_apply_migration_rmtree_outside_the_pytest_temp_root(
tmp_path_factory: pytest.TempPathFactory,
storage_guard_violations: list[str],
) -> None:
"""`migration.apply_migration`'s `outputs_reclaim` branch rmtrees
config-supplied `outputs/` entries directly, bypassing `cleanup` entirely
— both `migrate-storage --apply` (`tests/test_cli_migrate_storage.py`)
and the WebUI `/storage/reclaim` button (task 6) drive exactly this
function.
Round-2 review finding: the previous version of this test called
`cli.shutil.rmtree(outside)` directly — the *wrapper* the guard installs
— instead of the production call site inside `apply_migration`. It
passed regardless of whether that call site was actually guarded,
because `migration.py` does its own `import shutil` and so holds an
independent module-level binding from `cli.py`'s; only `cli_mod.shutil`
was ever patched. The reviewer proved the hole directly: a `MigrationPlan`
built exactly like this one, run through the *pre-fix* `apply_migration`,
deleted a directory outside the pytest temp root. This test drives that
same call site instead of the wrapper, so it cannot pass vacuously again.
The path is a synthesized nonexistent sibling of pytest's temp root,
never a real one — see the sibling self-tests above for why.
"""
from autosplat import migration
base_temp = tmp_path_factory.getbasetemp().resolve()
outside = base_temp.parent / "not-a-real-pytest-tmp-dir-outputs-entry"
assert not outside.exists()
plan = migration.MigrationPlan(
rows=(
migration.PlanRow(
display=f"outputs/{outside.name}",
kind="outputs_reclaim",
label="duplicate — reclaimed",
size=0,
outputs_entry=outside,
),
),
reclaimable_bytes=0,
skipped=0,
duplicate_names=(),
)
with pytest.raises(RuntimeError, match="outside the pytest temp root"):
migration.apply_migration(plan)
assert storage_guard_violations
assert not outside.exists()
def test_guard_covers_prune_intermediates_called_through_the_module_attribute(
tmp_path_factory: pytest.TempPathFactory,
storage_guard_violations: list[str],
) -> None:
"""P2 (final review of feat/capture-lifecycle): this file used to bind
`prune_intermediates` at import time via
`from autosplat.cleanup import ... prune_intermediates` and call that
name directly in ~15 places. The guard above patches the *module
attribute* `cleanup.prune_intermediates`, so the directly-imported name
still pointed at the pre-patch function object and every one of those
calls silently bypassed the guard — this test's own self-test sibling
(`test_guard_blocks_prune_intermediates_outside_the_pytest_temp_root`)
already called through `cleanup.prune_intermediates` and so passed,
masking the hole. Every call in this file now goes through the module
attribute, closing it.
The offending path is a nonexistent sibling of pytest's own temp root —
never `Path.home()` or `/` — so even if the guard failed to fire, this
test alone could not touch a real path.
"""
base_temp = tmp_path_factory.getbasetemp().resolve()
outside = base_temp.parent / "not-a-real-pytest-tmp-dir-this-should-never-exist"
assert not outside.exists()
with pytest.raises(RuntimeError, match="outside the pytest temp root"):
cleanup.prune_intermediates(outside)
assert storage_guard_violations
def test_guard_blocks_capture_delete_route_rmtree_outside_the_pytest_temp_root(
tmp_path_factory: pytest.TempPathFactory,
storage_guard_violations: list[str],
) -> None:
"""`POST /captures/{id}/delete` (`webui/routes/captures.py`) calls
`shutil.rmtree(capture.path, ignore_errors=True)` through its own
module-local `import shutil` — a binding independent of both
`cli_mod.shutil` and `migration_mod.shutil`, the two the guard patched
before this test existed. Neither of those covers this one; only
binding the guarded shutil into `webui.routes.captures` itself does.
This drives the actual HTTP route (`TestClient` + `create_app`), not
the module import or the wrapper directly, for the same reason the
sibling migration test above does: a self-test that asserts on the
wrapper passes whether or not the production call site is patched.
`get_capture` requires `capture_path.is_dir()`, unlike the migration
plan above (a bare `PlanRow` with a nonexistent `outputs_entry`), so
this test must create a real directory outside pytest's temp root —
never under `Path.home()`, and removed with the *real*, unguarded
`shutil.rmtree` in `finally` regardless of outcome. `ignore_errors=True`
on the production call never gets a chance to matter: the guard's
`_check` raises before the real `rmtree` call underneath it runs.
"""
import shutil as real_shutil
from fastapi import FastAPI
from starlette.testclient import TestClient
from autosplat.config import load_config
from autosplat.webui import create_app
base_temp = tmp_path_factory.getbasetemp().resolve()
outside_root = base_temp.parent / "not-a-real-pytest-tmp-dir-captures-root"
capture_id = "2026-05-01_outside"
capture_dir = outside_root / capture_id
(capture_dir / "output").mkdir(parents=True)
(capture_dir / "output" / "scene.ply").write_bytes(b"ply\n")
try:
cfg = load_config(include_xdg=False)
cfg.paths.captures_dir = outside_root
app: FastAPI = create_app(cfg)
# raise_server_exceptions=False: a genuinely unguarded call would not
# raise at all here (ignore_errors=True swallows it deep inside the
# real rmtree) — the deletion just succeeds silently and the route
# 303s. capture_dir.exists() is what actually distinguishes the two
# worlds, so the response itself is not asserted on.
with TestClient(app, raise_server_exceptions=False) as client:
client.post(f"/captures/{capture_id}/delete")
assert capture_dir.exists(), "the guard must stop the delete before rmtree runs"
assert any("shutil.rmtree" in v for v in storage_guard_violations)
finally:
real_shutil.rmtree(outside_root, ignore_errors=True)