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):
|
||||
|
||||
Reference in new issue
Block a user