Files
Artemis-Eapil/tests/unit/test_exploration_demonstrations.py

428 lines
17 KiB
Python

import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from apps.admin_console.services.automation_store import AutomationStore
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.demonstrations import Demonstrations
from artemis.exploration.store import ExplorationConflict, ExplorationStore
from artemis.runtime import task_control
@pytest.fixture
def demo_session(tmp_path, monkeypatch):
repository = ExplorationStore(AutomationStore(tmp_path / "history.db"))
monkeypatch.setattr("artemis.exploration.runtime.explorations", repository)
monkeypatch.setattr("artemis.exploration.demo_runtime.explorations", repository)
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces")
session = repository.create("验证操作结果", ExplorationConfig(), {}, "create")
run = repository.reserve_run(
session["id"], "start", kind="exploration", goal="验证", task_config={}
)
repository.set_run_state(run["id"], "running")
return repository, session, run
@pytest.mark.parametrize(
"window_class", ["Chrome_WidgetWin_1", "Progman", "WorkerW", "Shell_TrayWnd"]
)
def test_window_demo_bounds_use_foreground_hwnd_not_virtual_desktop(monkeypatch, window_class):
from artemis.exploration import demo_runtime
user32 = Mock()
user32.GetForegroundWindow.return_value = 2**40 + 123
user32.IsIconic.return_value = False
user32.SetThreadDpiAwarenessContext.return_value = 17
def class_name(hwnd, buffer, size):
buffer.value = window_class
return len(window_class)
def bounds(hwnd, pointer):
rect = pointer._obj
rect.left, rect.top, rect.right, rect.bottom = -1200, 50, -400, 650
return True
def title(hwnd, buffer, size):
buffer.value = "示范浏览器"
return len(buffer.value)
user32.GetClassNameW.side_effect = class_name
user32.GetWindowRect.side_effect = bounds
user32.GetWindowTextW.side_effect = title
monkeypatch.setattr(
demo_runtime.ctypes, "windll", SimpleNamespace(user32=user32), raising=False
)
monkeypatch.setattr(demo_runtime, "os", SimpleNamespace(name="nt"))
result = demo_runtime.desktop_source("window")
user32.GetSystemMetrics.assert_not_called()
assert user32.SetThreadDpiAwarenessContext.call_count == 2
if window_class != "Chrome_WidgetWin_1":
assert result is None
return
assert result.target_id == str(2**40 + 123)
assert result.label == "示范浏览器"
assert result.window == {"left": -1200, "top": 50, "width": 800, "height": 600}
assert result.screen is None
assert (result.width, result.height) == (800, 600)
def test_demo_command_retries_do_not_extend_twice_or_finish_other_sessions(
demo_session, monkeypatch
):
repository, session, run = demo_session
now = [100.0]
monkeypatch.setattr("artemis.exploration.demonstrations.time.time", lambda: now[0])
demos = Demonstrations(repository)
demo = demos.request(session["id"], "both", "request")
assert demos.request(session["id"], "both", "request")["id"] == demo["id"]
with pytest.raises(ExplorationConflict):
demos.request(session["id"], "android", "second")
demos.transition(demo["id"], "recording")
for _ in range(2):
demos.command(session["id"], demo["id"], "extend", "extend", seconds=60)
assert demos.get(demo["id"])["deadline"] == 1060
other = repository.create("另一个目标", ExplorationConfig(), {}, "other")
with pytest.raises(KeyError):
demos.command(other["id"], demo["id"], "finish", "finish")
now[0] = 1061
with pytest.raises(ExplorationConflict):
demos.command(session["id"], demo["id"], "finish", "finish")
demos.transition(demo["id"], "timed_out", media=[{"saved": True}])
assert repository.get(session["id"])["active_demonstration_id"] is None
assert repository.run(run["id"])["outcome"] is None
assert demos.transition(demo["id"], "completed")["status"] == "timed_out"
@pytest.mark.asyncio
async def test_demo_safe_point_keeps_lease_and_restores_capture_after_success(
demo_session, monkeypatch
):
from artemis.exploration.runtime import ExplorationControl
repository, session, run = demo_session
demo = Demonstrations(repository).request(session["id"], "android", "demo")
lock = SimpleNamespace(device_id="test-device", release=Mock())
control = ExplorationControl(run["id"], lock)
control.context = SimpleNamespace()
stop, resume = AsyncMock(), AsyncMock()
async def perform(ctx, observed_control, record):
assert record["id"] == demo["id"]
stop.assert_awaited_once()
lock.release.assert_not_called()
observed_control._state("demonstrating")
assert task_control.read_state(run["id"])["status"] == "demonstrating"
return ""
monkeypatch.setattr("artemis.exploration.demo_runtime.perform_demonstration", perform)
task_control.request_pause(run["id"], reason="demonstration")
assert await control.checkpoint(on_pause=stop, on_resume=resume)
lock.release.assert_not_called()
resume.assert_awaited_once()
@pytest.mark.asyncio
async def test_demo_timeout_saves_media_then_releases_lease_without_failing(
demo_session, monkeypatch
):
from artemis.exploration.runtime import ExplorationControl
repository, session, run = demo_session
demo = Demonstrations(repository).request(session["id"], "android", "demo")
lock = SimpleNamespace(device_id="test-device", release=Mock())
control = ExplorationControl(run["id"], lock)
control.context = SimpleNamespace()
async def perform(*_):
Demonstrations(repository).transition(
demo["id"], "timed_out", media=[{"file": "saved.mp4"}]
)
return "demonstration_timed_out"
monkeypatch.setattr("artemis.exploration.demo_runtime.perform_demonstration", perform)
task_control.request_pause(run["id"], reason="demonstration")
waiting = asyncio.create_task(control.checkpoint())
for _ in range(100):
await asyncio.sleep(0.01)
if waiting.done():
waiting.result()
if repository.get(session["id"])["status"] == "paused":
break
try:
assert lock.release.called
assert repository.get(session["id"])["status"] == "paused"
assert repository.run(run["id"])["outcome"] is None
assert Demonstrations(repository).get(demo["id"])["media"]
finally:
waiting.cancel()
await asyncio.gather(waiting, return_exceptions=True)
def test_system_demo_summary_cannot_answer_pending_user_question(demo_session):
from artemis.exploration.runtime import ExplorationControl
repository, session, run = demo_session
control = ExplorationControl(run["id"], Mock())
question = repository.ask(run["id"], "预期是哪个状态?", "开启", "q")
task_control.add_instruction(run["id"], "视频看到了点击", source="demonstration")
assert not control.question_answered()
repository.answer_question(session["id"], question["id"])
assert control.question_answered()
def test_legacy_demo_source_survives_recent_message_window_without_copying_media(demo_session):
from artemis.exploration.runtime import conversation_message
repository, session, run = demo_session
demo = {
"id": "old-demo",
"run_id": run["id"],
"exploration_id": session["id"],
"source": "computer",
"status": "completed",
"media": [{"kind": "computer", "segments": [{"file": "private/path/recording.mp4"}]}],
}
repository.database.put("exploration_demo", demo["id"], demo)
repository.database.put(
"exploration_demo", "failed-demo", {**demo, "id": "failed-demo", "status": "failed"}
)
repository.add_message(
session["id"],
"打开浏览器看首页",
"legacy-summary",
role="assistant",
metadata={"demonstration_id": demo["id"], "kind": "demonstration"},
)
for i in range(4):
repository.add_message(session["id"], "最新指导" * 2000, f"guidance-{i}")
ctx = SimpleNamespace(exploration_id=session["id"], exploration_run_id=run["id"])
payload = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert not any(m.get("demonstration_id") for m in payload["messages"])
assert payload["demonstrations"] == [
{
"id": "old-demo",
"run_id": run["id"],
"source": "computer",
"recorded_sources": ["computer"],
}
]
assert "private/path" not in str(payload)
@pytest.mark.asyncio
@pytest.mark.parametrize("needs_user", [True, False])
@pytest.mark.parametrize(
("source", "recorded_sources"),
[("computer", ["computer"]), ("android", ["android"]), ("both", ["android", "computer"])],
)
async def test_demo_source_survives_clarification_and_execution_restart(
demo_session, monkeypatch, needs_user, source, recorded_sources
):
from apps.admin_console.services.exploration_service import ExplorationService
from artemis.exploration import demo_runtime
from artemis.exploration.runtime import conversation_message
repository, session, run = demo_session
demos = Demonstrations(repository)
demo = demos.request(session["id"], source, "demo")
demos.transition(demo["id"], "recording")
demos.command(session["id"], demo["id"], "finish", "finish")
capture = SimpleNamespace(
start=AsyncMock(),
stop=AsyncMock(),
errors=[],
media=[{"id": f"track-{kind}", "kind": kind} for kind in recorded_sources],
)
monkeypatch.setattr(demo_runtime, "DemonstrationCapture", lambda *_: capture)
monkeypatch.setattr(
demo_runtime,
"analyze_demonstration",
AsyncMock(
return_value=demo_runtime.DemonstrationAnalysis(
summary="打开浏览器,浏览首页后返回顶部。",
needs_user=needs_user,
question="目标网址是什么?" if needs_user else "",
)
),
)
async def run_interruptibly(operation, **_):
return False, await operation
control = SimpleNamespace(
session_id=run["id"],
directory=task_control.control_dir(run["id"]),
_state=Mock(),
run_interruptibly=run_interruptibly,
)
ctx = SimpleNamespace(exploration_id=session["id"], exploration_run_id=run["id"])
reason = await demo_runtime.perform_demonstration(ctx, control, demo)
assert reason == ("question" if needs_user else "")
pending = repository.get(session["id"])["pending_question"]
assert bool(pending) == needs_user
ExplorationService(repository).message(session["id"], "www.playones.com,首页看一遍就行", "url")
assert repository.get(session["id"])["pending_question"] is None
# Reopen the durable store: source must not depend on in-memory capture,
# the model repeating a platform name, or whether analysis needed an answer.
restored = ExplorationStore(repository.database)
monkeypatch.setattr("artemis.exploration.runtime.explorations", restored)
payload = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert payload["demonstrations"] == [
{
"id": demo["id"],
"run_id": run["id"],
"source": source,
"recorded_sources": recorded_sources,
}
]
summary = next(m for m in payload["messages"] if m.get("demonstration_id") == demo["id"])
assert "打开浏览器" in summary["text"]
assert ("电脑" if source == "computer" else "安卓") in summary["text"]
instructions = task_control.consume_instructions(control.directory)["instruction"]
assert "打开浏览器" in instructions
assert "www.playones.com" in instructions
assert ("电脑" if source == "computer" else "安卓") in instructions
# Explicit user changes remain later guidance; provenance is evidence,
# never a hard global execution-target switch that would forbid mixed tests.
ExplorationService(restored).message(session["id"], "接下来切到手机核对页面", "switch")
changed = json.loads(conversation_message(ctx).content.split("\n", 1)[1])
assert changed["messages"][-1]["text"] == "接下来切到手机核对页面"
assert changed["demonstrations"] == payload["demonstrations"]
assert restored.run(run["id"])["task_config"] == {}
@pytest.mark.asyncio
async def test_saved_video_extracts_copy_and_preserves_source_and_offsets(tmp_path, monkeypatch):
from artemis.exploration.media import SavedVideoSource
original = tmp_path / "demo.mp4"
original.write_bytes(b"evidence")
async def render(segments, start, end, target, **kwargs):
assert (start, end) == (12, 14)
target.write_bytes(b"temporary")
return True
monkeypatch.setattr("artemis.exploration.media.render_timeline_clip", render)
source = SavedVideoSource(
tmp_path,
{
"id": "demo-computer",
"kind": "computer",
"segments": [{"file": "demo.mp4", "offset_ms": 12000, "duration_ms": 2000}],
},
)
result = await source.extract_segment_metadata(0, None)
assert result.actual_start_relative_time == 12
assert result.source_revision == "demo-computer"
result.video_path.unlink()
result.video_path.parent.rmdir()
assert original.read_bytes() == b"evidence"
with pytest.raises(ValueError):
SavedVideoSource(
tmp_path, {"segments": [{"file": "../private.mp4", "offset_ms": 0, "duration_ms": 1}]}
)
def test_saved_demo_sampling_reaches_end_of_long_interval(monkeypatch, tmp_path):
from artemis.agents.video_analyzer.universal_engine import _gather_universal_frames
sampling = Mock(return_value=[])
monkeypatch.setattr(
"artemis.agents.video_analyzer.video_analyzer.extract_frames_at_timestamps", sampling
)
analyzer = SimpleNamespace(
media_source=object(), _dense_action_offsets=lambda *args: [], max_dense_action_frames=24
)
_gather_universal_frames(analyzer, tmp_path / "video.mp4", 32.249, 92.249, 32.249)
offsets = sampling.call_args_list[0].args[1]
assert len(offsets) <= 30
assert offsets[0] == 0
assert offsets[-1] == pytest.approx(59.9)
@pytest.mark.asyncio
async def test_saved_demo_chunk_keeps_millisecond_coverage(monkeypatch, tmp_path):
from artemis.agents.video_analyzer.blackboard import VideoBlackboard
from artemis.agents.video_analyzer import chunk_runner
board = VideoBlackboard(
"demo", db_path=tmp_path / "board.db", evidence_dir=tmp_path / "evidence"
)
analyzer = SimpleNamespace(
max_retries=0,
ctx=SimpleNamespace(device=None),
blackboard=board,
model_name="test",
video_processing="static",
chunk_size_seconds=60,
_exec_single_chunk_universal=AsyncMock(return_value="actual summary"),
_parse_chunk_result=lambda _: ("已分析完整区间", "没有推断缺失操作"),
)
monkeypatch.setattr(chunk_runner, "_prepare_chunk_media", AsyncMock(return_value=(None, 1)))
monkeypatch.setattr(chunk_runner, "_build_chunk_prompt", lambda *args: "query")
await chunk_runner.exec_single_chunk(analyzer, 92.249, 137.428, "query")
assert board.missing_intervals(92.249, 137.428, "query") == []
@pytest.mark.asyncio
async def test_failed_render_removes_partial_temporary_clip_but_not_source(tmp_path, monkeypatch):
from artemis.exploration.media import SavedVideoSource
original = tmp_path / "recording.mp4"
original.write_bytes(b"saved")
targets = []
async def render(*args, **kwargs):
target = args[3]
targets.append(target)
target.write_bytes(b"partial")
return False
monkeypatch.setattr("artemis.exploration.media.render_timeline_clip", render)
source = SavedVideoSource(
tmp_path,
{
"id": "demo",
"kind": "android",
"segments": [{"file": original.name, "offset_ms": 0, "duration_ms": 1000}],
},
)
assert not (await source.extract_segment_metadata(0, 1)).success
assert not targets[0].parent.exists()
assert original.read_bytes() == b"saved"
def test_temporary_recording_cleanup_keeps_demonstration_playback(tmp_path):
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
from unittest.mock import MagicMock
from uuid import uuid4
import time
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces"))
ctx.device = None
engine = DataEngine(ctx)
sid = engine.start_session(goal="test")
keep = tmp_path / "demonstrations"
keep.mkdir()
demo = keep / "demo.mp4"
temporary = tmp_path / "temporary.mp4"
demo.write_bytes(b"saved")
temporary.write_bytes(b"temporary")
demo_id, temp_id = uuid4(), uuid4()
for vid, path in ((demo_id, demo), (temp_id, temporary)):
engine.record_video_start(vid, "device", path)
engine.record_video_stop(vid, "device", path, time.time() - 10, time.time())
engine.storage.discard_session_recordings(str(sid), preserve_directory=keep)
assert engine.storage.get_video_recording(demo_id).local_video_path == str(demo)
assert engine.storage.get_video_recording(temp_id).local_video_path is None
assert engine.storage.get_session(sid).video_filepath == str(demo)