feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
@@ -0,0 +1,252 @@
|
||||
"""Automation acceptance checks with isolated persistence and no real device side effects."""
|
||||
|
||||
from io import BytesIO
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.routers import automation as api
|
||||
from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import report_export as reports
|
||||
from apps.admin_console.services import task_import
|
||||
from apps.admin_console.services.automation_store import AutomationStore
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
db = tmp_path / "data.db"
|
||||
StorageManager(db_path=db, base_trace_dir=tmp_path)
|
||||
store = AutomationStore(db)
|
||||
for module in (api, service, reports):
|
||||
monkeypatch.setattr(module, "store", store)
|
||||
monkeypatch.setattr(reports, "TRACES_PATH", tmp_path / "traces")
|
||||
monkeypatch.setattr(reports, "EXPORT_ROOT", tmp_path / "exports")
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||
|
||||
monkeypatch.setattr(reports, "SessionRepository", lambda *a: SessionRepository(db))
|
||||
monkeypatch.setattr(reports, "StepRepository", lambda *a: StepRepository(db))
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(isolated):
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def payload(**extra):
|
||||
return {
|
||||
"name": "批次",
|
||||
"cases": [{"name": "用例甲", "goal": "观察设置"}, {"name": "用例乙", "goal": "观察首页"}],
|
||||
"device_serial": "test-device",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def workbook_bytes(rows):
|
||||
wb = load_workbook(BytesIO(task_import.template()))
|
||||
for row in rows:
|
||||
wb["任务"].append(row)
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_template_preview_rejects_formula_and_bad_json():
|
||||
data = workbook_bytes(
|
||||
[
|
||||
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
|
||||
["公式", "=SUM(1,2)"],
|
||||
["参数错误", "测试", "", "", "", "{"],
|
||||
]
|
||||
)
|
||||
result = task_import.parse_workbook(data)
|
||||
assert len(result["tasks"]) == 1
|
||||
assert [e["row"] for e in result["errors"]] == [3, 4]
|
||||
assert not result["valid"]
|
||||
|
||||
|
||||
def test_invalid_archive_and_empty_template():
|
||||
with pytest.raises(ValueError):
|
||||
task_import.parse_workbook(b"not a zip")
|
||||
assert not task_import.parse_workbook(task_import.template())["valid"]
|
||||
|
||||
|
||||
def test_preview_does_not_enqueue(client, isolated):
|
||||
response = client.post(
|
||||
"/api/automation/import/preview",
|
||||
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"]
|
||||
assert not isolated.jobs()
|
||||
|
||||
|
||||
def test_preferences_and_required_device(client):
|
||||
assert client.get("/api/automation/preferences").json()["formats"] == ["docx", "xlsx"]
|
||||
assert client.put("/api/automation/preferences", json={"formats": ["xlsx"]}).status_code == 200
|
||||
assert client.put("/api/automation/preferences", json={"formats": []}).status_code == 422
|
||||
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
||||
|
||||
|
||||
def test_restart_never_repeats_started_work(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**job, "status": "queued"})
|
||||
service.recover_batches()
|
||||
assert isolated.get("batch", batch["id"])["status"] == "paused"
|
||||
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["interrupted", "pending"]
|
||||
retry = service.batch_action(batch["id"], "retry_failed")
|
||||
assert retry["status"] == "draft"
|
||||
assert len(isolated.jobs(retry["id"])) == 1
|
||||
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
|
||||
from apps.admin_console import server
|
||||
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_pool.validate_explicit_serial_async", AsyncMock(return_value=None)
|
||||
)
|
||||
from artemis.runtime.device_pool import DeviceStatus
|
||||
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_pool.try_list_devices_async",
|
||||
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||
)
|
||||
await service.tick_batches()
|
||||
await service.tick_batches()
|
||||
assert enqueue.await_count == 1
|
||||
assert enqueue.call_args.kwargs["device_serial"] == "test-device"
|
||||
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_pool.try_list_devices_async", AsyncMock(return_value=[])
|
||||
)
|
||||
await service.tick_batches()
|
||||
assert isolated.get("batch", batch["id"])["status"] == "paused"
|
||||
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
||||
batch = service.create_batch(payload(stop_on_failure=True))
|
||||
first = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**first, "status": "failed"})
|
||||
service.batch_action(batch["id"], "resume")
|
||||
await service.tick_batches()
|
||||
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
|
||||
archive = reports.export_batch(batch["id"])
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
assert len(z.namelist()) == 6 # two summary files + two files per task
|
||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
monkeypatch.setattr(
|
||||
reports, "export_session", lambda *a: (_ for _ in ()).throw(OSError("disk full"))
|
||||
)
|
||||
await service.on_terminal({"session_id": job["session_id"]}, "completed")
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "completed"
|
||||
assert "disk full" in saved["report_error"]
|
||||
|
||||
|
||||
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job(
|
||||
{**job, "case": {"name": "=1+1", "goal": '=HYPERLINK("x")'}, "status": "completed"}
|
||||
)
|
||||
report = reports.session_report(job["session_id"])
|
||||
assert report["verdict"].startswith("未验证")
|
||||
assert reports.verdict({"tests": {"passed": 2, "failed": 1}}) == "验证失败"
|
||||
assert reports.verdict({"tests": {"passed": 2, "unchecked": 1}}) == "验证不完整"
|
||||
paths = reports.export_session(job["session_id"])
|
||||
wb = load_workbook(next(p for p in paths if p.suffix == ".xlsx"))
|
||||
assert wb["任务结果"]["B3"].data_type == "s"
|
||||
with zipfile.ZipFile(next(p for p in paths if p.suffix == ".docx")) as doc:
|
||||
assert "未验证".encode() in doc.read("word/document.xml")
|
||||
|
||||
|
||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
isolated.save_job(
|
||||
{
|
||||
**first,
|
||||
"status": "completed",
|
||||
"created_at": 100,
|
||||
"case": {"name": "搜索_%", "goal": "abc", "tags": "回归"},
|
||||
}
|
||||
)
|
||||
isolated.save_job({**second, "status": "failed", "created_at": 200})
|
||||
assert client.get("/api/automation/history", params={"q": "_%"}).json()["total"] == 1
|
||||
assert (
|
||||
client.get("/api/automation/history", params={"q": "回归"}).json()["items"][0]["session_id"]
|
||||
== first["session_id"]
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
"/api/automation/history", params={"start": 150, "end": 250, "status": "failed"}
|
||||
).json()["total"]
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
client.get("/api/automation/history", params={"size": 1, "page": 2}).json()["items"][0][
|
||||
"session_id"
|
||||
]
|
||||
== first["session_id"]
|
||||
)
|
||||
|
||||
|
||||
def test_batch_submission_is_atomic_and_idempotent(client, isolated):
|
||||
value = payload(request_id="same-request")
|
||||
first = client.post("/api/automation/batches", json=value)
|
||||
second = client.post("/api/automation/batches", json=value)
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json()["id"] == second.json()["id"]
|
||||
assert len(isolated.jobs()) == 2
|
||||
assert len(isolated.records("batch")) == 1
|
||||
conflict = client.post("/api/automation/batches", json={**value, "name": "different"})
|
||||
assert conflict.status_code == 409
|
||||
lookup = client.get("/api/automation/batches/by-request/same-request").json()
|
||||
assert lookup["id"] == first.json()["id"]
|
||||
assert len(lookup["jobs"]) == 2
|
||||
|
||||
|
||||
def test_removed_replay_is_inert_but_history_survives(client, isolated):
|
||||
batch = service.create_batch(payload())
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**job, "execution_mode": "strict", "workflow_id": "legacy"})
|
||||
service.recover_batches()
|
||||
assert isolated.get("batch", batch["id"])["status"] == "cancelled"
|
||||
assert all(j["status"] == "cancelled" for j in isolated.jobs(batch["id"]))
|
||||
assert client.post(f"/api/automation/batches/{batch['id']}/resume").status_code == 409
|
||||
assert client.post(f"/api/automation/batches/{batch['id']}/retry_failed").status_code == 409
|
||||
assert client.get("/api/automation/workflows").status_code == 404
|
||||
assert client.get("/api/automation/history").json()["total"] == 2
|
||||
@@ -22,6 +22,8 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_TIMEOUT_SECONDS",
|
||||
"OPENAI_MAX_RETRIES",
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"OPENAI_REASONING_EFFORT",
|
||||
"ARTEMIS_VIDEO_ENABLED",
|
||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
||||
@@ -85,7 +87,7 @@ def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
||||
assert config.pro.video_analyzer.enabled is False
|
||||
assert config.outputter.enabled is False
|
||||
assert config.flash.step_summarizer.enabled is False
|
||||
service.save_capability_settings(service.CapabilitySettings())
|
||||
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
|
||||
assert load_agent_config().video_analyzer.enabled is True
|
||||
|
||||
|
||||
@@ -106,3 +108,34 @@ def test_key_not_in_validation_errors(isolated_settings):
|
||||
service.ModelSettingsUpdate(model="test", api_key="secret\nvalue")
|
||||
)
|
||||
assert "secret" not in str(exc.value)
|
||||
|
||||
|
||||
def test_reasoning_retry_settings_roundtrip(isolated_settings):
|
||||
result = service.save_model_settings(service.ModelSettingsUpdate(model="gpt-5.6-luna"))
|
||||
assert result["timeout_seconds"] == 300
|
||||
assert result["max_retries"] == 5
|
||||
assert result["retry_interval_seconds"] == 10
|
||||
assert result["reasoning_effort"] == "high"
|
||||
saved = Settings(_env_file=isolated_settings)
|
||||
assert saved.OPENAI_REASONING_EFFORT == "high"
|
||||
assert parse_llm_config().operator.reasoning_effort == "high"
|
||||
service.save_model_settings(
|
||||
service.ModelSettingsUpdate(model="custom", reasoning_effort="default")
|
||||
)
|
||||
assert parse_llm_config().operator.reasoning_effort == "default"
|
||||
|
||||
|
||||
def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch):
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory
|
||||
from unittest.mock import Mock
|
||||
|
||||
factory = Mock()
|
||||
monkeypatch.setattr("langchain_openai.ChatOpenAI", factory)
|
||||
ModelFactory.create_model(
|
||||
ModelEndpoint(api_key="test-only", reasoning_effort="high", max_retries=5)
|
||||
)
|
||||
assert factory.call_args.kwargs["reasoning_effort"] == "high"
|
||||
assert factory.call_args.kwargs["max_retries"] == 0
|
||||
assert factory.call_args.kwargs["use_responses_api"] is True
|
||||
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
|
||||
assert "reasoning_effort" not in factory.call_args.kwargs
|
||||
@@ -241,8 +241,8 @@ async def test_invoke_with_retry_caps_unknown_failures_by_policy():
|
||||
with patch.object(va.asyncio, "sleep", new=AsyncMock()):
|
||||
with pytest.raises(RuntimeError):
|
||||
await va._invoke_with_retry(op, "test-op", max_attempts=5)
|
||||
# UNKNOWN failures follow the shared policy, not the caller's outer cap.
|
||||
assert calls == retry_policy_for(FailureCategory.UNKNOWN).max_attempts
|
||||
# Respect both the configured policy and the caller's explicit cap.
|
||||
assert calls == min(5, retry_policy_for(FailureCategory.UNKNOWN).max_attempts)
|
||||
|
||||
|
||||
# ------------------------------------------------ mid-run agentic degrade
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""MCP transport contracts: no real device actions, files and HTTP are isolated."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mcp_server.tools import automation as tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch):
|
||||
request = AsyncMock(
|
||||
return_value={
|
||||
"model": "old",
|
||||
"base_url": "https://example.test/v1",
|
||||
"api_key_configured": True,
|
||||
"env_path": "/private/.env",
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(tools, "_request", request)
|
||||
with pytest.raises(ValueError, match="persist_defaults"):
|
||||
await tools.mobile_config(changes={"model": "new"})
|
||||
with pytest.raises(ValueError, match="密钥"):
|
||||
await tools.mobile_config(changes={"api_key": "never-sent"}, persist_defaults=True)
|
||||
request.assert_not_awaited()
|
||||
await tools.mobile_config(changes={"model": "new"}, persist_defaults=True)
|
||||
payload = request.call_args.kwargs["json"]
|
||||
assert payload == {
|
||||
"model": "new",
|
||||
"base_url": "https://example.test/v1",
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
||||
source = tmp_path / "cases.xlsx"
|
||||
source.write_bytes(b"test workbook")
|
||||
request = AsyncMock(return_value={"valid": True, "tasks": [], "errors": []})
|
||||
monkeypatch.setattr(tools, "_request", request)
|
||||
await tools.mobile_task_file("preview", str(source))
|
||||
assert request.call_args.args == ("POST", "/api/automation/import/preview")
|
||||
request.reset_mock()
|
||||
with pytest.raises(ValueError):
|
||||
await tools.mobile_batch("create", batch={"name": "x"})
|
||||
request.assert_not_awaited()
|
||||
await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"})
|
||||
assert request.await_count == 1
|
||||
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
|
||||
assert request.call_args.args[1] == "/api/automation/batches"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_batch_start_is_idempotent(monkeypatch):
|
||||
request = AsyncMock(return_value={"id": "batch", "status": "running"})
|
||||
monkeypatch.setattr(tools, "_request", request)
|
||||
await tools.mobile_batch("start", batch_id="batch")
|
||||
assert request.await_count == 1
|
||||
assert request.call_args.args[0] == "GET"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
|
||||
monkeypatch.setattr(tools, "_base_url", AsyncMock(return_value="http://test"))
|
||||
client_type = httpx.AsyncClient
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, content=b"PK\x03\x04report-bytes")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
||||
)
|
||||
result = await tools._download("/api/automation/reports/test/docx", "test.docx")
|
||||
from pathlib import Path
|
||||
|
||||
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
||||
assert Path(result["path"]).is_absolute()
|
||||
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
||||
assert not list(tmp_path.glob("*.part"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_uses_saved_formats_without_mutating_execution(monkeypatch):
|
||||
request = AsyncMock(
|
||||
side_effect=[{"formats": ["xlsx"]}, {"verdict": "未验证", "status": "completed"}]
|
||||
)
|
||||
download = AsyncMock(return_value={"path": "/exports/report.xlsx"})
|
||||
monkeypatch.setattr(tools, "_request", request)
|
||||
monkeypatch.setattr(tools, "_download", download)
|
||||
result = await tools.mobile_export_report(session_id="session")
|
||||
assert result["report"]["verdict"] == "未验证"
|
||||
assert len(result["files"]) == 1
|
||||
assert all(call.args[0] == "GET" for call in request.call_args_list)
|
||||
assert download.call_args.args[0].endswith("/xlsx")
|
||||
@@ -197,6 +197,12 @@ def test_cli_mcp_generate_config_antigravity():
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
"mobile_diagnose",
|
||||
"mobile_workspace",
|
||||
"mobile_config",
|
||||
"mobile_task_file",
|
||||
"mobile_batch",
|
||||
"mobile_search_history",
|
||||
"mobile_export_report",
|
||||
}
|
||||
assert all(tool["eager"] is True for tool in legacy_config["tools"].values())
|
||||
|
||||
@@ -236,6 +242,12 @@ def test_cli_mcp_generate_config_codex():
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
"mobile_diagnose",
|
||||
"mobile_workspace",
|
||||
"mobile_config",
|
||||
"mobile_task_file",
|
||||
"mobile_batch",
|
||||
"mobile_search_history",
|
||||
"mobile_export_report",
|
||||
]
|
||||
|
||||
|
||||
@@ -433,6 +445,12 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
"mobile_diagnose",
|
||||
"mobile_workspace",
|
||||
"mobile_config",
|
||||
"mobile_task_file",
|
||||
"mobile_batch",
|
||||
"mobile_search_history",
|
||||
"mobile_export_report",
|
||||
]
|
||||
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
||||
assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Exercise upgrades against disposable Git repos and client configs, never the user's repo."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts/update_installation.py"
|
||||
spec = importlib.util.spec_from_file_location("installation_update", MODULE_PATH)
|
||||
update = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(update)
|
||||
|
||||
|
||||
def git(path, *args):
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(path), *args], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos(tmp_path):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
remote = tmp_path / "upstream.git"
|
||||
remote.mkdir()
|
||||
git(remote, "init", "--bare")
|
||||
author = tmp_path / "author"
|
||||
author.mkdir()
|
||||
git(author, "init", "-b", "main")
|
||||
git(author, "config", "user.name", "Test Fixture")
|
||||
git(author, "config", "user.email", "fixture@example.test")
|
||||
(author / "source.txt").write_text("version1")
|
||||
(author / ".gitignore").write_text(".env\n.venv/\n")
|
||||
git(author, "add", "source.txt", ".gitignore")
|
||||
git(author, "commit", "-m", "initial")
|
||||
git(author, "remote", "add", "origin", str(remote))
|
||||
git(author, "push", "-u", "origin", "main")
|
||||
installed = tmp_path / "installed with spaces"
|
||||
git(tmp_path, "clone", "-b", "main", str(remote), str(installed))
|
||||
(installed / ".env").write_text("secret stays local")
|
||||
(author / "source.txt").write_text("version2")
|
||||
git(author, "add", "source.txt")
|
||||
git(author, "commit", "-m", "upgrade")
|
||||
git(author, "push")
|
||||
return installed, author
|
||||
|
||||
|
||||
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
|
||||
installed, author = repos
|
||||
assert update.update_source(installed, port=1) == 10
|
||||
assert (installed / "source.txt").read_text() == "version2"
|
||||
assert (installed / ".env").read_text() == "secret stays local"
|
||||
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
|
||||
|
||||
def test_dirty_checkout_is_preserved(repos):
|
||||
installed, _ = repos
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
(installed / "source.txt").write_text("user edits")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
assert (installed / "source.txt").read_text() == "user edits"
|
||||
|
||||
|
||||
def test_diverged_checkout_is_not_merged(repos):
|
||||
installed, _ = repos
|
||||
git(installed, "config", "user.name", "Test Fixture")
|
||||
git(installed, "config", "user.email", "fixture@example.test")
|
||||
(installed / "local.txt").write_text("local commit")
|
||||
git(installed, "add", "local.txt")
|
||||
git(installed, "commit", "-m", "local")
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
|
||||
|
||||
def test_zip_install_continues_without_git_mutation(tmp_path, capsys):
|
||||
assert update.update_source(tmp_path, port=1) == 0
|
||||
assert not (tmp_path / ".git").exists()
|
||||
assert "Git" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
|
||||
root = tmp_path / "project"
|
||||
path = tmp_path / "config.toml"
|
||||
original = f"""model = "custom"
|
||||
[mcp_servers.artemis]
|
||||
command = "old-python"
|
||||
cwd = {json.dumps(str(root))}
|
||||
args = ["-m", "mcp_server"]
|
||||
enabled = false
|
||||
enabled_tools = [
|
||||
"mobile_run_task",
|
||||
]
|
||||
disabled_tools = ["mobile_config"]
|
||||
tool_timeout_sec = 900
|
||||
[mcp_servers.artemis.env]
|
||||
OPENAI_API_KEY = "test-only-secret"
|
||||
[mcp_servers.other]
|
||||
command = "leave-alone"
|
||||
"""
|
||||
path.write_text(original, encoding="utf-8")
|
||||
assert update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
config = data["mcp_servers"]["artemis"]
|
||||
assert config["enabled"] is False
|
||||
assert config["disabled_tools"] == ["mobile_config"]
|
||||
assert config["tool_timeout_sec"] == 900
|
||||
assert config["env"]["OPENAI_API_KEY"] == "test-only-secret"
|
||||
assert config["enabled_tools"] == ["mobile_run_task", "mobile_batch"]
|
||||
assert data["mcp_servers"]["other"]["command"] == "leave-alone"
|
||||
assert not update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
|
||||
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_json_only_updates_existing_matching_project(tmp_path):
|
||||
path = tmp_path / "mcp.json"
|
||||
root = tmp_path / "project"
|
||||
config = {
|
||||
"cwd": str(root),
|
||||
"command": "old",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"disabled": True,
|
||||
"env": {"CUSTOM": "preserved"},
|
||||
"autoApprove": [],
|
||||
}
|
||||
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
|
||||
assert not update.refresh_json(
|
||||
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",)
|
||||
)
|
||||
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
|
||||
result = json.loads(path.read_text())["mcpServers"]
|
||||
assert result["artemis"]["env"] == {"CUSTOM": "preserved"}
|
||||
assert result["artemis"]["disabled"] is True
|
||||
assert result["other"] == {"command": "keep"}
|
||||
missing = tmp_path / "not-installed.json"
|
||||
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
|
||||
assert not missing.exists()
|
||||
|
||||
|
||||
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
||||
ui = tmp_path / "apps/showcase_ui"
|
||||
(ui / "src").mkdir(parents=True)
|
||||
(ui / "src/app.ts").write_text("one")
|
||||
(ui / "package-lock.json").write_text("lock1")
|
||||
first = update.frontend_fingerprint(tmp_path)
|
||||
(ui / "package-lock.json").write_text("lock2")
|
||||
second = update.frontend_fingerprint(tmp_path)
|
||||
assert first != second
|
||||
(ui / "src/app.ts").unlink()
|
||||
assert update.frontend_fingerprint(tmp_path) != second
|
||||
@@ -239,3 +239,57 @@ async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch):
|
||||
assert payload["reason"] == "mid_stream_failure"
|
||||
assert "stream_exec_id" in payload
|
||||
assert "lower API priority" in payload["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
|
||||
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
|
||||
monkeypatch.setattr(llm_service.settings, "OPENAI_MAX_RETRIES", 5)
|
||||
monkeypatch.setattr(llm_service.settings, "OPENAI_RETRY_INTERVAL_SECONDS", 10)
|
||||
monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False))
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
|
||||
|
||||
class Fails:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
self.calls += 1
|
||||
if self.calls % 2:
|
||||
raise ConnectionError("connection reset")
|
||||
raise TimeoutError("timeout")
|
||||
|
||||
main, fallback = Fails(), Fails()
|
||||
endpoint = ModelEndpoint(model_name="test", api_base="https://example.test/v1", max_retries=5)
|
||||
primary = RobustChatModelWrapper(main, endpoint=endpoint)
|
||||
secondary = RobustChatModelWrapper(fallback, endpoint=endpoint.model_copy())
|
||||
with pytest.raises(LLMExhaustedError):
|
||||
await with_fallback(lambda: primary.ainvoke([]), lambda: secondary.ainvoke([]))
|
||||
assert main.calls == 6
|
||||
assert fallback.calls == 0
|
||||
assert [call.args[0] for call in sleep.call_args_list] == [10] * 5
|
||||
assert llm_service._FAILED_ENDPOINTS.get() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False))
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def pending():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
with pytest.raises(LLMExhaustedError):
|
||||
await llm_service._run_with_recovery(pending, request_timeout=0.005, max_retries=0)
|
||||
assert cancelled.is_set()
|
||||
@@ -157,7 +157,7 @@ def test_client_settings_and_cache_credentials(monkeypatch, max_retries):
|
||||
endpoint = ModelEndpoint(api_key="test", model_name="gpt-5-mini", reasoning_effort="low")
|
||||
model = ModelFactory.create_model(endpoint)
|
||||
assert model.use_responses_api is True
|
||||
assert model.max_retries == max_retries
|
||||
assert model.max_retries == 0 # Gateway owns retries, never multiply at SDK layer.
|
||||
assert model.reasoning_effort == "low"
|
||||
dynamic = ModelEndpoint()
|
||||
key = dynamic.cache_key()
|
||||
|
||||
Reference in new issue
Block a user