428 lines
17 KiB
Python
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)
|