US04-06: Automate Phase D End-to-End Acceptance

This commit is contained in:
2026-08-16 12:05:32 +02:00
parent 8d96acd067
commit b9b5ab7f51
6 changed files with 530 additions and 87 deletions

View File

@@ -102,3 +102,37 @@ work_item/scripts/python -m pytest tests/e2e -m phase_c -q
The deterministic naming provider is enabled only by test configuration
(`PHOTO_PIPELINE_FAKE_NAMING_LOG`); without it the application falls back to the
offline naming-policy name. Phase A and B suites remain green in the full run above.
### Phase D acceptance gate
Phase D (Epic E04: guarded renaming) is the first phase that changes the library on
disk, so its gate is the strictest. One command runs the rename API journeys, the
filesystem fault injection, and the browser suite:
```bash
work_item/scripts/python -m pytest tests/e2e -m phase_d -q
```
- `tests/e2e/test_phase_d_pipeline.py` drives a real server over HTTP: plan and
export, confirmation with the plan version and checksum (a stale token is refused
without touching disk), a valid apply, the case-only rename procedure, a collision
whose occupant survives, a source that changed after planning, and durability
across a full restart.
- **Fault injection is real.** `PHOTO_PIPELINE_FAULT_AFTER=<journal state>` kills the
server process the instant that state is persisted. The suite crashes it at every
journal transition in turn (`moving`, `moved`, `database_updated`, `verified`),
starts a fresh process against the same database and library, and requires recovery
to converge from journal and disk evidence alone — with the asset set, the stable
IDs, and every content hash unchanged. Ambiguous evidence is never guessed: it stays
classified `manual` and keeps blocking. An unresolved rename is the cancellation
boundary — there is no cancel once a run starts, and unrelated mutations (album
proposal generation and approval) are refused with 409 `rename_recovery_required`
until it is resolved, while reads stay available.
- `tests/e2e/test_renames_ui.py` covers the browser journeys: preview of every
affected path, confirmation carrying the server-issued token, apply with progress
and terminal verification, stale confirmation, collision, interruption, recovery,
rollback, keyboard confirmation, and the view still matching the journal after a
server restart.
The fault barrier is test-only configuration; without `PHOTO_PIPELINE_FAULT_AFTER`
the apply path has no crash points. Phases AC remain green in the full run above.

View File

@@ -14,7 +14,9 @@ import socket
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import httpx
@@ -159,3 +161,88 @@ def wait_until(predicate, *, timeout: float = 20, interval: float = 0.1):
return value
time.sleep(interval)
raise AssertionError("condition not met before timeout")
# ── Phase D: an analysed album, ready to be named and renamed ────────────────
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
class session_factory:
"""Session factory against a seeded database, for the few things a test has to
set up or inspect below the API — journal states, mainly."""
def __init__(self, seeded: Seeded) -> None:
self._seeded = seeded
def __enter__(self):
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib),
}
)
run_migrations(config.database_url)
self._engine = create_db_engine(config.database_url)
return create_session_factory(self._engine)
def __exit__(self, *_):
self._engine.dispose()
return False
def seed_album(tmp_path: Path, album: str = "rome", names: tuple[str, ...] = ("a.jpg", "b.jpg")):
"""A library holding one album folder whose photos are confirmed SFW and analysed
— the state a naming proposal, and therefore a rename plan, is built from."""
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.inventory import InventoryService
seeded = seed_library(tmp_path, {}, {})
folder = seeded.lib / album
folder.mkdir(parents=True)
for index, name in enumerate(names):
image(folder / name, index + 1)
with session_factory(seeded) as sf:
InventoryService(sf).scan(seeded.lib)
with sf() as session:
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
for asset_id, path in rows:
session.add(
SafetyReview(
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
)
)
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
description=f"a view of {path}",
tags='["ruins", "city"]',
approx_year=2019,
location_hint="Rome",
)
)
session.commit()
seeded.asset_ids.update({Path(path).stem: aid for aid, path in rows})
return seeded
def approve_album(base: str, *, album: str = "rome", name: str) -> None:
"""Generate a proposal, set its final name, and approve it over HTTP."""
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=10).raise_for_status()
for payload, route in (
({"name": name}, "edit"),
({}, "approve"),
):
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=10).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/{route}",
json={**payload, "expected_version": current["version"]},
timeout=10,
).raise_for_status()

