feat(plugins): add source-aware evidence and recording timelines
This commit is contained in:
1 parent
72ff342605
commit
ef7af9988e
61 files changed
+3847
-108
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user