fix(runtime): release 2.7.1 with consistent ADB and failure diagnostics
This commit is contained in:
1 parent
b2316ca249
commit
8bf59897bb
24 files changed
+792
-45
No files matched your search
@@ -181,18 +181,23 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||
exporter.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_batch_archive_is_rebuilt_with_current_recording_layout(client, isolated):
|
||||
@pytest.mark.parametrize("signature", [None, "artemis-batch-layout-3"])
|
||||
def test_legacy_batch_archive_is_rebuilt_with_runtime_diagnostics(client, isolated, signature):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"legacy archive")
|
||||
if signature:
|
||||
reports.remember(path, signature)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
assert client.get(base + "/status").json()["status"] == "idle"
|
||||
response = client.get(base)
|
||||
assert response.status_code == 200
|
||||
assert zipfile.is_zipfile(BytesIO(response.content))
|
||||
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||
assert len([name for name in archive.namelist() if name.endswith("运行环境诊断.json")]) == 2
|
||||
assert client.get(base + "/status").json()["status"] == "ready"
|
||||
|
||||
|
||||
@@ -721,7 +726,7 @@ 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()) == 10 # summaries + reports, diagnostics and evidence per task
|
||||
assert len(z.namelist()) == 12 # summaries + reports, diagnostics and evidence per task
|
||||
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
|
||||
assert len(diagnostics) == 2
|
||||
for name in diagnostics:
|
||||
@@ -1304,6 +1309,69 @@ def test_batch_zip_contains_each_tasks_android_artifacts(isolated):
|
||||
assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pause_source", ["device", "initialization", "user", "model"])
|
||||
def test_restart_retains_original_pause_reason_and_archives_recovery(isolated, pause_source):
|
||||
batch = service.create_batch(payload())
|
||||
reason = "原始暂停原因"
|
||||
isolated.put(
|
||||
"batch",
|
||||
batch["id"],
|
||||
{**batch, "status": "paused", "pause_source": pause_source, "reason": reason},
|
||||
)
|
||||
service.recover_batches()
|
||||
recovered = isolated.get("batch", batch["id"])
|
||||
assert recovered["status"] == "paused"
|
||||
assert recovered["pause_source"] == "recovery"
|
||||
assert recovered["reason"] == reason
|
||||
event = recovered["recovery_events"][0]
|
||||
assert event["previous_reason"] == reason
|
||||
assert event["previous_pause_source"] == pause_source
|
||||
assert "服务已重启" in event["message"]
|
||||
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
|
||||
service.recover_batches()
|
||||
assert isolated.get("batch", batch["id"])["reason"] == reason
|
||||
|
||||
|
||||
def test_runtime_evidence_is_exported_from_the_task_without_resampling(isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
snapshot = {
|
||||
"schema_version": 1,
|
||||
"failure": {
|
||||
"captured_at": 1,
|
||||
"runtime": {"source_commit": "worker-commit"},
|
||||
"memory": {"available_bytes": 163},
|
||||
},
|
||||
}
|
||||
directory = reports.TRACES_PATH / first["session_id"]
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "runtime_diagnostics.json").write_text(json.dumps(snapshot), encoding="utf-8")
|
||||
isolated.put(
|
||||
"batch",
|
||||
batch["id"],
|
||||
{**batch, "status": "paused", "reason": "设备离线", "pause_source": "device"},
|
||||
)
|
||||
service.recover_batches()
|
||||
|
||||
def assert_snapshots(archive_path):
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
records = [
|
||||
json.loads(archive.read(name))
|
||||
for name in archive.namelist()
|
||||
if name.endswith("运行环境诊断.json")
|
||||
]
|
||||
recorded = next(record for record in records if record["runtime"])
|
||||
assert recorded["runtime"] == snapshot
|
||||
assert recorded["batch"]["reason"] == "设备离线"
|
||||
assert recorded["batch"]["recovery_events"][0]["previous_pause_source"] == "device"
|
||||
return records
|
||||
|
||||
assert_snapshots(reports.export_session_media(first["session_id"], "all"))
|
||||
records = assert_snapshots(reports.export_batch(batch["id"]))
|
||||
assert len(records) == 2
|
||||
assert next(record for record in records if record["runtime"] is None)["runtime"] is None
|
||||
|
||||
|
||||
def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
|
||||
isolated, monkeypatch
|
||||
):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Hopper failures must not become successful negative lookups."""
|
||||
|
||||
import asyncio
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.agents.hopper.hopper import HopperError, HopperOutput
|
||||
|
||||
module = import_module("artemis.agents.hopper.hopper")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("invalid", [None, {}, {"found": True, "output": " ", "reason": "Empty"}])
|
||||
async def test_invalid_extraction_switches_to_fallback(monkeypatch, invalid):
|
||||
good = HopperOutput(found=True, output="com.example.app", reason="Matched")
|
||||
primary = SimpleNamespace(ainvoke=AsyncMock(return_value=invalid))
|
||||
secondary = SimpleNamespace(ainvoke=AsyncMock(return_value=good))
|
||||
factory = Mock(
|
||||
side_effect=[
|
||||
SimpleNamespace(with_structured_output=lambda _: primary),
|
||||
SimpleNamespace(with_structured_output=lambda _: secondary),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(module, "get_llm", factory)
|
||||
assert (
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "com.example.app")
|
||||
== good
|
||||
)
|
||||
assert primary.ainvoke.await_count == secondary.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_fallback", [False, True])
|
||||
async def test_exhausted_extraction_raises_a_distinct_error(monkeypatch, use_fallback):
|
||||
model = SimpleNamespace(ainvoke=AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
|
||||
)
|
||||
with pytest.raises(HopperError, match="模型未能完成数据提取"):
|
||||
await module.hopper(
|
||||
SimpleNamespace(data_engine=None), "Find app", "packages", use_fallback=use_fallback
|
||||
)
|
||||
assert model.ainvoke.await_count == (2 if use_fallback else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_extraction_never_uses_fallback(monkeypatch):
|
||||
model = SimpleNamespace(ainvoke=AsyncMock(side_effect=asyncio.CancelledError))
|
||||
monkeypatch.setattr(
|
||||
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
|
||||
)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
|
||||
assert model.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_setup_failure_is_not_reported_as_missing_data(monkeypatch):
|
||||
monkeypatch.setattr(module, "get_llm", Mock(side_effect=ValueError("invalid model settings")))
|
||||
with pytest.raises(HopperError, match="模型未能完成数据提取"):
|
||||
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Runtime snapshots are bounded, read-only evidence, never a task gate."""
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from artemis.runtime import diagnostics as module
|
||||
from artemis.runtime.adb_endpoint import AdbEndpoint
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_identity_cache():
|
||||
identity = module.runtime_identity
|
||||
identity.cache_clear()
|
||||
yield
|
||||
identity.cache_clear()
|
||||
|
||||
|
||||
def test_tool_identity_records_exact_binary_hash_and_version(monkeypatch, tmp_path):
|
||||
adb = tmp_path / "platform tools" / "adb.exe"
|
||||
adb.parent.mkdir()
|
||||
adb.write_bytes(b"selected adb")
|
||||
monkeypatch.setattr(module.toolchain, "resolve", lambda _: str(adb))
|
||||
command = Mock(return_value="Android Debug Bridge version test")
|
||||
monkeypatch.setattr(module, "_command", command)
|
||||
record = module._tool_identity("adb")
|
||||
assert record["path"] == str(adb)
|
||||
assert record["sha256"] == hashlib.sha256(b"selected adb").hexdigest()
|
||||
assert record["version"] == command.return_value
|
||||
command.assert_called_once_with([str(adb), "version"])
|
||||
|
||||
|
||||
def test_unknown_source_and_archive_identity_are_not_invented(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(module, "ROOT_DIR", tmp_path)
|
||||
monkeypatch.setattr(module, "get_version", lambda: "test-version")
|
||||
command = Mock()
|
||||
monkeypatch.setattr(module, "_command", command)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "private-test-key")
|
||||
record = module.runtime_identity(False)
|
||||
assert record["source_commit"] is None
|
||||
assert record["source_modified"] is None
|
||||
assert record["installation_archive_sha256"] is None
|
||||
assert record["artemis_version"] == "test-version"
|
||||
assert record["tools"] == {}
|
||||
assert "private-test-key" not in json.dumps(record)
|
||||
command.assert_not_called()
|
||||
|
||||
|
||||
def test_version_commands_are_bounded_and_never_repair_devices(monkeypatch):
|
||||
run = Mock(return_value=SimpleNamespace(stdout="version", returncode=0))
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
assert module._command(["adb", "version"]) == "version"
|
||||
assert run.call_args.kwargs["timeout"] == 3
|
||||
assert run.call_args.kwargs["check"] is False
|
||||
assert run.call_args.args == (["adb", "version"],)
|
||||
run.side_effect = OSError("Cannot create process")
|
||||
assert module._command(["adb", "version"]) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(module.sys.platform != "win32", reason="Windows PSAPI structure")
|
||||
def test_system_commit_converts_pages_to_bytes(monkeypatch):
|
||||
def query(pointer, size):
|
||||
values = pointer._obj
|
||||
assert size == ctypes.sizeof(module._PerformanceInformation)
|
||||
values.PageSize = 4096
|
||||
values.CommitTotal = 900
|
||||
values.CommitLimit = 1000
|
||||
return True
|
||||
|
||||
dll = SimpleNamespace(GetPerformanceInfo=Mock(side_effect=query))
|
||||
monkeypatch.setattr(module.ctypes, "WinDLL", lambda *args, **kwargs: dll)
|
||||
result = module._windows_commit()
|
||||
assert result["committed_bytes"] == 900 * 4096
|
||||
assert result["limit_bytes"] == 1000 * 4096
|
||||
assert result["available_bytes"] == 100 * 4096
|
||||
|
||||
|
||||
def test_remote_adb_process_is_explicitly_unknown(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
module, "current_adb_endpoint", lambda: AdbEndpoint.create("192.0.2.10", 5037)
|
||||
)
|
||||
connections = Mock()
|
||||
monkeypatch.setattr(module.psutil, "net_connections", connections)
|
||||
result = module._adb_server()
|
||||
assert result["local_listener"] is None
|
||||
assert result["endpoint"]["host"] == "192.0.2.10"
|
||||
connections.assert_not_called()
|
||||
|
||||
|
||||
def test_local_adb_listener_identity_excludes_unrelated_bindings(monkeypatch):
|
||||
monkeypatch.setattr(module, "current_adb_endpoint", lambda: AdbEndpoint.local())
|
||||
connections = [
|
||||
SimpleNamespace(status=psutil.CONN_LISTEN, laddr=SimpleNamespace(ip=ip, port=5037), pid=pid)
|
||||
for ip, pid in [("192.0.2.10", 5), ("127.0.0.1", 6)]
|
||||
]
|
||||
monkeypatch.setattr(module.psutil, "net_connections", lambda **_: connections)
|
||||
process = SimpleNamespace(pid=6, name=lambda: "adb.exe", exe=lambda: "selected/adb.exe")
|
||||
lookup = Mock(return_value=process)
|
||||
monkeypatch.setattr(module.psutil, "Process", lookup)
|
||||
assert module._adb_server()["local_listener"] == {
|
||||
"pid": 6,
|
||||
"name": "adb.exe",
|
||||
"path": "selected/adb.exe",
|
||||
}
|
||||
lookup.assert_called_once_with(6)
|
||||
|
||||
|
||||
def test_start_snapshot_survives_failure_and_repeated_start(monkeypatch, tmp_path):
|
||||
identity = Mock(return_value={"source_commit": "test-commit"})
|
||||
memory = Mock(side_effect=[{"available_bytes": 100}, {"available_bytes": 1}])
|
||||
monkeypatch.setattr(module, "runtime_identity", identity)
|
||||
monkeypatch.setattr(module, "_memory_snapshot", memory)
|
||||
monkeypatch.setattr(module, "_adb_server", lambda: {"local_listener": None})
|
||||
module.write_runtime_diagnostics(tmp_path, "start")
|
||||
module.write_runtime_diagnostics(tmp_path, "start")
|
||||
module.write_runtime_diagnostics(tmp_path, "failure")
|
||||
payload = json.loads((tmp_path / module.FILENAME).read_text(encoding="utf-8"))
|
||||
assert payload["start"]["memory"]["available_bytes"] == 100
|
||||
assert payload["failure"]["memory"]["available_bytes"] == 1
|
||||
assert payload["failure"]["runtime"]["source_commit"] == "test-commit"
|
||||
assert memory.call_count == 2
|
||||
assert not (tmp_path / "runtime_diagnostics.tmp").exists()
|
||||
|
||||
|
||||
def test_snapshot_failure_cannot_fail_a_task(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(module, "runtime_identity", lambda _: {})
|
||||
monkeypatch.setattr(module, "_memory_snapshot", Mock(side_effect=OSError("commit exhausted")))
|
||||
module.write_runtime_diagnostics(tmp_path, "failure")
|
||||
assert not (tmp_path / module.FILENAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"error", [OSError("commit exhausted"), RuntimeError("cannot start thread")]
|
||||
)
|
||||
async def test_snapshot_thread_creation_failure_is_optional(monkeypatch, tmp_path, error):
|
||||
monkeypatch.setattr(module.asyncio, "to_thread", AsyncMock(side_effect=error))
|
||||
await module.capture_runtime_diagnostics(tmp_path, "start")
|
||||
assert not (tmp_path / module.FILENAME).exists()
|
||||
@@ -22,6 +22,22 @@ from artemis.runtime.device_lock import DeviceBusyError
|
||||
from artemis.sdk.types.exceptions import AgentError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ["failed", "completed", "cancelled"])
|
||||
async def test_failure_snapshot_is_written_before_tracing_ends(tmp_path, status):
|
||||
agent = object.__new__(Agent)
|
||||
task = MagicMock(status=status)
|
||||
context = MagicMock(execution_target="computer", execution_setup=None)
|
||||
context.data_engine.base_dir = tmp_path
|
||||
with patch("artemis.runtime.diagnostics.write_runtime_diagnostics") as snapshot:
|
||||
await agent._finalize_tracing(task, context)
|
||||
if status == "failed":
|
||||
snapshot.assert_called_once_with(tmp_path, "failure", android=False)
|
||||
else:
|
||||
snapshot.assert_not_called()
|
||||
context.data_engine.end_session.assert_called_once_with(status=status)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_finalization_failure_does_not_escape():
|
||||
agent = object.__new__(Agent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the LLM gateway: complete(), classified recovery, and fallback."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||
import pytest
|
||||
@@ -16,6 +17,55 @@ from artemis.services.llm import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"invalid",
|
||||
[
|
||||
None,
|
||||
{"raw": AIMessage(content=""), "parsed": None, "parsing_error": None},
|
||||
{
|
||||
"raw": AIMessage(content=""),
|
||||
"parsed": None,
|
||||
"parsing_error": ValueError("Invalid schema"),
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_missing_structured_output_retries_inside_existing_budget(invalid):
|
||||
expected = {"found": True, "output": "com.example.app", "reason": "Matched"}
|
||||
base = SimpleNamespace(
|
||||
ainvoke=AsyncMock(
|
||||
side_effect=[
|
||||
invalid,
|
||||
{"raw": AIMessage(content=""), "parsed": expected, "parsing_error": None},
|
||||
]
|
||||
)
|
||||
)
|
||||
model = RobustChatModelWrapper(base, structured_mode="parsed")
|
||||
assert await model.ainvoke([]) == expected
|
||||
assert base.ainvoke.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_structured_output_exhausts_then_uses_fallback():
|
||||
primary = SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": None})
|
||||
)
|
||||
expected = {"found": False, "output": None, "reason": "No match"}
|
||||
secondary = SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": expected})
|
||||
)
|
||||
main = RobustChatModelWrapper(
|
||||
primary, endpoint=ModelEndpoint(model_name="primary"), structured_mode="parsed"
|
||||
)
|
||||
fallback = RobustChatModelWrapper(
|
||||
secondary, endpoint=ModelEndpoint(model_name="fallback"), structured_mode="parsed"
|
||||
)
|
||||
result = await with_fallback(lambda: main.ainvoke([]), lambda: fallback.ainvoke([]))
|
||||
assert result == expected
|
||||
assert primary.ainvoke.await_count == 2
|
||||
assert secondary.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_gateway_state(monkeypatch, tmp_path):
|
||||
llm_service._ENDPOINT_BREAKER._states.clear()
|
||||
|
||||
@@ -25,6 +25,7 @@ import pytest
|
||||
import cv2
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.config.settings import settings
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
from artemis.utils.video import (
|
||||
@@ -42,6 +43,55 @@ from artemis.utils.video import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("host,port", [("127.0.0.1", 5037), ("192.0.2.10", 5038)])
|
||||
async def test_scrcpy_uses_selected_adb_binary_and_endpoint(monkeypatch, tmp_path, host, port):
|
||||
import os
|
||||
|
||||
adb = str(tmp_path / "platform tools" / "adb.exe")
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
|
||||
monkeypatch.setattr(settings, "ADB_HOST", host)
|
||||
monkeypatch.setattr(settings, "ADB_PORT", port)
|
||||
monkeypatch.setenv("ADB", "other-adb")
|
||||
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:unrelated:5999")
|
||||
command = ["scrcpy", "--no-playback"]
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
await UnifiedMobileController._spawn_scrcpy(command)
|
||||
environment = spawn.call_args.kwargs["env"]
|
||||
assert environment["ADB"] == adb
|
||||
assert environment["ADB_SERVER_SOCKET"] == f"tcp:{'localhost' if port == 5037 else host}:{port}"
|
||||
assert environment["ADB_HOST"] == host
|
||||
assert environment["ADB_PORT"] == str(port)
|
||||
assert spawn.call_args.args == tuple(command)
|
||||
assert os.environ["ADB"] == "other-adb"
|
||||
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:unrelated:5999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scrcpy_does_not_fall_back_to_an_unselected_adb(monkeypatch):
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: None)
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
with pytest.raises(FileNotFoundError, match="adb"):
|
||||
await UnifiedMobileController._spawn_scrcpy(["scrcpy"])
|
||||
spawn.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_android_recording_also_uses_selected_adb(monkeypatch, tmp_path):
|
||||
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
||||
|
||||
adb = str(tmp_path / "platform-tools" / "adb.exe")
|
||||
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
|
||||
monkeypatch.setattr(settings, "ADB_HOST", "192.0.2.10")
|
||||
monkeypatch.setattr(settings, "ADB_PORT", 5038)
|
||||
driver = AndroidAdbDriver("fixture-device", MagicMock())
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
|
||||
await driver.start_video_recording(tmp_path)
|
||||
assert spawn.await_count == 1
|
||||
assert spawn.call_args.kwargs["env"]["ADB"] == adb
|
||||
assert spawn.call_args.kwargs["env"]["ADB_SERVER_SOCKET"] == "tcp:192.0.2.10:5038"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
|
||||
silent_video = tmp_path / "silent.mp4"
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from artemis.agents.hopper.hopper import HopperOutput
|
||||
from artemis.agents.hopper.hopper import HopperError, HopperOutput
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.tools.mobile.launch_app import find_package
|
||||
from artemis.tools.mobile.launch_app import find_package, launch_app
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_cont
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context):
|
||||
async def test_find_package_not_found_is_not_cached(mock_hopper, mock_list_packages, mock_context):
|
||||
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
|
||||
|
||||
# Hopper doesn't find it
|
||||
@@ -64,13 +64,14 @@ async def test_find_package_not_found_caches_none(mock_hopper, mock_list_package
|
||||
pkg1 = await find_package(mock_context, "App 3")
|
||||
assert pkg1 is None
|
||||
assert mock_hopper.call_count == 1
|
||||
assert mock_context.package_cache["App 3"] is None
|
||||
assert "App 3" not in mock_context.package_cache
|
||||
|
||||
# Second call: cache hit (None), does not call hopper again
|
||||
# An earlier miss must not prevent a later successful lookup.
|
||||
mock_hopper.reset_mock()
|
||||
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
|
||||
pkg2 = await find_package(mock_context, "App 3")
|
||||
assert pkg2 is None
|
||||
mock_hopper.assert_not_called()
|
||||
assert pkg2 == "com.example.app1"
|
||||
mock_hopper.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,3 +91,32 @@ async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages,
|
||||
data="com.example.app1\ncom.example.app2",
|
||||
use_fallback=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_model_failure_is_reported_without_poisoning_package_cache(
|
||||
mock_hopper, mock_list_packages, mock_context
|
||||
):
|
||||
mock_list_packages.return_value = "com.example.app1"
|
||||
mock_hopper.side_effect = HopperError("模型未能完成数据提取,请查看模型请求诊断。")
|
||||
result = await launch_app.execute(ctx=mock_context, app_name="App 1")
|
||||
assert "模型未能完成数据提取" in result
|
||||
assert "Package not found" not in result
|
||||
assert "App 1" not in mock_context.package_cache
|
||||
mock_hopper.side_effect = None
|
||||
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
|
||||
assert await find_package(mock_context, "App 1") == "com.example.app1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
||||
async def test_invented_package_is_never_cached(mock_hopper, mock_list_packages, mock_context):
|
||||
mock_list_packages.return_value = "com.example.app1"
|
||||
mock_hopper.return_value = HopperOutput(
|
||||
found=True, output="com.example.missing", reason="Guess"
|
||||
)
|
||||
assert await find_package(mock_context, "App 1") is None
|
||||
assert "App 1" not in mock_context.package_cache
|
||||
Reference in new issue
Block a user