Files
deer-flow/backend/tests/test_replay_golden.py
T
Xinmin Zeng 88759015e4 test(e2e): deterministic record/replay front-back contract verification (#3365)
* test(e2e): record/replay front-back contract verification

Guards the front-back contract with a deterministic, key-free record/replay
harness (mirrors open-design's golden-trace approach):

- ReplayChatModel (tests/replay_provider.py): replays recorded LLM turns by a
  normalized hash of the model input. Strips <system-reminder>/date/uuid/tmp-path
  so one fixture replays across days and from both the browser and direct-POST
  paths; a miss raises loudly (no silent divergence).
- Recording is record-through-browser (scripts/record_gateway.py +
  build_fixture_from_jsonl.py + frontend/tests/e2e-record): a real run is driven
  through the real frontend so captured inputs match exactly what the browser
  sends; fixtures contain no API key.
- Layer 1 — backend golden (tests/test_replay_golden.py): replay through the real
  gateway, assert the SSE event sequence == committed golden.
- Layer 2 — full-stack render (frontend/tests/e2e-real-backend): real Next.js +
  real gateway (replay model) + Chromium; assert the replayed auto-title and
  follow-up suggestions render. DOM assertions are the gate; visual regression is
  a local dev gate (CI uploads the render as an artifact).
- CI (.github/workflows/replay-e2e.yml): both layers, triggered on EITHER side of
  the contract (frontend/** or backend gateway/harness/fixtures).

* test(e2e): multi-run render-order cross-stack scenario (#3352)

Guards the dangerous front-back class where a backend ordering change
silently breaks a frontend assumption while both sides' unit tests stay
green. Reproduces issue #3352: backend list_by_thread returns runs
newest-first (#2932) and the frontend prepended per-run pages, inverting
chronological order once the checkpoint no longer held the older messages.

- tests/seed_runs_router.py: test-only seeder, mounted on the replay
  gateway only when DEERFLOW_ENABLE_TEST_SEED=1 (never in the production
  app). Seeds a thread with >=2 runs + per-run message events and no
  checkpoint -- the #3352 precondition -- so the frontend per-run reload
  path is the sole source of truth and the prepend inversion is observable.
- frontend/tests/e2e-real-backend/multi-run-order.spec.ts: drives the real
  frontend against the real gateway, asserts the first run renders above
  the second. Reverting the #3354 fix turns it red.
- replay-e2e.yml: trigger on the new replay test-infra paths.
- docs: REPLAY_E2E.md cross-stack scenario section.

* test(e2e): address Copilot review on the replay harness

- Fix stale recorder references (scripts/record_traces.py ->
  scripts/record_gateway.py + scripts/build_fixture_from_jsonl.py) in
  replay_provider.py, test_replay_golden.py, _replay_fixture.py.
- MODE_CONTEXT['ultra']: thinking_enabled False -> True, mirroring the
  frontend's `context.mode !== 'flash'` (hooks.ts). It did not affect the
  hashed input (Layer 1 golden still green), but the table now matches the
  real frontend context it claims to mirror.
- replay_provider.py docstring: stop claiming memory is recorded-enabled;
  the replay config disables memory/summarization for determinism (title
  stays, as an in-graph deterministic call).
- record_gateway.py / run_replay_gateway.py: override DEER_FLOW_HOME instead
  of setdefault, so an outer value can't leak into the hermetic harness.
- record_gateway.py: clear error when DEERFLOW_RECORD_OUT is unset (was a
  bare KeyError).
- playwright.record.config.ts: forward OPENAI_*/DEERFLOW_RECORD_OUT only when
  set, so the gateway raises a clear 'missing env' error instead of getting ''.

* test(e2e): address Copilot review round 2

- seed_runs_router.py: constrain SeedMessage.role to Literal['human','ai']
  so a bad value is a clean 422 at the boundary instead of a 500
  (KeyError on _EVENT_TYPE).
- record-write-read-file.spec.ts: waitForCaptureStable now throws on
  timeout instead of returning the last count, so a truncated/partial
  recording can't pass silently.
- real-backend-render.spec.ts: guard the suggestions JSON.parse; a
  bracket-prefixed non-JSON turn falls back to '' so the existing
  not.toBe('') assertion fails clearly instead of a generic parse throw.
2026-06-08 12:35:03 +08:00

88 lines
3.9 KiB
Python

"""Layer 1 of the record/replay e2e: replay a recorded trace through the **real
gateway** with a deterministic ``ReplayChatModel`` (no API key, no network) and
assert the streamed SSE event sequence matches a committed golden.
This catches backend protocol drift: if a change alters the shape/sequence of
SSE the gateway emits for the recorded scenario, this test goes red. The replay
model serves the recorded assistant turns by input hash, so the agent graph
(write_file -> auto-title -> read_file -> final answer) reproduces offline.
Fixtures are produced by ``scripts/record_gateway.py`` +
``scripts/build_fixture_from_jsonl.py`` (manual, needs a key).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from _replay_fixture import REPLAY_MODEL_BLOCK, build_config_yaml, drive_gateway, prepare_hermetic_extras
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "replay"
def _reset_process_singletons(monkeypatch: pytest.MonkeyPatch) -> None:
"""Invalidate process-wide caches so the test-only config/home take effect.
Same set the real-server e2e resets (see test_setup_agent_http_e2e_real_server).
"""
from deerflow.config import app_config as app_config_module
from deerflow.config import paths as paths_module
from deerflow.persistence import engine as engine_module
for module, attr in (
(app_config_module, "_app_config"),
(app_config_module, "_app_config_path"),
(app_config_module, "_app_config_mtime"),
(paths_module, "_paths_singleton"),
(engine_module, "_engine"),
(engine_module, "_session_factory"),
):
monkeypatch.setattr(module, attr, None, raising=False)
@pytest.mark.no_auto_user
def test_replay_write_read_file_ultra_matches_golden(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
scenario, mode = "write_read_file", "ultra"
fixture_path = FIXTURE_DIR / f"{scenario}.{mode}.json"
events_path = FIXTURE_DIR / f"{scenario}.{mode}.events.json"
fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("DEER_FLOW_HOME", str(home))
monkeypatch.setenv("DEERFLOW_REPLAY_FIXTURE", str(fixture_path))
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(build_config_yaml(model_block=REPLAY_MODEL_BLOCK, home=home), encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(prepare_hermetic_extras(home)))
_reset_process_singletons(monkeypatch)
from deerflow.config import app_config as app_config_module
cfg = app_config_module.get_app_config()
cfg.database.sqlite_dir = str(home / "db")
from app.gateway.app import create_app
events = drive_gateway(create_app(), prompt=fixture["prompt"], context=fixture["context"])
assert events, "replay produced no SSE events"
assert events[0]["event"] == "metadata", f"first event should be metadata, got {events[0]!r}"
assert events[-1]["event"] == "end", f"last event should be end (run completed), got {events[-1]!r}"
# Regenerate the committed golden after re-recording the fixture:
# DEERFLOW_WRITE_GOLDEN=1 uv run pytest tests/test_replay_golden.py
if os.environ.get("DEERFLOW_WRITE_GOLDEN"):
events_path.write_text(json.dumps({"scenario": scenario, "mode": mode, "events": events}, ensure_ascii=False, indent=2), encoding="utf-8")
return
golden = json.loads(events_path.read_text(encoding="utf-8"))["events"]
# A replay hash-miss surfaces as the run erroring mid-stream -> the event
# shape sequence diverges from the golden, so this assertion is the catch-all
# for both backend SSE drift and replay divergence.
assert events == golden, f"SSE event-shape sequence drifted from the golden.\ngot ({len(events)}): {[e['event'] for e in events]}\nwant ({len(golden)}): {[e['event'] for e in golden]}"