feat: improve batch task controls and release 1.2.0
This commit is contained in:
1 parent
0e13bb5c29
commit
84e183d28d
38 files changed
+632
-147
No files matched your search
@@ -2,8 +2,10 @@
|
||||
|
||||
from io import BytesIO
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import UUID
|
||||
import zipfile
|
||||
|
||||
from docx import Document
|
||||
@@ -125,19 +127,44 @@ def test_preferences_and_required_device(client):
|
||||
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
||||
|
||||
|
||||
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated):
|
||||
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
|
||||
created = client.post(
|
||||
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||
).json()
|
||||
first, second = isolated.jobs(created["id"])
|
||||
isolated.save_job({**first, "status": "completed"})
|
||||
trace_dir = reports.TRACES_PATH / first["session_id"]
|
||||
trace_dir.mkdir(parents=True)
|
||||
(trace_dir / "result.json").write_text("{}", encoding="utf-8")
|
||||
report_dir = reports.EXPORT_ROOT / "sessions" / first["session_id"]
|
||||
report_dir.mkdir(parents=True)
|
||||
(report_dir / "任务报告.docx").write_bytes(b"report")
|
||||
batch_dir = reports.EXPORT_ROOT / "batches" / created["id"]
|
||||
batch_dir.mkdir(parents=True)
|
||||
(batch_dir / "批次报告.zip").write_bytes(b"zip")
|
||||
mcp_task_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "sessions" / first["session_id"]
|
||||
mcp_task_dir.mkdir(parents=True)
|
||||
(mcp_task_dir / "报告.docx").write_bytes(b"report")
|
||||
mcp_batch_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "batches" / created["id"]
|
||||
mcp_batch_dir.mkdir(parents=True)
|
||||
(mcp_batch_dir / "批次.zip").write_bytes(b"zip")
|
||||
unrelated = reports.TRACES_PATH / "unrelated-task"
|
||||
unrelated.mkdir()
|
||||
(unrelated / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
|
||||
response = client.delete(f"/api/automation/batches/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
|
||||
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲"
|
||||
assert isolated.job(first["session_id"]) is None
|
||||
assert isolated.job(second["session_id"]) is None
|
||||
assert reports.session_report(first["session_id"])["name"] == "用例甲"
|
||||
assert not trace_dir.exists()
|
||||
assert not report_dir.exists()
|
||||
assert not batch_dir.exists()
|
||||
assert not mcp_task_dir.exists()
|
||||
assert not mcp_batch_dir.exists()
|
||||
assert (unrelated / "keep.txt").read_text(encoding="utf-8") == "keep"
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
reports.session_report(first["session_id"])
|
||||
assert (
|
||||
client.post(
|
||||
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||
@@ -158,6 +185,49 @@ def test_batch_delete_rejects_active_jobs(client, isolated, status):
|
||||
assert len(isolated.jobs(batch["id"])) == 2
|
||||
|
||||
|
||||
def test_batch_deletion_reservation_blocks_new_dispatch(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
|
||||
isolated.begin_delete_batch(batch["id"])
|
||||
|
||||
with pytest.raises(ValueError, match="正在删除"):
|
||||
isolated.save_job({**job, "status": "queued"})
|
||||
with pytest.raises(ValueError, match="正在删除"):
|
||||
isolated.put("batch", batch["id"], {**batch, "status": "running"})
|
||||
assert isolated.jobs_for_sessions([job["session_id"]])[job["session_id"]]["status"] == "pending"
|
||||
|
||||
|
||||
def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isolated):
|
||||
first_batch = service.create_batch(payload())
|
||||
second_batch = service.create_batch(payload(name="另一批次"))
|
||||
first = isolated.jobs(first_batch["id"])[0]
|
||||
other = isolated.jobs(second_batch["id"])[0]
|
||||
shared_hash = "a" * 64
|
||||
storage = StorageManager(isolated.path, reports.TRACES_PATH)
|
||||
image_file = reports.TRACES_PATH / "images" / f"{shared_hash}.jpg"
|
||||
image_file.parent.mkdir(parents=True)
|
||||
image_file.write_bytes(b"shared image")
|
||||
with sqlite3.connect(isolated.path) as conn:
|
||||
conn.execute("INSERT INTO images(image_name) VALUES (?)", (shared_hash,))
|
||||
for job in (first, other):
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(job["session_id"], "观察设置", 1.0, "completed", "{}"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO steps(step_id,session_id,pre_image_name) VALUES (?,?,?)",
|
||||
(job["session_id"], job["session_id"], shared_hash),
|
||||
)
|
||||
|
||||
assert client.delete(f"/api/automation/batches/{first_batch['id']}").status_code == 200
|
||||
assert image_file.read_bytes() == b"shared image"
|
||||
assert storage.get_session(UUID(other["session_id"])) is not None
|
||||
assert client.delete(f"/api/automation/batches/{second_batch['id']}").status_code == 200
|
||||
assert not image_file.exists()
|
||||
|
||||
|
||||
def test_restart_never_repeats_started_work(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
@@ -260,6 +330,41 @@ async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||
assert "disk full" in saved["report_error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_timeout_stays_visible_in_job_report_and_history(client, isolated):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
isolated.save_job(
|
||||
{**job, "status": "running", "timed_out": True, "error": "任务执行超时(30 秒)"}
|
||||
)
|
||||
await service.on_terminal({"session_id": job["session_id"]}, "cancelled")
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "failed"
|
||||
assert "超时" in saved["error"]
|
||||
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||
history = client.get("/api/automation/history").json()["items"]
|
||||
assert any(
|
||||
item["session_id"] == job["session_id"] and "超时" in item["error"] for item in history
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
folder = reports.TRACES_PATH / job["session_id"]
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "stderr.log").write_text(
|
||||
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
await service.on_terminal({"session_id": job["session_id"]}, "failed")
|
||||
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "failed"
|
||||
assert "超时" in saved["error"]
|
||||
assert reports.session_report(job["session_id"])["status"] == "failed"
|
||||
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||
|
||||
|
||||
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
@@ -296,6 +401,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
|
||||
assert "abc123secret" not in reports.failure_reason(
|
||||
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
|
||||
)
|
||||
assert "超时" not in reports.failure_reason(folder, "failed", "LLMExhaustedError: 额度不足")
|
||||
paths = reports.export_session(job["session_id"])
|
||||
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
||||
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
|
||||
@@ -325,6 +431,7 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
client, isolated, tmp_path, monkeypatch
|
||||
):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}})
|
||||
evidence = tmp_path / "evidence"
|
||||
evidence.mkdir()
|
||||
first = evidence / "first.png"
|
||||
@@ -343,14 +450,16 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
assert response.status_code == 200
|
||||
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||
files = archive.namelist()
|
||||
assert all(name.startswith("1.1/") for name in files)
|
||||
assert any(name.endswith(".docx") for name in files)
|
||||
assert any(name.endswith(".xlsx") for name in files)
|
||||
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
|
||||
"步骤截图/0002_步骤0002_操作后.png"
|
||||
assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
|
||||
"1.1/步骤截图/0002_步骤0002_操作后.png"
|
||||
)
|
||||
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||
manifest = json.loads(archive.read("文件清单.json"))
|
||||
assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||
manifest = json.loads(archive.read("1.1/文件清单.json"))
|
||||
assert manifest["session_id"] == job["session_id"]
|
||||
assert "1.1-all.zip" in response.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||
|
||||
@@ -5,6 +5,14 @@ import pytest
|
||||
from apps.admin_console.routers import sessions as sessions_router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_jobs(monkeypatch):
|
||||
mocked = MagicMock()
|
||||
mocked.jobs_for_sessions.return_value = {}
|
||||
monkeypatch.setattr(sessions_router, "store", mocked)
|
||||
return mocked
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
||||
repo = MagicMock()
|
||||
@@ -96,3 +104,42 @@ async def test_list_sessions_extracts_device_serial(monkeypatch):
|
||||
assert result[0]["device_id"] == "63191FDKX00062"
|
||||
assert result[1]["device_serial"] == "emulator-5554"
|
||||
assert result[1]["device_id"] == "emulator-5554"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_uses_batch_title_and_timeout_error(monkeypatch, isolate_jobs):
|
||||
repo = MagicMock()
|
||||
repo.get_all_sessions.return_value = [
|
||||
{
|
||||
"session_id": "session-case",
|
||||
"status": "completed",
|
||||
"start_time": 1.0,
|
||||
"initial_goal": "打开设置并检查页面",
|
||||
"device_info": '{"profile":"flash"}',
|
||||
}
|
||||
]
|
||||
repo.get_video_recordings_map.return_value = {}
|
||||
repo.get_latest_video_recordings_map.return_value = {}
|
||||
repo.get_agent_trace_names_map.return_value = {}
|
||||
monkeypatch.setattr(sessions_router, "session_repo", repo)
|
||||
monkeypatch.setattr(
|
||||
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
|
||||
)
|
||||
isolate_jobs.jobs_for_sessions.return_value = {
|
||||
"session-case": {
|
||||
"session_id": "session-case",
|
||||
"status": "failed",
|
||||
"ended_at": 65.0,
|
||||
"error": "任务执行超时(60 秒)",
|
||||
"case": {"case_id": "1.1", "name": "打开并核对设置", "goal": "打开设置并检查页面"},
|
||||
}
|
||||
}
|
||||
|
||||
result = await sessions_router.list_sessions()
|
||||
|
||||
isolate_jobs.jobs_for_sessions.assert_called_once_with(["session-case"])
|
||||
assert result[0]["display_name"] == "1.1 打开并核对设置"
|
||||
assert result[0]["initial_goal"] == "打开设置并检查页面"
|
||||
assert result[0]["status"] == "failed"
|
||||
assert result[0]["end_time"] == 65.0
|
||||
assert "超时" in result[0]["error"]
|
||||
Reference in new issue
Block a user