feat: add prompt-first batches and pausable task guidance
This commit is contained in:
1 parent
cec9898e4c
commit
057f0c3050
44 files changed
+1080
-191
No files matched your search
@@ -64,9 +64,9 @@ def workbook_bytes(rows):
|
||||
def test_template_preview_rejects_formula_and_bad_json():
|
||||
data = workbook_bytes(
|
||||
[
|
||||
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
|
||||
["公式", "=SUM(1,2)"],
|
||||
["参数错误", "测试", "", "", "", "{"],
|
||||
["正常", "设置", "冒烟", "打开设置", "", "观察设置", '{"profile":"flash"}'],
|
||||
["公式", "", "", "", "", "=SUM(1,2)"],
|
||||
["参数错误", "", "", "", "", "测试", "{"],
|
||||
]
|
||||
)
|
||||
result = task_import.parse_workbook(data)
|
||||
@@ -75,6 +75,30 @@ def test_template_preview_rejects_formula_and_bad_json():
|
||||
assert not result["valid"]
|
||||
|
||||
|
||||
def test_prompt_only_and_per_row_config_validation():
|
||||
goal = "先查看屏幕,再按实际画面判断。"
|
||||
data = workbook_bytes(
|
||||
[
|
||||
[
|
||||
"CASE-7",
|
||||
"检查项目",
|
||||
"子项",
|
||||
"用例名称",
|
||||
"仅作索引的约束",
|
||||
goal,
|
||||
'{"profile":"pro","model":"mimo-v2.6-flash","record_video":true}',
|
||||
],
|
||||
["CASE-8", "", "", "", "", "观察屏幕", '{"OPENAI_API_KEY":"never"}'],
|
||||
]
|
||||
)
|
||||
preview = task_import.parse_workbook(data)
|
||||
assert [e["row"] for e in preview["errors"]] == [3]
|
||||
case = preview["tasks"][0]
|
||||
assert task_import.case_goal(case) == goal
|
||||
assert case["preconditions"] == "仅作索引的约束"
|
||||
assert case["task_config"]["record_video"] is True
|
||||
|
||||
|
||||
def test_invalid_archive_and_empty_template():
|
||||
with pytest.raises(ValueError):
|
||||
task_import.parse_workbook(b"not a zip")
|
||||
@@ -84,7 +108,9 @@ def test_invalid_archive_and_empty_template():
|
||||
def test_preview_does_not_enqueue(client, isolated):
|
||||
response = client.post(
|
||||
"/api/automation/import/preview",
|
||||
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
|
||||
files={
|
||||
"file": ("tasks.xlsx", workbook_bytes([["案例", "", "", "观察页面", "", "观察页面"]]))
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"]
|
||||
@@ -137,6 +163,30 @@ async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch
|
||||
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paused_case_yields_batch_slot_to_next_case(isolated, monkeypatch):
|
||||
from apps.admin_console import server
|
||||
from artemis.runtime.device_pool import DeviceStatus
|
||||
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
first = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**first, "status": "task_paused", "started_at": time.time()})
|
||||
monkeypatch.setattr(
|
||||
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||
)
|
||||
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_pool.try_list_devices_async",
|
||||
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||
)
|
||||
await service.tick_batches()
|
||||
assert enqueue.await_count == 1
|
||||
assert enqueue.call_args.args[0] == ["观察首页"]
|
||||
assert [job["status"] for job in isolated.jobs(batch["id"])] == ["task_paused", "queued"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
@@ -193,6 +243,38 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||
assert "未验证".encode() in doc.read("word/document.xml")
|
||||
|
||||
|
||||
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
client, isolated, tmp_path, monkeypatch
|
||||
):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
evidence = tmp_path / "evidence"
|
||||
evidence.mkdir()
|
||||
first = evidence / "first.png"
|
||||
second = evidence / "second.png"
|
||||
video = evidence / "recording.mp4"
|
||||
for path in (first, second, video):
|
||||
path.write_bytes(path.name.encode())
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
"session_screenshots",
|
||||
lambda report: [(first, "0001_步骤0001_操作前.png"), (second, "0002_步骤0002_操作后.png")],
|
||||
)
|
||||
monkeypatch.setattr(reports, "session_videos", lambda report: [(video, "0001_recording.mp4")])
|
||||
|
||||
response = client.get(f"/api/automation/reports/{job['session_id']}/all")
|
||||
assert response.status_code == 200
|
||||
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||
files = archive.namelist()
|
||||
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 archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||
manifest = json.loads(archive.read("文件清单.json"))
|
||||
assert manifest["session_id"] == job["session_id"]
|
||||
|
||||
|
||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
|
||||
@@ -159,7 +159,7 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
|
||||
):
|
||||
await on_shutdown()
|
||||
|
||||
update_status.assert_called_once_with("ui-running", "cancelled")
|
||||
update_status.assert_called_once_with("ui-running", "interrupted")
|
||||
assert state.queue_items == []
|
||||
state.active_session_id = None
|
||||
state.is_shutting_down = False
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
|
||||
assert repository.cleanup_orphans_on_startup() == 1
|
||||
|
||||
assert repository.get_session_status("live") == "running"
|
||||
assert repository.get_session_status("dead") == "failed"
|
||||
assert repository.get_session_status("dead") == "interrupted"
|
||||
|
||||
|
||||
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
||||
|
||||
@@ -987,6 +987,7 @@ async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run():
|
||||
on different devices: device A's task is stopped manually, device B's task
|
||||
then finishes normally and must still be reported as completed.
|
||||
"""
|
||||
state.is_shutting_down = False
|
||||
ended_payloads: dict[str, dict] = {}
|
||||
|
||||
def capture(event_type, data):
|
||||
|
||||
@@ -279,6 +279,45 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
assert "not found" in res["message"]
|
||||
|
||||
|
||||
def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch):
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import artemis.runtime.task_control as control
|
||||
|
||||
monkeypatch.setattr(control, "TRACES_PATH", Path(temp_trace_env))
|
||||
monkeypatch.setattr("mcp_server.tools.task_manager._pid_alive", lambda pid: True)
|
||||
trace_id = str(uuid.uuid4())
|
||||
db = Path(temp_trace_env) / "data_engine.db"
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
|
||||
"start_time REAL, end_time REAL, device_info TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions VALUES (?, 'running', 17, 1, NULL, ?)",
|
||||
(trace_id, '{"device_id":"test-device"}'),
|
||||
)
|
||||
|
||||
assert (
|
||||
mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
|
||||
)
|
||||
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
|
||||
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
|
||||
control._write_atomic(
|
||||
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
|
||||
)
|
||||
guided = mobile_manage_task(
|
||||
action="inject_instruction", trace_id=trace_id, instruction="先确认当前画面"
|
||||
)
|
||||
assert guided["status"] == "task_paused"
|
||||
assert (Path(temp_trace_env) / trace_id / "resume.request").exists()
|
||||
assert (
|
||||
control.consume_instructions(control.control_dir(trace_id))["instruction"]
|
||||
== "先确认当前画面"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Paused tasks yield their device and resume with a fresh lease."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import artemis.runtime.task_control as task_control
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
events = []
|
||||
|
||||
class Lock:
|
||||
def __init__(self, device_id, **kwargs):
|
||||
self.device_id = device_id
|
||||
self.description = kwargs.get("description", "test")
|
||||
self.max_concurrency = kwargs.get("max_concurrency")
|
||||
self.concurrency_mode = kwargs.get("concurrency_mode", "per_device")
|
||||
self.ingress = kwargs.get("ingress", "web")
|
||||
self.lock_scope = kwargs.get("lock_scope", "test")
|
||||
|
||||
def release(self):
|
||||
events.append("released")
|
||||
|
||||
def acquire(self, cancel_event=None):
|
||||
events.append("acquired")
|
||||
|
||||
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
||||
original = Lock("test-device")
|
||||
control = task_control.TaskControl("task-123", original)
|
||||
task_control.request_pause("task-123")
|
||||
waiter = asyncio.create_task(
|
||||
control.checkpoint(
|
||||
on_pause=lambda: _event("capture_stopped", events),
|
||||
on_resume=lambda: _event("capture_started", events),
|
||||
)
|
||||
)
|
||||
for _ in range(30):
|
||||
if task_control.read_state("task-123").get("status") == "task_paused":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert task_control.read_state("task-123")["status"] == "task_paused"
|
||||
assert events == ["capture_stopped", "released"]
|
||||
task_control.request_resume("task-123")
|
||||
assert await asyncio.wait_for(waiter, 2) is True
|
||||
assert events == ["capture_stopped", "released", "acquired", "capture_started"]
|
||||
control.release()
|
||||
assert task_control.read_state("task-123") == {}
|
||||
|
||||
|
||||
async def _event(name, events):
|
||||
events.append(name)
|
||||
@@ -58,6 +58,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
|
||||
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
||||
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
||||
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
||||
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
|
||||
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
|
||||
Remove-Item Env:npm_config_registry
|
||||
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
||||
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
||||
@@ -69,6 +71,13 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_scrcpy_archive_is_checked_before_extraction():
|
||||
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
|
||||
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
|
||||
"Remove-Item $extractDir -Recurse"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrupt", [False, True])
|
||||
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
||||
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
||||
|
||||
Reference in new issue
Block a user