View File

@@ -0,0 +1,377 @@
"""Phase D end-to-end acceptance (US04-06): guarded renaming, black box.
Every journey here drives a real ``photo_pipeline serve`` child process over HTTP —
plan, export, confirm, apply, collide, go stale, crash, recover, roll back. The
crashes are real: the server is killed by the ``PHOTO_PIPELINE_FAULT_AFTER`` barrier
at each persisted journal transition in turn, then a fresh process is started against
the same database and library and has to reconcile the wreckage from evidence alone.
Photos really move. After every journey the assertions read the filesystem and the
inventory back: the asset set, the stable IDs, and the content hashes must be exactly
what they were before, only at new paths.
"""
from __future__ import annotations
import httpx
import pytest
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
pytestmark = pytest.mark.phase_d
TIMEOUT = 10
APPROVED = "2019 Rome"
CRASH_POINTS = ["moving", "moved", "database_updated", "verified"]
@pytest.fixture
def server(tmp_path):
seeded = seed_album(tmp_path)
running = Server(seeded).start()
running.seeded = seeded
try:
yield running
finally:
running.stop()
# ── helpers ──────────────────────────────────────────────────────────────────
def _plan(base) -> dict:
response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT)
response.raise_for_status()
return response.json()
def _get_plan(base, plan_id) -> dict:
return httpx.get(f"{base}/api/v1/rename-plans/{plan_id}", timeout=TIMEOUT).json()
def _apply(base, plan, **body):
payload = {"expected_version": plan["version"], **body}
return httpx.post(
f"{base}/api/v1/rename-plans/{plan['id']}/apply", json=payload, timeout=TIMEOUT
)
def _inventory(base) -> dict[str, dict]:
"""Every asset by stable ID, so identity can be compared across a rename."""
items = httpx.get(
f"{base}/api/v1/inventory/assets", params={"limit": 200}, timeout=TIMEOUT
).json()["items"]
return {item["id"]: item for item in items}
def _content(root) -> dict[str, bytes]:
return {
str(path.relative_to(root)): path.read_bytes()
for path in sorted(root.rglob("*"))
if path.is_file()
}
def _recovery(base) -> dict:
return httpx.get(f"{base}/api/v1/rename-recovery", timeout=TIMEOUT).json()
def _journal_states(seeded, plan_id) -> list[str]:
from photo_pipeline.services.rename_journal import RenameJournal
with session_factory(seeded) as sf:
return [row["journal_state"] for row in RenameJournal(sf).operations(plan_id)]
# ── US04-01: plan and export ─────────────────────────────────────────────────
def test_plan_and_export_describe_every_move_without_touching_the_library(server):
before = _content(server.seeded.lib)
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
assert plan["state"] == "validated" and plan["operation_count"] == 1
operation = plan["operations"][0]
assert operation["source_path"].endswith("/rome")
assert operation["destination_path"].endswith(f"/{APPROVED}")
assert operation["asset_count"] == 2
assert set(operation["asset_ids"]) == set(_inventory(server.base))
export = httpx.get(
f"{server.base}/api/v1/rename-plans/{plan['id']}/export", timeout=TIMEOUT
).json()
assert export["schema_version"] == 1
assert export["checksum"] == plan["checksum"]
assert [op["source_path"] for op in export["operations"]] == [operation["source_path"]]
# Portable evidence must not smuggle out anything sensitive.
assert "token" not in str(export).lower() and "key" not in str(export).lower()
# Planning is a preview: not one byte moved.
assert _content(server.seeded.lib) == before
# ── US04-03: confirmation and apply ──────────────────────────────────────────
def test_apply_requires_the_current_confirmation_token(server):
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
stale = _apply(server.base, {**plan, "version": plan["version"] + 7})
assert stale.status_code == 409 and stale.json()["error"]["code"] == "version_conflict"
wrong_checksum = _apply(server.base, plan, expected_checksum="0" * 64)
assert wrong_checksum.status_code == 409
assert (server.seeded.lib / "rome").is_dir(), "a refused confirmation moves nothing"
def test_a_valid_apply_preserves_ids_hashes_and_the_asset_set(server):
approve_album(server.base, name=APPROVED)
before = _inventory(server.base)
before_content = _content(server.seeded.lib)
plan = _plan(server.base)
applied = _apply(server.base, plan, expected_checksum=plan["checksum"]).json()
assert applied["applied"] == 1 and applied["failed"] == 0 and applied["state"] == "applied"
after = _inventory(server.base)
assert set(after) == set(before), "renaming must not change asset identity"
assert {item["current_sha256"] for item in after.values()} == {
item["current_sha256"] for item in before.values()
}
assert all(APPROVED in item["current_path"] for item in after.values())
# Same bytes, new folder — nothing was rewritten in the move.
assert _content(server.seeded.lib) == {
key.replace("rome/", f"{APPROVED}/"): value for key, value in before_content.items()
}
assert _journal_states(server.seeded, plan["id"]) == ["complete"]
def test_a_case_only_rename_applies_on_a_case_insensitive_filesystem(tmp_path):
seeded = seed_album(tmp_path, album="rome")
running = Server(seeded).start()
try:
approve_album(running.base, name="Rome")
plan = _plan(running.base)
assert plan["operations"][0]["case_only"] is True
assert _apply(running.base, plan).json()["applied"] == 1
entries = {path.name for path in seeded.lib.iterdir()}
assert "Rome" in entries
# The staged intermediate name must not survive the procedure.
assert not any(name.startswith(".rename-") for name in entries)
assert all("/Rome/" in item["current_path"] for item in _inventory(running.base).values())
finally:
running.stop()
def test_a_collision_is_refused_and_the_occupant_survives(server):
approve_album(server.base, name=APPROVED)
occupied = server.seeded.lib / APPROVED
occupied.mkdir()
(occupied / "precious.jpg").write_bytes(b"do not lose me")
plan = _plan(server.base)
assert plan["state"] == "invalid" and "destination_exists" in plan["blockers"]
refused = _apply(server.base, plan)
assert refused.status_code == 422 and refused.json()["error"]["code"] == "cannot_apply"
assert (occupied / "precious.jpg").read_bytes() == b"do not lose me"
assert (server.seeded.lib / "rome").is_dir()
def test_a_source_that_changed_after_planning_is_refused(server):
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
# The plan recorded per-asset hashes; the file changes before confirmation.
(server.seeded.lib / "rome" / "a.jpg").write_bytes(b"tampered")
result = _apply(server.base, plan).json()
assert result["failed"] == 1 and result["applied"] == 0
assert (server.seeded.lib / "rome").is_dir(), "a failed precondition leaves the source alone"
operation = _get_plan(server.base, plan["id"])["operations"][0]
assert operation["journal_state"] == "failed"
assert operation["error_code"] == "source_changed"
# ── US04-04: crash points, recovery, rollback ────────────────────────────────
def _crash_during_apply(seeded, plan, state):
"""Apply with the fault barrier armed: the server dies at ``state``, mid-move."""
crashing = Server(seeded, extra_env={"PHOTO_PIPELINE_FAULT_AFTER": state}).start()
try:
with pytest.raises(httpx.HTTPError):
_apply(crashing.base, plan)
finally:
crashing.stop()
assert crashing.proc is None
@pytest.mark.parametrize("crash_point", CRASH_POINTS)
def test_every_journal_crash_point_recovers_without_losing_content(tmp_path, crash_point):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
before = _inventory(first.base)
before_bytes = sorted(_content(seeded.lib).values())
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, crash_point)
# A brand-new process, no in-memory state: everything comes from the journal.
restarted = Server(seeded).start()
try:
recovery = _recovery(restarted.base)
assert recovery["items"], f"a crash at {crash_point} must leave visible evidence"
assert recovery["items"][0]["journal_state"] == crash_point
# Only a crash that could have left the library half-renamed blocks other
# work. `verified` is past every filesystem and database change — the move
# is done and checked, just not flagged complete — so it blocks nothing.
assert recovery["blocks_mutation"] is (crash_point != "verified")
resolved = httpx.post(
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
).json()
assert resolved["manual"] == 0, "an interrupted rename must be decidable from evidence"
# Recovery is idempotent: running it again changes nothing.
assert _recovery(restarted.base)["blocks_mutation"] is False
httpx.post(f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT)
# A resumable crash is left ready to run again; finish it so every crash
# point converges on the same observable end state.
current = _get_plan(restarted.base, plan["id"])
if current["state"] != "applied":
_apply(restarted.base, current)
after = _inventory(restarted.base)
assert set(after) == set(before), "no asset may be lost or invented by a crash"
assert {item["current_sha256"] for item in after.values()} == {
item["current_sha256"] for item in before.values()
}
assert sorted(_content(seeded.lib).values()) == before_bytes
assert (seeded.lib / APPROVED).is_dir() and not (seeded.lib / "rome").exists()
assert all(APPROVED in item["current_path"] for item in after.values())
assert _journal_states(seeded, plan["id"]) == ["complete"]
finally:
restarted.stop()
def test_ambiguous_evidence_is_kept_for_a_human_and_keeps_blocking(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, "moving")
# Someone creates the destination while the operation is unresolved: now both
# paths exist and nothing can tell which one holds the truth.
(seeded.lib / APPROVED).mkdir(exist_ok=True)
restarted = Server(seeded).start()
try:
assert _recovery(restarted.base)["items"][0]["classification"] == "manual"
resolved = httpx.post(
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
).json()
assert resolved["manual"] == 1 and resolved["resumed"] == 0 and resolved["completed"] == 0
# Still blocking, and still nothing guessed.
assert _recovery(restarted.base)["blocks_mutation"] is True
assert (seeded.lib / "rome").is_dir()
finally:
restarted.stop()
def test_an_unresolved_rename_is_the_cancellation_boundary(tmp_path):
"""There is no cancel button once a rename starts. The boundary is that nothing
else may mutate the library until the interrupted work is resolved."""
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, "moving")
restarted = Server(seeded).start()
try:
assert _recovery(restarted.base)["blocks_mutation"] is True
refused = httpx.post(f"{restarted.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
assert refused.status_code == 409
assert refused.json()["error"]["code"] == "rename_recovery_required"
# Reading stays available throughout — only mutation is paused.
assert httpx.get(f"{restarted.base}/api/v1/albums/evidence", timeout=TIMEOUT).status_code
assert len(_inventory(restarted.base)) == 2
finally:
restarted.stop()
def test_rollback_returns_an_interrupted_move_to_its_source(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
before = _inventory(first.base)
plan = _plan(first.base)
finally:
first.stop()
# Crash after the content moved but before the database caught up: the operation
# is still reversible, which is exactly when rollback is defined.
_crash_during_apply(seeded, plan, "moved")
restarted = Server(seeded).start()
try:
rolled = httpx.post(
f"{restarted.base}/api/v1/rename-plans/{plan['id']}/rollback", timeout=TIMEOUT
).json()
assert rolled["rolled_back"] == 1 and rolled["state"] == "rolled_back"
assert (seeded.lib / "rome" / "a.jpg").exists()
assert not (seeded.lib / APPROVED).exists()
after = _inventory(restarted.base)
assert set(after) == set(before)
assert all(item["current_path"].endswith(".jpg") for item in after.values())
assert _recovery(restarted.base)["blocks_mutation"] is False
finally:
restarted.stop()
# ── durability ───────────────────────────────────────────────────────────────
def test_the_applied_state_survives_a_full_restart(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
_apply(first.base, plan, expected_checksum=plan["checksum"]).raise_for_status()
expected = _inventory(first.base)
finally:
first.stop()
restarted = Server(seeded).start()
try:
assert _inventory(restarted.base) == expected
after = _get_plan(restarted.base, plan["id"])
assert after["state"] == "applied"
assert after["checksum"] == plan["checksum"], "the plan's evidence is immutable"
assert [op["journal_state"] for op in after["operations"]] == ["complete"]
assert all(op["verified_at"] for op in after["operations"])
assert _recovery(restarted.base) == {"blocks_mutation": False, "items": []}
finally:
restarted.stop()

View File

@@ -11,89 +11,22 @@ Renames really happen here: the assertions read the filesystem afterwards.
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import httpx
import pytest
from playwright.sync_api import expect
from tests.e2e._pipeline_harness import Server, image, seed_library
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
# Part of the Phase D acceptance command (US04-06); mapped to US04-05 for traceability.
pytestmark = pytest.mark.phase_d
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
TIMEOUT = 10
APPROVED = "2019 Rome"
def _seed(tmp_path):
"""A library with one album ("rome") whose two photos are SFW and analysed."""
seeded = seed_library(tmp_path, {}, {})
album = seeded.lib / "rome"
album.mkdir()
image(album / "a.jpg", 1)
image(album / "b.jpg", 2)
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.inventory import InventoryService
with _factory(seeded) as sf:
InventoryService(sf).scan(seeded.lib)
with sf() as session:
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
for asset_id, path in rows:
session.add(
SafetyReview(
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
)
)
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
description=f"a view of {path}",
tags='["ruins", "city"]',
approx_year=2019,
location_hint="Rome",
)
)
session.commit()
return seeded
class _factory:
"""Session factory against the seeded database, for the few things a test has to
set up or inspect below the API (journal states, mainly)."""
def __init__(self, seeded):
self._seeded = seeded
def __enter__(self):
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib),
}
)
run_migrations(config.database_url)
self._engine = create_db_engine(config.database_url)
return create_session_factory(self._engine)
def __exit__(self, *_):
self._engine.dispose()
return False
@pytest.fixture
def server(tmp_path):
seeded = _seed(tmp_path)
seeded = seed_album(tmp_path)
running = Server(seeded).start()
running.seeded = seeded
try:
@@ -105,22 +38,8 @@ def server(tmp_path):
# ── helpers ──────────────────────────────────────────────────────────────────
def _approve(base, *, album="rome", name=APPROVED):
"""Generate a proposal, set its final name, and approve it — the state a rename
plan is built from."""
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT).raise_for_status()
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/edit",
json={"name": name, "expected_version": current["version"]},
timeout=TIMEOUT,
).raise_for_status()
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/approve",
json={"expected_version": current["version"]},
timeout=TIMEOUT,
).raise_for_status()
def _approve(base, *, name=APPROVED):
approve_album(base, name=name)
def _build(base):
@@ -145,7 +64,7 @@ def _interrupt(seeded, plan, *, state="moving", make_destination=False):
"""
from photo_pipeline.services.rename_journal import RenameJournal
with _factory(seeded) as sf:
with session_factory(seeded) as sf:
journal = RenameJournal(sf)
operation = journal.operations(plan["id"])[0]
journal.begin(operation["id"], worker_id="crashed", fencing_token=1)
@@ -263,6 +182,22 @@ def test_the_applied_plan_survives_a_reload(page, server):
expect(page.get_by_test_id("apply-result")).to_have_count(0)
def test_the_view_matches_the_journal_after_a_server_restart(page, server):
_open_plan(page, server)
page.get_by_test_id("apply-plan").click()
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
server.stop()
server.start() # same port, so the page reconnects to a genuinely fresh process
page.reload()
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text(
"complete"
)
expect(page.get_by_test_id("op-verified")).to_be_visible()
def test_apply_can_be_confirmed_from_the_keyboard(page, server):
_open_plan(page, server)
page.get_by_test_id("apply-plan").focus()
@@ -287,7 +222,7 @@ def test_rollback_returns_an_unfinished_move_to_its_source(page, server):
# the destination and the operation is still reversible.
_interrupt(server.seeded, plan, state="moving")
(server.seeded.lib / "rome").rename(server.seeded.lib / APPROVED)
with _factory(server.seeded) as sf:
with session_factory(server.seeded) as sf:
from photo_pipeline.services.rename_journal import RenameJournal
journal = RenameJournal(sf)

View File

@@ -12,12 +12,19 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
MAP = json.loads((REPO / "tests" / "story_traceability.json").read_text())["stories"]
PHASE_A_STORIES = {f"US01-0{n}" for n in range(1, 8)}
PHASE_D_STORIES = {f"US04-0{n}" for n in range(1, 7)}
def test_all_phase_a_stories_are_mapped():
assert PHASE_A_STORIES <= set(MAP)
def test_all_phase_d_stories_are_mapped():
"""US04-06 acceptance: every guarded-rename story, plan through browser, is tied
to automated tests — renaming is the first thing that mutates the real library."""
assert PHASE_D_STORIES <= set(MAP)
def test_every_mapped_test_file_exists_and_is_nonempty():
for story, files in MAP.items():
assert files, f"{story} maps to no tests"

View File

@@ -100,6 +100,9 @@
],
"US04-05": [
"tests/e2e/test_renames_ui.py"
],
"US04-06": [
"tests/e2e/test_phase_d_pipeline.py"
]
}
}