"""Favorite snapshots and transport retries never replay an old result or task.""" import asyncio from copy import deepcopy import json from unittest.mock import AsyncMock from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import ValidationError import pytest from apps.admin_console.services import automation_service from apps.admin_console.services.automation_store import AutomationStore from apps.admin_console.services.favorites import ( FavoriteCreate, FavoriteRun, FavoriteService, FavoriteUpdate, ) from artemis.config.exploration import ExplorationConfig from artemis.data_engine.storage import StorageManager from artemis.exploration.models import CaseDraft from artemis.exploration.store import ExplorationStore @pytest.fixture def favorites(tmp_path, monkeypatch): storage = AutomationStore(tmp_path / "history.db") StorageManager(storage.path, tmp_path / "traces") monkeypatch.setattr(automation_service, "store", storage) return FavoriteService(storage) def task(service, session_id="task", **options): config = { "profile": "pro", "model": "original-model", "reasoning_effort": "high", "device_serial": "original-device", "record_video": True, **options, } job = { "session_id": session_id, "status": "completed", "case": {"name": "原用例", "goal": "检查真实页面"}, "execution_config": { "runtime_overrides": config, "expected_output": "保留证据", "enable_outputter": True, }, } service.store.save_job(job) return service.create(FavoriteCreate(source_id=session_id)) def test_snapshot_survives_source_deletion_and_edits_do_not_modify_source(favorites): item = task(favorites) updated = favorites.update( item["id"], FavoriteUpdate(name="常用检查", task_config={"model": "new-model"}) ) assert updated["snapshot"]["runtime_overrides"]["model"] == "new-model" assert ( favorites.store.job("task")["execution_config"]["runtime_overrides"]["model"] == "original-model" ) favorites.store.delete_history() assert favorites.get(item["id"])["snapshot"]["goal"] == "检查真实页面" assert favorites.store.job("task") is None @pytest.mark.asyncio async def test_rerun_preserves_settings_and_is_durably_idempotent(favorites, monkeypatch): item = task(favorites) enqueue = AsyncMock(return_value={"tasks": [{"session_id": "new-task"}], "status": "queued"}) monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) value = FavoriteRun(request_id="same-submission", task_config={"reasoning_effort": "max"}) first, second = await asyncio.gather( favorites.run(item["id"], value), favorites.run(item["id"], value) ) assert first == second enqueue.assert_awaited_once() request = enqueue.call_args.args[0] assert request.session_id != "task" assert request.runtime_overrides.model == "original-model" assert request.runtime_overrides.reasoning_effort == "max" assert request.runtime_overrides.record_video is True assert request.runtime_overrides.device_serial == "original-device" assert request.expected_output == "保留证据" assert favorites.get(item["id"])["snapshot"]["runtime_overrides"]["reasoning_effort"] == "high" favorites.delete(item["id"]) assert await FavoriteService(favorites.store).run(item["id"], value) == first enqueue.assert_awaited_once() with pytest.raises(ValueError, match="request_id"): await favorites.run( item["id"], FavoriteRun(request_id="same-submission", task_config={"model": "different"}), ) @pytest.mark.asyncio async def test_lost_admission_reply_reuses_snapshot_and_session(favorites, monkeypatch): item = task(favorites) enqueue = AsyncMock(side_effect=[ConnectionError("reply lost"), {"tasks": [{}]}]) monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) value = FavoriteRun(request_id="retry") with pytest.raises(ConnectionError): await favorites.run(item["id"], value) favorites.update(item["id"], FavoriteUpdate(task_config={"model": "changed"})) await FavoriteService(favorites.store).run(item["id"], value) requests = [call.args[0] for call in enqueue.call_args_list] assert requests[0].session_id == requests[1].session_id assert requests[1].runtime_overrides.model == "original-model" @pytest.mark.asyncio async def test_batch_favorite_keeps_one_round_and_original_repetitions(favorites): batch = automation_service.create_batch( { "name": "压测", "repeat_count": 3, "profile": "flash", "device_serial": "device", "cases": [ {"name": "甲", "goal": "检查甲", "task_config": {"model": "model-a"}}, { "name": "乙", "goal": "检查乙", "task_config": {"profile": "pro", "model": "model-b"}, }, ], } ) item = favorites.create(FavoriteCreate(source_kind="batch", source_id=batch["id"])) assert len(item["snapshot"]["cases"]) == 2 assert item["snapshot"]["repeat_count"] == 3 result = await favorites.run( item["id"], FavoriteRun( request_id="batch-rerun", task_config={"reasoning_effort": "max"}, case_configs={1: {"model": "changed-b"}}, ), ) jobs = favorites.store.jobs(result["batch_id"]) assert len(jobs) == 6 assert [j["case"]["name"] for j in jobs] == ["甲", "乙"] * 3 assert [j["case"]["task_config"]["model"] for j in jobs] == ["model-a", "changed-b"] * 3 assert all(j["case"]["task_config"]["reasoning_effort"] == "max" for j in jobs) assert favorites.store.get("batch", result["batch_id"])["status"] == "running" again = await favorites.run( item["id"], FavoriteRun( request_id="batch-rerun", task_config={"reasoning_effort": "max"}, case_configs={1: {"model": "changed-b"}}, ), ) assert again == result assert len(favorites.store.records("batch")) == 2 def test_exploration_case_pins_version_without_inheriting_expensive_model(favorites): repo = ExplorationStore(favorites.store) session = repo.create( "探索目标", ExplorationConfig(model="exploration-model", reasoning_effort="max"), {"model": "exploration-model", "device_serial": "device"}, "create", ) repo.save_case( session["id"], CaseDraft(name="完成用例", goal="验证主页", preconditions="已登录"), 0, "case-v1", ) item = favorites.create(FavoriteCreate(source_kind="exploration_case", source_id=session["id"])) assert item["case_version"] == 1 assert item["snapshot"]["goal"] == "前置条件:\n已登录\n\n验证主页" assert item["snapshot"]["runtime_overrides"]["profile"] == "flash" assert item["snapshot"]["runtime_overrides"]["model"] != "exploration-model" repo.save_case(session["id"], CaseDraft(name="新用例", goal="新目标"), 1, "case-v2") assert favorites.get(item["id"])["snapshot"]["goal"].endswith("验证主页") exploration = favorites.create( FavoriteCreate(source_kind="exploration", source_id=session["id"]) ) assert exploration["snapshot"]["config"]["model"] == "exploration-model" def test_old_task_recovers_real_model_and_marks_missing_settings(favorites): with favorites.store.connection() as conn: conn.execute( "INSERT INTO sessions(session_id,initial_goal,status,device_info) VALUES (?,?,?,?)", ( "legacy", "检查浏览器", "completed", json.dumps({"profile": "flash", "serial": "computer:local"}), ), ) conn.execute( "INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?,?,?,?,?)", ( "request", "legacy", "llm_diagnostic", 1, json.dumps( {"role": "FlashRunner", "model": "actual-model", "reasoning_effort": "max"} ), ), ) conn.commit() item = favorites.create(FavoriteCreate(source_id="legacy")) options = item["snapshot"]["runtime_overrides"] assert options["model"] == "actual-model" assert options["reasoning_effort"] == "max" assert options["execution_target"] == "computer" assert "model" not in item["inherited_fields"] assert "recording" in item["inherited_fields"] def test_api_rejects_credentials_and_arbitrary_snapshots(favorites, monkeypatch): from apps.admin_console.routers import favorites as api monkeypatch.setattr(api, "favorites", favorites) app = FastAPI() app.include_router(api.router) client = TestClient(app) item = task(favorites) assert len(client.get("/api/automation/favorites").json()) == 1 assert ( client.patch( f"/api/automation/favorites/{item['id']}", json={"task_config": {"api_key": "never"}} ).status_code == 422 ) assert ( client.post( "/api/automation/favorites", json={"source_id": "task", "snapshot": {}} ).status_code == 422 ) assert ( client.patch(f"/api/automation/favorites/{item['id']}", json={"name": " "}).status_code == 409 ) assert client.delete(f"/api/automation/favorites/{item['id']}").status_code == 200 assert favorites.store.job("task") with pytest.raises(ValidationError): FavoriteRun(request_id="run", task_config={"password": "never"}) def test_collection_defaults_cannot_modify_original_snapshot(favorites): item = task(favorites) original = deepcopy(item) favorites.updated(item, FavoriteUpdate(task_config={"model": "temporary"})) assert item == original def test_partial_recording_update_keeps_other_favorite_settings(favorites): item = task(favorites, recording={"codec": "h264", "fps": 10, "max_long_edge": 720}) updated = favorites.updated(item, FavoriteUpdate(task_config={"recording": {"codec": "h265"}})) assert updated["snapshot"]["runtime_overrides"]["recording"] == { **item["snapshot"]["runtime_overrides"]["recording"], "codec": "h265", } assert item["snapshot"]["runtime_overrides"]["recording"]["codec"] == "h264" def test_legacy_empty_recording_configuration_uses_current_defaults(): from apps.admin_console.services.execution_config import execution_config from artemis.config.agent import load_agent_config snapshot = execution_config({"profile": "flash", "runtime_overrides": {"recording": None}}) assert snapshot["runtime_overrides"]["recording"] == load_agent_config().recording.model_dump() def test_legacy_favorite_is_completed_once_without_changing_actual_settings(favorites, monkeypatch): from artemis.config.settings import settings item = task(favorites, max_retries=0, record_video=False, recording={"fps": 10}) item.pop("config_version") original = deepcopy(item) favorites.store.put("favorite", item["id"], item) completed = favorites.list()[0] options = completed["snapshot"]["runtime_overrides"] assert options["model"] == "original-model" assert options["max_retries"] == 0 assert options["record_video"] is False assert options["recording"]["fps"] == 10 assert options["recording"]["max_long_edge"] > 0 assert completed["created_at"] == original["created_at"] assert completed["updated_at"] == original["updated_at"] assert favorites.store.get("favorite", item["id"]) == completed monkeypatch.setattr(settings, "OPENAI_MODEL", "new-global-model") assert FavoriteService(favorites.store).get(item["id"]) == completed def test_partial_legacy_snapshot_and_batch_complete_missing_fields(favorites): from apps.admin_console.services.execution_config import execution_config expected = execution_config({})["runtime_overrides"] item = task(favorites) item.pop("config_version") item["snapshot"]["runtime_overrides"] = {"profile": "pro", "model": "kept-model"} favorites.store.put("favorite", item["id"], item) options = favorites.get(item["id"])["snapshot"]["runtime_overrides"] assert options["model"] == "kept-model" assert options["reasoning_effort"] == expected["reasoning_effort"] assert options["recording"] == expected["recording"] item["id"] = "legacy-batch" item["kind"] = "batch" item["snapshot"] = { "profile": "pro", "device_serial": "device", "cases": [ { "name": "一", "goal": "检查", "task_config": {"model": "case-model", "record_video": False}, } ], } favorites.store.put("favorite", item["id"], item) options = favorites.get(item["id"])["snapshot"]["cases"][0]["task_config"] assert options["model"] == "case-model" assert options["profile"] == "pro" assert options["device_serial"] == "device" assert options["record_video"] is False assert options["request_timeout_seconds"] == expected["request_timeout_seconds"] @pytest.mark.asyncio @pytest.mark.parametrize("rejected", [False, True]) async def test_restart_exploration_creates_new_conversation_and_reports_rejection( favorites, monkeypatch, rejected ): from apps.admin_console.services.exploration_service import ExplorationService repo = ExplorationStore(favorites.store) original = repo.create( "探索目标", ExplorationConfig(model="explore-model", reasoning_effort="max"), {"device_serial": "device", "record_video": True}, "source", ) item = favorites.create(FavoriteCreate(source_kind="exploration", source_id=original["id"])) monkeypatch.setattr( "apps.admin_console.services.exploration_service.exploration_service", ExplorationService(repo), ) enqueue = AsyncMock( return_value=( {"status": "rejected", "error": "设备离线"} if rejected else {"status": "queued", "tasks": [{}]} ) ) monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue) value = FavoriteRun(request_id="new-exploration", task_config={"model": "selected-model"}) if rejected: with pytest.raises(ValueError, match="设备离线"): await favorites.run(item["id"], value) with pytest.raises(ValueError, match="设备离线"): await favorites.run(item["id"], value) else: result = await favorites.run(item["id"], value) assert result["exploration_id"] != original["id"] assert repo.run(result["session_id"])["exploration_id"] == result["exploration_id"] assert await favorites.run(item["id"], value) == result request = enqueue.call_args.args[0] assert request.conversation_id != original["id"] assert request.runtime_overrides.model == "selected-model" assert request.runtime_overrides.reasoning_effort == "max" assert request.runtime_overrides.record_video is True enqueue.assert_awaited_once() assert repo.get(original["id"]) == original