feat(plugins): add source-aware evidence and recording timelines

This commit is contained in:
chenmingxuan committed 2026-10-16 10:20:11 +08:00
1 parent 72ff342605
commit ef7af9988e
61 files changed
+3847 -108

No files matched your search

+2 -1
View File
@@ -583,7 +583,8 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
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 len(z.namelist()) == 8 # summaries + two reports and evidence index per task
assert len([name for name in z.namelist() if name.endswith("证据索引.json")]) == 2
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
@@ -0,0 +1,82 @@
import json
from unittest.mock import MagicMock
from apps.admin_console.routers import media
from apps.admin_console.services import evidence_service as evidence
from apps.admin_console.services import report_export as exports
def fixture_files(tmp_path, monkeypatch):
monkeypatch.setattr(evidence, "TRACES_PATH", tmp_path)
root = tmp_path / "task" / "evidence" / "recordings"
root.mkdir(parents=True)
video = root / "clip.mp4"
video.write_bytes(b"fixture video")
(root / "recordings.json").write_text(
json.dumps(
{
"version": 1,
"sources": [
{
"id": "pc",
"kind": "computer",
"target_ids": ["window"],
"status": "ready",
"segments": [
{
"file": "evidence/recordings/clip.mp4",
"offset_ms": 5000,
"duration": 2,
}
],
}
],
}
),
"utf-8",
)
return video
def test_plugin_media_preserves_session_offsets_and_exports_relative_files(tmp_path, monkeypatch):
fixture_files(tmp_path, monkeypatch)
monkeypatch.setattr(exports, "session_screenshots", lambda _: [])
monkeypatch.setattr(exports, "session_artifacts", lambda _: [])
source = evidence.plugin_sources("task")[0]
assert source["segments"][0]["start"] == 5
index = evidence.export_index(
"task", {"name": "case", "status": "completed", "verdict": "未验证"}, []
)
clip = index["recordings"][0]["segments"][0]
assert clip["file"] == "任务录屏/电脑/clip.mp4" and "url" not in clip
assert clip["file"] in {item["path"] for item in index["files"]}
def test_legacy_url_only_does_not_enable_strict_timeline(monkeypatch):
monkeypatch.setattr(
media,
"_get_legacy_session_video_sync",
lambda _: {"status": "ready", "video_url": "/videos/old.mp4", "video_segments": []},
)
monkeypatch.setattr(evidence, "plugin_sources", lambda _: [])
monkeypatch.setattr(media, "session_repo", MagicMock(get_session_by_id=lambda _: {}))
response = media._get_session_video_sync("task")
assert response["session_timeline"] is False
assert response["video_url"] == "/videos/old.mp4"
def test_computer_only_recording_can_be_downloaded(tmp_path, monkeypatch):
import zipfile
fixture_files(tmp_path, monkeypatch)
monkeypatch.setattr(exports, "EXPORT_ROOT", tmp_path / "exports")
monkeypatch.setattr(
exports,
"session_report",
lambda _: {"id": "task", "name": "case", "status": "completed", "verdict": "未验证"},
)
monkeypatch.setattr(exports, "session_videos", lambda _: [])
with zipfile.ZipFile(exports.export_session_media("task", "video")) as archive:
index = json.loads(archive.read("文件清单.json"))
assert index["videos"] == ["任务录屏/电脑/clip.mp4"]
assert archive.getinfo(index["videos"][0]).compress_type == zipfile.ZIP_STORED
+4 -2
View File
@@ -37,8 +37,9 @@ def test_platform_singleton():
assert p.process.path_separator in (":", ";")
def test_linux_platform_paths():
def test_linux_platform_paths(monkeypatch):
"""Verify Linux platform paths conform to XDG Base Directory specification."""
monkeypatch.delenv("ARTEMIS_APP_DIR", raising=False)
with patch.dict(
"os.environ",
{"XDG_CONFIG_HOME": "/tmp/test_xdg_config", "XDG_DATA_HOME": "/tmp/test_xdg_data"},
@@ -61,8 +62,9 @@ def test_darwin_platform_paths():
assert darwin_p.process.path_separator == ":"
def test_windows_platform_paths():
def test_windows_platform_paths(monkeypatch):
"""Verify Windows platform paths adhere to AppData standards."""
monkeypatch.delenv("ARTEMIS_APP_DIR", raising=False)
with patch.dict(
"os.environ",
{
+396
View File
@@ -0,0 +1,396 @@
"""Plugin contracts and evidence persistence, without devices or external services."""
import base64
import hashlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
from mcp.types import CallToolResult, ImageContent, TextContent, Tool, ToolAnnotations
import pytest
from artemis.plugins.evidence import (
decode_receipt,
persist_attachments,
persist_receipt,
source_identity,
validate_tools,
)
from artemis.plugins.models import EvidenceSource, PluginCallResult, PluginManifest
from artemis.plugins.runtime import PluginRuntime
def manifest(**updates):
return PluginManifest.model_validate(
{
"schema_version": 2,
"id": "visual-test",
"name": "Visual fixture",
"version": "1.0.0",
"skill": "SKILL.md",
"observation": {"tool": "observe", "refresh_tools": ["observe"]},
"capabilities": {
"kind": "visual",
"recording": "windows",
"tools": {
"observe": {
"effect": "read",
"observation": True,
"target_argument": "app",
}
},
"required_tools": ["observe"],
},
**updates,
}
)
def observation_tool(*, readonly=True, properties=None):
return Tool(
name="observe",
inputSchema={
"type": "object",
"properties": {"app": {}} if properties is None else properties,
},
annotations=ToolAnnotations(readOnlyHint=readonly),
)
def attachment(data=b"verified evidence", name="capture.png"):
return {
"name": name,
"data": base64.b64encode(data).decode(),
"sha256": hashlib.sha256(data).hexdigest(),
"media_type": "image/png",
}
def test_schema_one_stays_compatible_but_schema_two_requires_contract():
legacy = manifest(schema_version=1, capabilities=None)
validate_tools(legacy, [observation_tool(readonly=False)])
with pytest.raises(ValueError, match="capabilities"):
manifest(capabilities=None)
with pytest.raises(ValueError, match="schema_version=2"):
manifest(schema_version=1)
@pytest.mark.parametrize("effect,observation", [("write", True), ("read", False)])
def test_auto_observation_requires_readonly_contract(effect, observation):
with pytest.raises(ValueError, match="只读"):
manifest(
capabilities={
"kind": "visual",
"tools": {"observe": {"effect": effect, "observation": observation}},
}
)
def test_nonvisual_plugin_cannot_request_screen_recording():
with pytest.raises(ValueError, match="视觉插件"):
manifest(observation=None, capabilities={"kind": "api", "recording": "windows"})
def test_manifest_is_checked_against_actual_mcp_tools():
validate_tools(manifest(), [observation_tool()])
with pytest.raises(ValueError, match="只读声明"):
validate_tools(manifest(), [observation_tool(readonly=False)])
with pytest.raises(ValueError, match="缺少目标参数"):
validate_tools(manifest(), [observation_tool(properties={})])
with pytest.raises(ValueError, match="缺少:observe"):
validate_tools(manifest(), [])
with pytest.raises(ValueError, match="未声明:click"):
validate_tools(manifest(), [observation_tool(), Tool(name="click", inputSchema={})])
def test_source_identity_invalidates_geometry_but_not_title_or_capture_time():
source = EvidenceSource(
kind="computer",
target_id="123",
label="before",
captured_at=1,
width=800,
height=600,
window={"left": 0, "top": 0, "width": 800, "height": 600},
)
identity = source_identity("visual-test", source)
assert identity == source_identity(
"visual-test", source.model_copy(update={"label": "after", "captured_at": 2})
)
for change in (
{"target_id": "456"},
{"width": 400},
{"window": {"left": 100, "top": 0, "width": 800, "height": 600}},
):
assert identity != source_identity("visual-test", source.model_copy(update=change))
assert identity != source_identity("another-plugin", source)
@pytest.mark.parametrize(
"execution,retryable,observe",
[
("unknown", False, True),
("partial", False, True),
("completed", False, False),
("not_started", True, False),
],
)
def test_uncertain_writes_cannot_claim_to_be_retryable(execution, retryable, observe):
result = PluginCallResult(
status="error",
structured_content={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "possible",
"retryable": True,
"error_code": "remote_failure",
}
},
)
decode_receipt(result)
assert result.execution == execution
assert result.retryable is retryable
assert result.requires_observation is observe
assert result.error_code == "remote_failure"
@pytest.mark.parametrize("raw", ["invalid", {"version": 99}])
def test_rejects_unsupported_receipt_shapes(raw):
with pytest.raises(ValueError):
decode_receipt(PluginCallResult(status="success", structured_content={"artemis": raw}))
def test_attachments_are_read_back_and_never_overwrite_existing_evidence(tmp_path):
receipt = attachment()
saved = persist_attachments(tmp_path, "visual-test", [receipt])[0]
path = tmp_path / saved["path"]
assert path.read_bytes() == b"verified evidence"
assert saved["sha256"] == receipt["sha256"]
assert saved["bytes"] == len(b"verified evidence")
assert (
persist_attachments(
tmp_path, "visual-test", [{k: v for k, v in receipt.items() if k != "data"}]
)[0]
== saved
)
with pytest.raises(ValueError, match="不能覆盖"):
persist_attachments(tmp_path, "visual-test", [attachment(b"different")])
assert path.read_bytes() == b"verified evidence"
def test_invalid_hash_does_not_publish_attachment(tmp_path):
receipt = attachment()
receipt["sha256"] = "0" * 64
with pytest.raises(ValueError, match="校验失败"):
persist_attachments(tmp_path, "visual-test", [receipt])
assert not (tmp_path / "artifacts/plugins/visual-test/capture.png").exists()
@pytest.mark.parametrize(
"name",
[
"../outside.png",
"..\\outside.png",
"C:\\outside.png",
"file.png:stream",
"CON.png",
"LPT1",
"file.",
"file ",
"bad\nname",
],
)
def test_attachment_names_cannot_escape_or_alias_windows_files(tmp_path, name):
with pytest.raises(ValueError, match="普通文件名"):
persist_attachments(tmp_path, "visual-test", [attachment(name=name)])
def test_receipt_index_has_references_instead_of_binary_content(tmp_path):
result = PluginCallResult(
status="success",
call_id="call-1",
image_names=["image-1"],
content=[{"type": "image", "data": "large-binary"}],
structured_content={"artemis": {"large": "payload"}},
)
persist_receipt(
SimpleNamespace(data_engine=SimpleNamespace(base_dir=tmp_path)),
result,
{"app": "fixture"},
123,
)
receipt = json.loads((tmp_path / "evidence/calls.jsonl").read_text(encoding="utf-8"))
assert receipt["call_id"] == "call-1"
assert receipt["image_names"] == ["image-1"]
assert receipt["started_at"] == 123
assert "content" not in receipt
assert "structured_content" not in receipt
@pytest.mark.asyncio
async def test_receipt_disk_failure_preserves_successful_write_result(monkeypatch):
runtime = PluginRuntime.__new__(PluginRuntime)
runtime.ctx = None
runtime.bindings = {
"write": (
"visual-test",
SimpleNamespace(name="write", inputSchema={"type": "object"}, annotations=None),
)
}
runtime.packages = {
"visual-test": (
SimpleNamespace(capabilities=None, observation=None, version="1.0.0"),
SimpleNamespace(argument_constraints={}, secrets={}),
None,
)
}
session = SimpleNamespace(
call=AsyncMock(
return_value=CallToolResult(
content=[TextContent(type="text", text="changed")], isError=False
)
),
stop_uncertain=False,
)
runtime.sessions = {"visual-test": session}
runtime.ensure_resources_ready = lambda: None
runtime._image_targets = {}
runtime.last_images = {}
runtime._observation_calls = {}
def unavailable(*_args, **_kwargs):
raise OSError("disk full")
monkeypatch.setattr("artemis.plugins.runtime.persist_receipt", unavailable)
outcome = await runtime.call("write", {})
session.call.assert_awaited_once()
assert outcome.status == "success"
assert outcome.execution == "completed"
assert outcome.side_effect != "none"
@pytest.mark.asyncio
async def test_v2_observation_without_task_context_never_starts_recording(monkeypatch):
runtime = PluginRuntime.__new__(PluginRuntime)
runtime.ctx = None
runtime.bindings = {"observe": ("visual-test", observation_tool())}
runtime.packages = {
"visual-test": (
manifest(),
SimpleNamespace(argument_constraints={}, secrets={}),
None,
)
}
runtime.sessions = {
"visual-test": SimpleNamespace(
call=AsyncMock(
return_value=CallToolResult(
content=[ImageContent(type="image", mimeType="image/png", data="aW1hZ2U=")],
structuredContent={
"artemis": {
"version": 1,
"execution": "completed",
"source": {"kind": "computer", "target_id": "123"},
}
},
)
),
stop_uncertain=False,
)
}
runtime.ensure_resources_ready = lambda: None
runtime._image_targets = {}
runtime.last_images = {}
runtime._observation_calls = {}
runtime._recordings = None
def unexpected_recorder(*_args):
pytest.fail("Recording requires an owning task trace")
monkeypatch.setattr("artemis.plugins.recording.PluginRecordings", unexpected_recorder)
outcome = await runtime.call("observe", {"app": "fixture"})
assert outcome.status == "success"
assert outcome.source.target_id == "123"
assert outcome.source_id.startswith("plugin:visual-test:")
assert runtime.last_images["visual-test"] == b"image"
assert runtime._recordings is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"target,pre_valid,cleared",
[
("computer", False, True),
("computer", True, False),
("android", False, False),
("mixed", False, False),
],
)
async def test_pro_cross_window_evidence_does_not_reuse_another_source_before_image(
tmp_path, monkeypatch, target, pre_valid, cleared
):
from artemis.agents.validator import execution_loop
from artemis.data_engine.engine import DataEngine
from artemis.data_engine.models import ImageRecord, SessionMetadata, StepRecord
from artemis.data_engine.storage import StorageManager
from apps.admin_console.services import report_export
storage = StorageManager(tmp_path / "evidence.db", tmp_path)
session_id, step_id = uuid4(), uuid4()
storage.create_session(SessionMetadata(session_id=session_id, initial_goal="fixture"))
storage.create_image(ImageRecord(image_name="window-a"))
storage.create_image(ImageRecord(image_name="window-b"))
storage.create_step(
StepRecord(step_id=step_id, session_id=session_id, step_number=1, pre_image_name="window-a")
)
engine = DataEngine.__new__(DataEngine)
engine.storage = storage
engine._run_in_background = lambda fn, *args: fn(*args)
engine.get_step_number = lambda _step_id: 1
engine._get_generic_tools_for_sse = lambda _step_id: []
engine._publish = Mock()
engine.record_trace = Mock()
engine.get_relative_time = lambda _timestamp: "0s"
ctx = SimpleNamespace(execution_target=target, data_engine=engine)
node = SimpleNamespace(
ctx=ctx,
_parse_decisions=lambda _: ([{"action": "plugin-click"}], None),
_get_mcp_session=AsyncMock(),
_get_initial_screenshot=AsyncMock(return_value=("old-screen", "window-a")),
)
state = SimpleNamespace(
structured_decisions=None, current_step_id=str(step_id), open_incident=None
)
outcome = execution_loop._ActionOutcome(
action_item={
"action": "plugin-click",
"plugin_id": "visual-test",
"source_id": "window-b",
"post_image_name": "window-b",
"pre_image_valid": pre_valid,
},
success=True,
post_screenshot_b64="new-screen",
)
monkeypatch.setattr(execution_loop, "_process_action", AsyncMock(return_value=outcome))
monkeypatch.setattr(execution_loop, "_absorb_post_screenshot", lambda *_args: "window-b")
await execution_loop.run_validation_loop(node, state)
step = storage.get_steps(session_id)[0].model_dump()
assert (step["pre_image_name"] is None) is cleared
if cleared:
assert step["post_image_name"] == "window-b"
assert engine._publish.call_args.args[1]["pre_image_name"] is None
before = tmp_path / "window-a.png"
after = tmp_path / "window-b.png"
before.write_bytes(b"a")
after.write_bytes(b"b")
monkeypatch.setattr(
report_export,
"image_path",
lambda name: {"window-a": before, "window-b": after}.get(name),
)
exported = report_export.session_screenshots({"steps": [step]})
assert [path for path, _name in exported] == [after]
assert all("操作前" not in name for _path, name in exported)
+391
View File
@@ -0,0 +1,391 @@
"""Recorder lifecycle and archive limits use fake processes, never a live desktop."""
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from artemis.config.agent import RecordingConfig
from artemis.plugins.models import EvidenceSource
from artemis.plugins import recording
from artemis.plugins.recording import PluginRecordings, build_capture_command, capture_rectangle
def source(**updates):
return EvidenceSource(
**{
"kind": "computer",
"target_id": "123",
"label": "Fixture window",
"width": 800,
"height": 600,
"window": {"left": 40, "top": 50, "width": 800, "height": 600},
"screen": {"left": -1920, "top": 0, "width": 1920, "height": 1080},
**updates,
}
)
def context(root, *, enabled=True, **config):
return SimpleNamespace(
data_engine=SimpleNamespace(base_dir=root, session_start_time=100),
agent_config=SimpleNamespace(recording=RecordingConfig(**config)),
execution_setup=SimpleNamespace(
record_video_enabled=enabled, video_recording_tools_enabled=False
),
)
class FakeRecorderProcess:
def __init__(self, *, produces_frames=True):
self.pid = 123456
self.returncode = None if produces_frames else 1
self.stdout = asyncio.StreamReader()
if produces_frames:
self.stdout.feed_data(b"frame=1\n")
self.stdout.feed_eof()
self.stdin = SimpleNamespace(write=self._quit, drain=AsyncMock())
self.killed = False
def _quit(self, _data):
self.returncode = 0
async def wait(self):
return self.returncode
def kill(self):
self.killed = True
self.returncode = -9
@pytest.fixture
def fake_capture(monkeypatch):
state = SimpleNamespace(commands=[], processes=[], fail_hevc=False)
async def spawn(*command, **_kwargs):
state.commands.append(command)
encoder = command[command.index("-c:v") + 1]
process = FakeRecorderProcess(
produces_frames=not (state.fail_hevc and encoder.startswith("hevc"))
)
state.processes.append(process)
Path(command[-1]).write_bytes(b"fake mp4")
return process
local_asyncio = SimpleNamespace(**vars(asyncio))
local_asyncio.create_subprocess_exec = spawn
monkeypatch.setattr(recording, "asyncio", local_asyncio)
monkeypatch.setattr(recording, "os", SimpleNamespace(name="nt", getpid=lambda: 123))
monkeypatch.setattr(
recording,
"CaptureProcessJob",
lambda: SimpleNamespace(assign=lambda _pid: None, close=lambda: None),
)
monkeypatch.setattr(
recording, "shutil", SimpleNamespace(disk_usage=lambda _path: SimpleNamespace(free=10**10))
)
monkeypatch.setattr(recording, "time", SimpleNamespace(time=lambda: 123.0))
monkeypatch.setattr(recording, "get_ffmpeg_path", lambda: "fake-ffmpeg")
monkeypatch.setattr(
recording,
"probe_video_segment",
AsyncMock(return_value={"duration": 2.5, "width": 1280, "height": 720}),
)
return state
def argument(command, option):
return command[command.index(option) + 1]
def test_archive_command_keeps_physical_capture_and_limits_encoded_output(tmp_path):
command = build_capture_command(
"ffmpeg", source(), RecordingConfig(), tmp_path / "video.mp4", "libx264"
)
assert argument(command, "-offset_x") == "-1920"
assert argument(command, "-video_size") == "1920x1080"
assert argument(command, "-vf") == "scale=1280:720"
assert argument(command, "-framerate") == "15"
assert argument(command, "-b:v") == "1500k"
assert argument(command, "-g") == "30"
assert "-an" in command
def test_small_window_is_not_upscaled_and_hwnd_capture_is_explicit(tmp_path):
command = build_capture_command(
"ffmpeg",
source(),
RecordingConfig(computer_scope="window", codec="h265"),
tmp_path / "video.mp4",
"hevc_nvenc",
)
assert argument(command, "-i") == "hwnd=123"
assert argument(command, "-vf") == "scale=800:600"
assert argument(command, "-b:v") == "1000k"
assert argument(command, "-tag:v") == "hvc1"
assert "-offset_x" not in command
@pytest.mark.parametrize(
"area",
[
None,
{"left": 0},
{"left": 0, "top": 0, "width": 0, "height": 600},
{"left": 65537, "top": 0, "width": 800, "height": 600},
],
)
def test_invalid_capture_geometry_is_not_sent_to_ffmpeg(area):
with pytest.raises(ValueError):
capture_rectangle(source(screen=area), "screen")
def test_window_recording_rejects_non_hwnd_targets(tmp_path):
with pytest.raises(ValueError, match="HWND"):
build_capture_command(
"ffmpeg",
source(target_id="window-title"),
RecordingConfig(computer_scope="window"),
tmp_path / "video.mp4",
"libx264",
)
@pytest.mark.asyncio
async def test_disabled_recording_does_not_spawn_or_create_evidence(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path, enabled=False))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert fake_capture.commands == []
assert not (tmp_path / "evidence").exists()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"codec,hevc_playback,expected_attempts",
[("auto", False, 1), ("auto", True, 4), ("h265", False, 4)],
)
async def test_hardware_hevc_failure_falls_back_to_compatible_archive(
tmp_path, fake_capture, codec, hevc_playback, expected_attempts
):
fake_capture.fail_hevc = True
recorder = PluginRecordings(context(tmp_path, codec=codec, hevc_playback=hevc_playback))
try:
await recorder.observe("source-1", "visual-test", source())
assert len(fake_capture.commands) == expected_attempts
assert argument(fake_capture.commands[-1], "-c:v") == "libx264"
assert len(recorder.active) == 1
finally:
await recorder.stop()
track = next(iter(recorder.sources.values()))
assert track["status"] == "ready"
assert len(track["segments"]) == 1
assert track["segments"][0]["codec"] == "h264"
assert track["segments"][0]["duration_ms"] == 2500
assert all(process.returncode is not None for process in fake_capture.processes)
@pytest.mark.asyncio
async def test_same_screen_reuses_recording_and_keeps_both_target_ids(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path))
try:
await recorder.observe("window-1", "visual-test", source())
await recorder.observe("window-2", "visual-test", source(target_id="456"))
assert len(fake_capture.commands) == 1
finally:
await recorder.stop()
assert next(iter(recorder.sources.values()))["target_ids"] == ["window-1", "window-2"]
@pytest.mark.asyncio
async def test_low_disk_space_records_failure_without_starting_capture(
tmp_path, fake_capture, monkeypatch
):
monkeypatch.setattr(recording.shutil, "disk_usage", lambda _path: SimpleNamespace(free=1))
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert fake_capture.commands == []
track = next(iter(recorder.sources.values()))
assert track["status"] == "failed"
assert "磁盘" in track["message"]
@pytest.mark.asyncio
async def test_index_write_failure_does_not_leave_capture_processes(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
def unavailable():
raise OSError("disk full")
monkeypatch.setattr(recorder, "_save", unavailable)
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
assert next(iter(recorder.sources.values()))["status"] == "failed"
@pytest.mark.asyncio
async def test_probe_failure_during_stop_is_recorded_and_capture_is_reaped(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
monkeypatch.setattr(
recording, "probe_video_segment", AsyncMock(side_effect=OSError("probe unavailable"))
)
await recorder.stop()
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
track = next(iter(recorder.sources.values()))
assert track["status"] == "failed"
assert "probe unavailable" in track["message"]
@pytest.mark.asyncio
async def test_stop_retries_process_cleanup_interrupted_in_watchdog(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
recorder._watchdog.cancel()
await asyncio.gather(recorder._watchdog, return_exceptions=True)
key = next(iter(recorder.active))
entered = asyncio.Event()
attempts = 0
terminate = recorder._terminate
async def interrupted_first(process):
nonlocal attempts
attempts += 1
if attempts == 1:
entered.set()
await asyncio.Future()
await terminate(process)
monkeypatch.setattr(recorder, "_terminate", interrupted_first)
recorder._watchdog = asyncio.create_task(recorder._finish(key))
await entered.wait()
await recorder.stop()
assert attempts == 2
assert not recorder.active
assert all(process.returncode is not None for process in fake_capture.processes)
@pytest.mark.asyncio
async def test_stop_preserves_clip_when_watchdog_is_cancelled_during_probe(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
recorder._watchdog.cancel()
await asyncio.gather(recorder._watchdog, return_exceptions=True)
key = next(iter(recorder.active))
entered = asyncio.Event()
attempts = 0
async def interrupted_probe(_output):
nonlocal attempts
attempts += 1
if attempts == 1:
entered.set()
await asyncio.Future()
return {"duration": 2.5, "width": 1280, "height": 720}
monkeypatch.setattr(recording, "probe_video_segment", interrupted_probe)
recorder._watchdog = asyncio.create_task(recorder._finish(key))
await entered.wait()
await recorder.stop()
assert attempts == 2
assert not recorder.active
track = recorder.sources[key]
assert track["status"] == "ready"
assert track["segments"][0]["duration_ms"] == 2500
@pytest.mark.asyncio
async def test_resumed_recorder_appends_and_does_not_overwrite_previous_clips(
tmp_path, fake_capture
):
first = PluginRecordings(context(tmp_path))
await first.observe("source-1", "visual-test", source())
await first.stop()
original = next(iter(first.sources.values()))["segments"][0].copy()
original_path = tmp_path / original["file"]
original_path.write_bytes(b"immutable original clip")
second = PluginRecordings(context(tmp_path))
try:
await second.observe("source-1", "visual-test", source())
finally:
await second.stop()
segments = next(iter(second.sources.values()))["segments"]
assert len(segments) == 2
assert segments[0] == original
assert segments[1]["file"] != original["file"]
assert original_path.read_bytes() == b"immutable original clip"
manifest = json.loads(
(tmp_path / "evidence/recordings/recordings.json").read_text(encoding="utf-8")
)
assert len(manifest["sources"][0]["segments"]) == 2
@pytest.mark.asyncio
async def test_resized_window_seals_old_geometry_before_new_capture(tmp_path, fake_capture):
recorder = PluginRecordings(context(tmp_path, computer_scope="window"))
try:
await recorder.observe("source-1", "visual-test", source())
await recorder.observe(
"source-2",
"visual-test",
source(window={"left": 40, "top": 50, "width": 1000, "height": 700}),
)
assert len(fake_capture.commands) == 2
assert fake_capture.processes[0].returncode is not None
assert len(recorder.active) == 1
finally:
await recorder.stop()
assert len(recorder.sources) == 2
assert all(track["status"] == "ready" for track in recorder.sources.values())
@pytest.mark.asyncio
async def test_dead_worker_recovery_keeps_partial_clip_and_does_not_restart(
tmp_path, fake_capture, monkeypatch
):
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
await recorder.stop()
track = next(iter(recorder.sources.values()))
track.update(status="recording", owner_pid=987654321, owner_created_at=1)
track["segments"][0].update(duration=0, duration_ms=0)
recorder._save()
count = len(fake_capture.commands)
assert await recording.recover_interrupted_recordings(tmp_path)
recovered = PluginRecordings(context(tmp_path))
result = next(iter(recovered.sources.values()))
assert result["status"] == "failed"
assert result["segments"][0]["duration"] == 2.5
assert len(fake_capture.commands) == count
@pytest.mark.asyncio
async def test_live_worker_recovery_never_finalizes_active_capture(tmp_path, fake_capture):
import os
import psutil
recorder = PluginRecordings(context(tmp_path))
await recorder.observe("source-1", "visual-test", source())
track = next(iter(recorder.sources.values()))
track.update(owner_pid=os.getpid(), owner_created_at=psutil.Process().create_time())
recorder._save()
try:
assert not await recording.recover_interrupted_recordings(tmp_path)
assert track["status"] == "recording"
finally:
await recorder.stop()
+1
View File
@@ -671,6 +671,7 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
bindings={name: ("test-plugin", None)},
tool_names={name},
last_images={"test-plugin": b"desktop-before"},
previous_image=lambda name, args: b"desktop-before",
)
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda ctx: plugins)
capture = AsyncMock(return_value=("phone-path", b"phone-after", "phone-tree-after"))
+79
View File
@@ -0,0 +1,79 @@
"""Per-task archive quality overrides outrank JSON and saved environment defaults."""
import json
import pytest
from apps.admin_console.services.task_import import TaskCase, TaskOptions
from artemis.config.agent import AgentGlobalConfig, load_agent_config
from artemis.config.settings import settings
@pytest.fixture(autouse=True)
def recording_defaults(monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_RECORDING_CONFIG", {"fps": 10, "h264_kbps": 900})
monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {})
def test_json_recording_defaults_override_saved_defaults(tmp_path):
config = tmp_path / "artemis.jsonc"
config.write_text(json.dumps({"agent": {"recording": {"fps": 20}}}), encoding="utf-8")
result = load_agent_config(config).recording
assert result.fps == 20
assert result.h264_kbps == 900
assert result.max_long_edge == 1280
def test_task_recording_override_has_highest_priority_and_is_partial(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 30})
config = tmp_path / "artemis.jsonc"
config.write_text(
json.dumps({"agent": {"recording": {"fps": 20, "max_long_edge": 1920}}}), encoding="utf-8"
)
result = load_agent_config(config).recording
assert result.fps == 30
assert result.max_long_edge == 1920
assert result.h264_kbps == 900
def test_task_recording_override_applies_without_json_recording_block(monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 30})
assert AgentGlobalConfig().recording.fps == 30
def test_invalid_task_recording_override_is_rejected(monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_TASK_RECORDING_CONFIG", {"fps": 500})
with pytest.raises(ValueError, match="fps"):
AgentGlobalConfig.model_validate({"recording": {"fps": 20}})
def test_task_and_batch_serialization_do_not_inject_unset_recording_defaults():
options = TaskOptions.model_validate({"recording": {"fps": 30}})
assert options.model_dump(exclude_none=True)["recording"] == {"fps": 30}
case = TaskCase(name="fixture", goal="fixture", task_config=options)
assert case.model_dump()["task_config"]["recording"] == {"fps": 30}
# Round trips through stored batches preserve the partial override.
restored = TaskCase.model_validate(case.model_dump())
assert restored.model_dump()["task_config"]["recording"] == {"fps": 30}
def test_worker_env_keeps_task_quality_separate_from_saved_defaults(monkeypatch):
from apps.admin_console.services.task_queue_service import TaskQueueService
from artemis.runtime.adb_endpoint import AdbEndpoint, AdbTarget
monkeypatch.setenv("ARTEMIS_RECORDING_CONFIG", '{"fps":10}')
monkeypatch.setenv("ARTEMIS_TASK_RECORDING_CONFIG", '{"fps":60}')
_, environment = TaskQueueService._build_worker_invocation(
{"runtime_overrides": {"recording": {"fps": 30}}},
"test-run",
None,
"fixture",
"flash",
AdbTarget(AdbEndpoint.local()),
)
assert json.loads(environment["ARTEMIS_RECORDING_CONFIG"]) == {"fps": 10}
assert json.loads(environment["ARTEMIS_TASK_RECORDING_CONFIG"]) == {"fps": 30}
_, clean_environment = TaskQueueService._build_worker_invocation(
{}, "test-run", None, "fixture", "flash", AdbTarget(AdbEndpoint.local())
)
assert "ARTEMIS_TASK_RECORDING_CONFIG" not in clean_environment
+42
View File
@@ -0,0 +1,42 @@
"""Exercise Windows ownership without recording a user screen or running FFmpeg."""
import os
from pathlib import Path
import subprocess
import sys
import time
import psutil
import pytest
@pytest.mark.skipif(os.name != "nt", reason="Windows capture process ownership")
def test_worker_exit_terminates_owned_capture_child():
project = str(Path(__file__).resolve().parents[2])
script = f"""
import os, subprocess, sys
sys.path.insert(0, {project!r})
from artemis.plugins.recording_process import CaptureProcessJob
job = CaptureProcessJob()
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)'],
creationflags=0x08000000, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
job.assign(child.pid)
print(child.pid, flush=True)
os._exit(0)
"""
parent = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=15,
creationflags=0x08000000,
check=True,
)
pid = int(parent.stdout.strip())
for _ in range(50):
if not psutil.pid_exists(pid):
return
time.sleep(0.1)
# Only the disposable child created above is eligible for cleanup.
psutil.Process(pid).kill()
pytest.fail("Owned capture child survived abrupt worker exit")
@@ -714,6 +714,32 @@ async def test_start_recording_anchors_timeline_to_first_frame(mock_ctx, tmp_pat
remove_active_session("emulator-5554")
@pytest.mark.asyncio
async def test_unconfirmed_recording_start_fails_and_closes_own_process(mock_ctx, tmp_path):
controller = UnifiedMobileController(mock_ctx)
remove_active_session("emulator-5554")
proc = _FakeScrcpyProcess([])
with (
patch.object(controller, "_spawn_scrcpy", AsyncMock(return_value=proc)),
patch.object(controller, "_stop_scrcpy", AsyncMock()) as stop,
patch(
"artemis.controllers.unified_controller.get_android_display_state",
AsyncMock(return_value=None),
),
patch(
"artemis.controllers.unified_controller.await_scrcpy_first_frame",
AsyncMock(side_effect=TimeoutError("no first frame")),
) as first,
):
result = await controller.start_video_recording(output_dir=tmp_path)
assert not result.success
assert "no first frame" in result.message
assert first.call_args.kwargs["require_marker"] is True
stop.assert_awaited()
assert get_active_session("emulator-5554") is None
mock_ctx.data_engine.record_video_failure.assert_called_once()
@pytest.mark.asyncio
async def test_next_segment_anchors_at_first_frame(mock_ctx, tmp_path):
controller = UnifiedMobileController(mock_ctx)
+206
View File
@@ -0,0 +1,206 @@
"""Reject corrupt recording timelines and recover only actually captured frames."""
import asyncio
import json
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.utils import video
def metadata(**updates):
return {
"streams": [
{
"width": 640,
"height": 360,
"duration": "1.0",
"start_time": "0",
"nb_frames": "15",
"avg_frame_rate": "15/1",
}
],
"format": {"duration": "1.0"},
"packets": [{"pts_time": "0.0", "flags": "K__"}, {"pts_time": "0.9", "flags": "___"}],
**updates,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"change",
["microsecond-duration", "pts-outside-duration", "nan-duration", "no-packets", "no-dimensions"],
)
async def test_probe_rejects_invalid_timeline_despite_valid_container_exit(monkeypatch, change):
payload = metadata()
stream = payload["streams"][0]
if change == "microsecond-duration":
stream.update(duration="0.000375", avg_frame_rate="56000/3", nb_frames="7")
elif change == "pts-outside-duration":
payload["packets"][-1]["pts_time"] = "3"
elif change == "nan-duration":
stream["duration"] = "NaN"
elif change == "no-packets":
payload["packets"] = []
else:
stream["width"] = 0
process = SimpleNamespace(
returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b""))
)
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
assert await video.probe_video_segment(Path("invalid.mp4")) == {}
@pytest.mark.asyncio
async def test_probe_accepts_vfr_timing_without_inventing_extra_duration(monkeypatch):
payload = metadata()
payload["streams"][0].update(duration="8", avg_frame_rate="1/4", nb_frames="2")
payload["packets"][-1]["pts_time"] = "7.9"
process = SimpleNamespace(
returncode=0, communicate=AsyncMock(return_value=(json.dumps(payload).encode(), b""))
)
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
assert (await video.probe_video_segment(Path("valid.mp4")))["duration"] == 8
@pytest.mark.asyncio
async def test_remux_never_publishes_invalid_output_and_preserves_source(tmp_path, monkeypatch):
source, output = tmp_path / "source.mkv", tmp_path / "recording.mp4"
source.write_bytes(b"original recording")
commands = []
async def spawn(*command, **_kwargs):
commands.append(command)
Path(command[-1]).write_bytes(b"nonempty but invalid")
return SimpleNamespace(returncode=0, communicate=AsyncMock(return_value=(b"", b"")))
monkeypatch.setattr(video.asyncio, "create_subprocess_exec", spawn)
monkeypatch.setattr(video, "probe_video_segment", AsyncMock(return_value={}))
assert not await video.remux_recording_to_mp4(source, output)
assert source.read_bytes() == b"original recording"
assert not output.exists()
assert not output.with_name("recording.part.mp4").exists()
assert len(commands) == 2
fallback = commands[1]
assert fallback[fallback.index("-fps_mode") + 1] == "passthrough"
assert "-r" not in fallback
assert "setpts=PTS-STARTPTS" in fallback
@pytest.mark.asyncio
async def test_bounded_media_wait_reaps_process_on_timeout():
async def stalled():
await asyncio.Future()
process = SimpleNamespace(
returncode=None, communicate=stalled, kill=Mock(), wait=AsyncMock(return_value=-9)
)
with pytest.raises(TimeoutError):
await video._communicate_video_process(process, 0.01)
process.kill.assert_called_once()
process.wait.assert_awaited_once()
@pytest.mark.asyncio
async def test_strict_scrcpy_start_rejects_eof_without_recording_marker():
reader = asyncio.StreamReader()
reader.feed_data(b"INFO: connection initialized\n")
reader.feed_eof()
process = SimpleNamespace(stdout=reader, wait=AsyncMock(return_value=0))
with pytest.raises(OSError, match="未确认开始录制"):
await video.await_scrcpy_first_frame(process, 100, require_marker=True)
@pytest.mark.asyncio
async def test_strict_scrcpy_start_rejects_silent_process_timeout():
process = SimpleNamespace(stdout=asyncio.StreamReader())
with pytest.raises(TimeoutError, match="启动时限"):
await video.await_scrcpy_first_frame(process, 100, timeout=0.01, require_marker=True)
@pytest.mark.asyncio
async def test_strict_scrcpy_start_accepts_actual_marker():
reader = asyncio.StreamReader()
reader.feed_data(b"INFO: Recording started to file: recording.mkv\n")
process = SimpleNamespace(stdout=reader)
assert await video.await_scrcpy_first_frame(process, 100, require_marker=True) >= 100
def run_media(*command):
return subprocess.run(command, check=True, capture_output=True, timeout=30).stdout
def frame_times(path):
payload = json.loads(
run_media(
video.get_ffprobe_path(),
"-v",
"error",
"-select_streams",
"v:0",
"-show_frames",
"-show_entries",
"frame=pts_time",
"-of",
"json",
str(path),
)
)
return [float(frame["pts_time"]) for frame in payload["frames"]]
@pytest.mark.asyncio
async def test_real_remux_recovers_pts_without_filling_missing_capture_time(tmp_path):
ffmpeg = video.get_ffmpeg_path()
clean = tmp_path / "clean.mkv"
corrupt = tmp_path / "corrupt.mp4"
restored = tmp_path / "restored.mp4"
run_media(
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc2=size=64x96:rate=10:duration=0.7",
"-c:v",
"libx264",
"-bf",
"0",
"-pix_fmt",
"yuv420p",
str(clean),
)
# Preserve original frame PTS while compressing decode timestamps to 1 ms.
run_media(
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(clean),
"-map",
"0:v:0",
"-c",
"copy",
"-bsf:v",
"setts=dts=N:duration=1",
str(corrupt),
)
assert await video.probe_video_segment(corrupt) == {}
captured = frame_times(corrupt)
assert await video.remux_recording_to_mp4(corrupt, restored)
result = await video.probe_video_segment(restored)
assert 0.4 <= result["duration"] <= 0.8
recovered = frame_times(restored)
assert recovered
assert len(recovered) <= len(captured)
assert recovered == pytest.approx(captured[: len(recovered)], abs=0.001)
assert corrupt.exists()