Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf59897bb |
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
|
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
|
||||||
|
|
||||||
**2.7.0**:新增 Responses WebSocket 增量传输,复用连接减少多轮任务的重复上传;默认启用,并将旧的全局 HTTP 配置迁移一次,之后仍可手动切回 HTTP。见 [2.7.0 发行说明](docs/releases/2.7.0.md)。包含 [2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
|
**2.7.1**:统一任务操作与录屏使用的 ADB,修复模型空结果引起的应用查找误报,并在任务资料 ZIP 中保留运行环境诊断和批次原暂停原因。见 [2.7.1 发行说明](docs/releases/2.7.1.md)。继续包含 [2.7.0 的 Responses WebSocket 增量传输](docs/releases/2.7.0.md)、[2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
|
|||||||
@@ -123,9 +123,18 @@ def recover_batches():
|
|||||||
if batch["status"] in {"running", "pausing", "paused"}:
|
if batch["status"] in {"running", "pausing", "paused"}:
|
||||||
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||||
continue
|
continue
|
||||||
|
recovery_reason = "服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
|
batch.setdefault("recovery_events", []).append(
|
||||||
|
{
|
||||||
|
"message": recovery_reason,
|
||||||
|
"recovered_at": time.time(),
|
||||||
|
"previous_reason": batch.get("reason"),
|
||||||
|
"previous_pause_source": batch.get("pause_source"),
|
||||||
|
}
|
||||||
|
)
|
||||||
batch.update(
|
batch.update(
|
||||||
status="paused",
|
status="paused",
|
||||||
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
|
reason=batch.get("reason") or recovery_reason,
|
||||||
pause_source="recovery",
|
pause_source="recovery",
|
||||||
)
|
)
|
||||||
for job in store.jobs(batch["id"]):
|
for job in store.jobs(batch["id"]):
|
||||||
@@ -330,7 +339,7 @@ async def tick_batches():
|
|||||||
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
||||||
)
|
)
|
||||||
if rejection:
|
if rejection:
|
||||||
batch.update(status="paused", reason=str(rejection))
|
batch.update(status="paused", reason=str(rejection), pause_source="device")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
|||||||
from apps.admin_console.services.case_review import reviewed_result
|
from apps.admin_console.services.case_review import reviewed_result
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3"
|
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-4"
|
||||||
|
|
||||||
|
|
||||||
def batch_archive_signature(batch_id: str | None = None) -> str:
|
def batch_archive_signature(batch_id: str | None = None) -> str:
|
||||||
@@ -197,6 +197,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
|
|
||||||
identifier(session_id)
|
identifier(session_id)
|
||||||
meta = store.job(session_id) or {}
|
meta = store.job(session_id) or {}
|
||||||
|
batch = store.get("batch", meta["batch_id"]) if meta.get("batch_id") else None
|
||||||
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
||||||
session = repo.get_session_by_id(session_id) or {}
|
session = repo.get_session_by_id(session_id) or {}
|
||||||
if not session and not meta:
|
if not session and not meta:
|
||||||
@@ -270,6 +271,15 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
"original_case": case.get("original_case"),
|
"original_case": case.get("original_case"),
|
||||||
"task_config": case.get("task_config") or {},
|
"task_config": case.get("task_config") or {},
|
||||||
"task_extensions": task_extensions.report_sections(session_id, store),
|
"task_extensions": task_extensions.report_sections(session_id, store),
|
||||||
|
"runtime_diagnostics": read_json(directory / "runtime_diagnostics.json"),
|
||||||
|
"batch_diagnostics": (
|
||||||
|
{
|
||||||
|
key: batch.get(key)
|
||||||
|
for key in ("id", "status", "reason", "pause_source", "recovery_events")
|
||||||
|
}
|
||||||
|
if batch
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1081,6 +1091,17 @@ def export_session_media(session_id: str, kind: str, *, report: dict | None = No
|
|||||||
)
|
)
|
||||||
if kind == "all":
|
if kind == "all":
|
||||||
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
|
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
|
||||||
|
archive.writestr(
|
||||||
|
prefix + "运行环境诊断.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"runtime": report.get("runtime_diagnostics"),
|
||||||
|
"batch": report.get("batch_diagnostics"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
archive.writestr(
|
archive.writestr(
|
||||||
prefix + "证据索引.json",
|
prefix + "证据索引.json",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -1202,6 +1223,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
|||||||
compress_type=compression(source),
|
compress_type=compression(source),
|
||||||
)
|
)
|
||||||
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
||||||
|
archive.writestr(
|
||||||
|
f"{prefix}/运行环境诊断.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"runtime": report.get("runtime_diagnostics"),
|
||||||
|
"batch": report.get("batch_diagnostics"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
archive.writestr(
|
archive.writestr(
|
||||||
f"{prefix}/证据索引.json",
|
f"{prefix}/证据索引.json",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.2.4",
|
"@angular/cdk": "^22.2.4",
|
||||||
"@angular/common": "^22.2.3",
|
"@angular/common": "^22.2.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ from artemis.context import ArtemisContext
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
|
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HopperError(RuntimeError):
|
||||||
|
"""Extraction failed; this says nothing about whether the requested data exists."""
|
||||||
|
|
||||||
|
|
||||||
class _CyFunctionDetectorMeta(type):
|
class _CyFunctionDetectorMeta(type):
|
||||||
def __instancecheck__(self, instance):
|
def __instancecheck__(self, instance):
|
||||||
name = type(instance).__name__
|
name = type(instance).__name__
|
||||||
@@ -55,6 +59,12 @@ class HopperOutput(BaseModel):
|
|||||||
+ " and how you decided what to extract."
|
+ " and how you decided what to extract."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_found_output(self):
|
||||||
|
if self.found and (not self.output or not self.output.strip()):
|
||||||
|
raise ValueError("A successful extraction must contain a nonempty output")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
@trace(type="agent", name="hopper")
|
@trace(type="agent", name="hopper")
|
||||||
async def hopper(
|
async def hopper(
|
||||||
@@ -72,25 +82,22 @@ async def hopper(
|
|||||||
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
|
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
|
||||||
]
|
]
|
||||||
|
|
||||||
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
|
async def extract(model) -> HopperOutput:
|
||||||
|
response = await invoke_llm_with_timeout_message(model.ainvoke(messages))
|
||||||
|
return HopperOutput.model_validate(response)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
|
||||||
if use_fallback:
|
if use_fallback:
|
||||||
llm_fallback = get_llm(
|
llm_fallback = get_llm(
|
||||||
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
|
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
|
||||||
).with_structured_output(HopperOutput)
|
).with_structured_output(HopperOutput)
|
||||||
response: HopperOutput = await with_fallback(
|
return await with_fallback(
|
||||||
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)),
|
main_call=lambda: extract(llm),
|
||||||
fallback_call=lambda: invoke_llm_with_timeout_message(
|
fallback_call=lambda: extract(llm_fallback),
|
||||||
llm_fallback.ainvoke(messages)
|
)
|
||||||
),
|
|
||||||
) # type: ignore
|
|
||||||
else:
|
else:
|
||||||
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
|
return await extract(llm)
|
||||||
return response
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Hopper LLM invocation failed: {e}")
|
logger.error(f"Hopper LLM invocation failed: {e}")
|
||||||
return HopperOutput(
|
raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e
|
||||||
found=False,
|
|
||||||
output=None,
|
|
||||||
reason=f"Failed due to LLM error: {str(e)[:500]}",
|
|
||||||
)
|
|
||||||
@@ -36,6 +36,8 @@ from artemis.controllers.types import (
|
|||||||
SwipeStartEndPercentagesRequest,
|
SwipeStartEndPercentagesRequest,
|
||||||
TapOutput,
|
TapOutput,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||||
|
from artemis.toolchain import find_scrcpy
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
from artemis.utils.video import (
|
from artemis.utils.video import (
|
||||||
ANDROID_RECORDING_SEGMENT_SECONDS,
|
ANDROID_RECORDING_SEGMENT_SECONDS,
|
||||||
@@ -82,9 +84,13 @@ class UnifiedMobileController:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process:
|
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process:
|
||||||
|
adb = AdbSession(current_adb_endpoint())
|
||||||
|
if not adb.adb_path:
|
||||||
|
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"stdout": asyncio.subprocess.PIPE,
|
"stdout": asyncio.subprocess.PIPE,
|
||||||
"stderr": asyncio.subprocess.PIPE,
|
"stderr": asyncio.subprocess.PIPE,
|
||||||
|
"env": adb.environment(),
|
||||||
}
|
}
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
@@ -606,7 +612,7 @@ class UnifiedMobileController:
|
|||||||
config = recording_config(self.ctx)
|
config = recording_config(self.ctx)
|
||||||
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
|
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
|
||||||
return build_scrcpy_record_command(
|
return build_scrcpy_record_command(
|
||||||
"scrcpy",
|
find_scrcpy(),
|
||||||
device_id,
|
device_id,
|
||||||
path,
|
path,
|
||||||
f"{bitrate}K",
|
f"{bitrate}K",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from artemis.runtime.observation_budget import (
|
|||||||
current_observation,
|
current_observation,
|
||||||
)
|
)
|
||||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||||
|
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||||
from artemis.utils.video import build_scrcpy_record_command
|
from artemis.utils.video import build_scrcpy_record_command
|
||||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -608,6 +609,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._recording_output_path = out_dir / "recording.mp4"
|
self._recording_output_path = out_dir / "recording.mp4"
|
||||||
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
|
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
|
||||||
try:
|
try:
|
||||||
|
adb = AdbSession(current_adb_endpoint())
|
||||||
|
if not adb.adb_path:
|
||||||
|
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
|
||||||
scrcpy_bin = find_scrcpy()
|
scrcpy_bin = find_scrcpy()
|
||||||
cmd = build_scrcpy_record_command(
|
cmd = build_scrcpy_record_command(
|
||||||
scrcpy_bin,
|
scrcpy_bin,
|
||||||
@@ -617,6 +621,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
)
|
)
|
||||||
self._scrcpy_process = await asyncio.create_subprocess_exec(
|
self._scrcpy_process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
|
env=adb.environment(),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ class AdbSession:
|
|||||||
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
|
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||||
environment = dict(base if base is not None else os.environ)
|
environment = dict(base if base is not None else os.environ)
|
||||||
self.endpoint.apply_to_environment(environment)
|
self.endpoint.apply_to_environment(environment)
|
||||||
|
if self.adb_path:
|
||||||
|
environment["ADB"] = self.adb_path
|
||||||
return environment
|
return environment
|
||||||
|
|
||||||
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Small, task-owned runtime snapshots; no polling or readiness decisions."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
from functools import lru_cache
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from artemis._version import get_version
|
||||||
|
from artemis.config.paths import ROOT_DIR
|
||||||
|
from artemis.runtime.adb_endpoint import current_adb_endpoint
|
||||||
|
from artemis.toolchain import toolchain
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
FILENAME = "runtime_diagnostics.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _command(arguments: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
arguments,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=3,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()[:4096] if result.returncode == 0 else None
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_identity(name: str) -> dict:
|
||||||
|
path = toolchain.resolve(name)
|
||||||
|
result = {"path": path, "version": None, "sha256": None}
|
||||||
|
if path:
|
||||||
|
result["version"] = _command([path, "version" if name == "adb" else "--version"])
|
||||||
|
try:
|
||||||
|
with Path(path).open("rb") as stream:
|
||||||
|
result["sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Could not hash %s", name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=2)
|
||||||
|
def runtime_identity(android: bool) -> dict:
|
||||||
|
"""Read identity once per worker, before later source updates can change it."""
|
||||||
|
git = ["git", "-C", str(ROOT_DIR)]
|
||||||
|
checkout = (ROOT_DIR / ".git").exists()
|
||||||
|
commit = _command([*git, "rev-parse", "--verify", "HEAD"]) if checkout else None
|
||||||
|
changes = (
|
||||||
|
_command([*git, "status", "--porcelain", "--untracked-files=no"]) if checkout else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"artemis_version": get_version(),
|
||||||
|
"source_commit": commit,
|
||||||
|
"source_modified": bool(changes) if changes is not None else None,
|
||||||
|
"installation_archive_sha256": None,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"platform": platform.system(),
|
||||||
|
"tools": {name: _tool_identity(name) for name in ("adb", "scrcpy")} if android else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PerformanceInformation(ctypes.Structure):
|
||||||
|
# https://learn.microsoft.com/windows/win32/api/psapi/ns-psapi-performance_information
|
||||||
|
_fields_ = (
|
||||||
|
[("cb", wintypes.DWORD)]
|
||||||
|
+ [
|
||||||
|
(name, ctypes.c_size_t)
|
||||||
|
for name in (
|
||||||
|
"CommitTotal",
|
||||||
|
"CommitLimit",
|
||||||
|
"CommitPeak",
|
||||||
|
"PhysicalTotal",
|
||||||
|
"PhysicalAvailable",
|
||||||
|
"SystemCache",
|
||||||
|
"KernelTotal",
|
||||||
|
"KernelPaged",
|
||||||
|
"KernelNonpaged",
|
||||||
|
"PageSize",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [(name, wintypes.DWORD) for name in ("HandleCount", "ProcessCount", "ThreadCount")]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_commit() -> dict | None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return None
|
||||||
|
values = _PerformanceInformation()
|
||||||
|
values.cb = ctypes.sizeof(values)
|
||||||
|
query = ctypes.WinDLL("psapi", use_last_error=True).GetPerformanceInfo
|
||||||
|
query.argtypes = [ctypes.POINTER(_PerformanceInformation), wintypes.DWORD]
|
||||||
|
query.restype = wintypes.BOOL
|
||||||
|
if not query(ctypes.byref(values), values.cb):
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
committed = values.CommitTotal * values.PageSize
|
||||||
|
limit = values.CommitLimit * values.PageSize
|
||||||
|
return {
|
||||||
|
"committed_bytes": committed,
|
||||||
|
"limit_bytes": limit,
|
||||||
|
"available_bytes": max(0, limit - committed),
|
||||||
|
"note": "系统提交额度,不能当作页面文件大小。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_snapshot() -> dict:
|
||||||
|
result = {"physical": None, "system_commit": None, "worker": None}
|
||||||
|
try:
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
result["physical"] = {"total_bytes": memory.total, "available_bytes": memory.available}
|
||||||
|
result["system_commit"] = _windows_commit()
|
||||||
|
except (OSError, psutil.Error):
|
||||||
|
logger.debug("Host memory snapshot unavailable")
|
||||||
|
try:
|
||||||
|
memory = psutil.Process().memory_info()
|
||||||
|
result["worker"] = {
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"rss_bytes": memory.rss,
|
||||||
|
"private_bytes": getattr(memory, "private", None),
|
||||||
|
}
|
||||||
|
except (OSError, psutil.Error):
|
||||||
|
logger.debug("Worker memory snapshot unavailable")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_server() -> dict:
|
||||||
|
endpoint = current_adb_endpoint()
|
||||||
|
result = {"endpoint": endpoint.to_dict(), "local_listener": None}
|
||||||
|
if endpoint.host.lower() not in {"localhost", "127.0.0.1", "::1"}:
|
||||||
|
return result # A remote server's process cannot be observed on this host.
|
||||||
|
try:
|
||||||
|
for connection in psutil.net_connections(kind="tcp"):
|
||||||
|
if (
|
||||||
|
connection.status == psutil.CONN_LISTEN
|
||||||
|
and connection.laddr.port == endpoint.port
|
||||||
|
and connection.laddr.ip
|
||||||
|
in {
|
||||||
|
endpoint.host,
|
||||||
|
"0.0.0.0",
|
||||||
|
"::",
|
||||||
|
*(("127.0.0.1", "::1") if endpoint.host.lower() == "localhost" else ()),
|
||||||
|
}
|
||||||
|
and connection.pid
|
||||||
|
):
|
||||||
|
process = psutil.Process(connection.pid)
|
||||||
|
result["local_listener"] = {
|
||||||
|
"pid": process.pid,
|
||||||
|
"name": process.name(),
|
||||||
|
"path": process.exe(),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
except (OSError, psutil.Error):
|
||||||
|
logger.debug("ADB listener identity unavailable")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def write_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
|
||||||
|
"""Keep a start snapshot and the latest failure snapshot without changing task results."""
|
||||||
|
if phase not in {"start", "failure"}:
|
||||||
|
raise ValueError("Unknown runtime snapshot phase")
|
||||||
|
path = directory / FILENAME
|
||||||
|
try:
|
||||||
|
payload = {"schema_version": 1}
|
||||||
|
if path.is_file() and path.stat().st_size <= 256 * 1024:
|
||||||
|
try:
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(saved, dict):
|
||||||
|
payload = saved
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logger.debug("Previous runtime snapshot unreadable")
|
||||||
|
if phase == "start" and phase in payload:
|
||||||
|
return
|
||||||
|
payload[phase] = {
|
||||||
|
"captured_at": time.time(),
|
||||||
|
"runtime": runtime_identity(android),
|
||||||
|
"memory": _memory_snapshot(),
|
||||||
|
"adb_server": _adb_server() if android else None,
|
||||||
|
}
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
except (OSError, psutil.Error, ValueError, TypeError):
|
||||||
|
logger.warning("Could not save runtime snapshot (%s)", phase, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
|
||||||
|
"""Keep optional evidence failures outside the task's execution result."""
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(write_runtime_diagnostics, directory, phase, android=android)
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
logger.warning("Could not start runtime snapshot (%s)", phase, exc_info=True)
|
||||||
@@ -654,6 +654,13 @@ class Agent:
|
|||||||
# publish a new active session while the current task is still
|
# publish a new active session while the current task is still
|
||||||
# finishing.
|
# finishing.
|
||||||
self._prepare_tracing(task=task, context=context)
|
self._prepare_tracing(task=task, context=context)
|
||||||
|
from artemis.runtime.diagnostics import capture_runtime_diagnostics
|
||||||
|
|
||||||
|
await capture_runtime_diagnostics(
|
||||||
|
context.data_engine.base_dir,
|
||||||
|
"start",
|
||||||
|
android=request.execution_target != "computer",
|
||||||
|
)
|
||||||
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||||
from artemis.runtime.task_control import TaskControl
|
from artemis.runtime.task_control import TaskControl
|
||||||
|
|
||||||
@@ -1333,6 +1340,14 @@ class Agent:
|
|||||||
|
|
||||||
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
|
if task.status == "failed":
|
||||||
|
from artemis.runtime.diagnostics import capture_runtime_diagnostics
|
||||||
|
|
||||||
|
await capture_runtime_diagnostics(
|
||||||
|
context.data_engine.base_dir,
|
||||||
|
"failure",
|
||||||
|
android=context.execution_target != "computer",
|
||||||
|
)
|
||||||
session_status = "completed"
|
session_status = "completed"
|
||||||
if task.status == "failed":
|
if task.status == "failed":
|
||||||
session_status = "failed"
|
session_status = "failed"
|
||||||
|
|||||||
@@ -748,10 +748,15 @@ class RobustChatModelWrapper:
|
|||||||
if observation is not None and (usage := usage_from_message(response)) is not None:
|
if observation is not None and (usage := usage_from_message(response)) is not None:
|
||||||
observation.data["usage"] = usage
|
observation.data["usage"] = usage
|
||||||
ensure_response_complete(response)
|
ensure_response_complete(response)
|
||||||
if self.structured_mode == "parsed" and isinstance(response, dict) and "raw" in response:
|
if self.structured_mode == "parsed":
|
||||||
|
if isinstance(response, dict) and "raw" in response:
|
||||||
if response.get("parsing_error") is not None:
|
if response.get("parsing_error") is not None:
|
||||||
raise response["parsing_error"]
|
raise ResponseIntegrityError("invalid_structured_output") from response[
|
||||||
return response["parsed"]
|
"parsing_error"
|
||||||
|
]
|
||||||
|
response = response.get("parsed")
|
||||||
|
if response is None:
|
||||||
|
raise ResponseIntegrityError("missing_structured_output")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def ainvoke(self, *args, **kwargs):
|
async def ainvoke(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from langchain_core.messages import ToolMessage
|
|||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from artemis.agents.hopper.hopper import HopperOutput, hopper
|
from artemis.agents.hopper.hopper import HopperError, HopperOutput, hopper
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.controllers.platform_specific_commands_controller import (
|
from artemis.controllers.platform_specific_commands_controller import (
|
||||||
list_packages_async,
|
list_packages_async,
|
||||||
@@ -53,7 +53,7 @@ LAUNCH_APP_DOCSTRING = (
|
|||||||
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
|
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
|
||||||
"""Finds the package name for a given application name.
|
"""Finds the package name for a given application name.
|
||||||
|
|
||||||
Returns None if package not found or on error.
|
Returns None if no installed package matches. Model failures raise HopperError.
|
||||||
"""
|
"""
|
||||||
package_cache = getattr(ctx, "package_cache", None)
|
package_cache = getattr(ctx, "package_cache", None)
|
||||||
if package_cache is None or not isinstance(package_cache, dict):
|
if package_cache is None or not isinstance(package_cache, dict):
|
||||||
@@ -63,7 +63,7 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
|||||||
except Exception: # pylint: disable=broad-exception-caught
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
package_cache = {}
|
package_cache = {}
|
||||||
|
|
||||||
if isinstance(package_cache, dict) and app_name in package_cache:
|
if isinstance(package_cache, dict) and package_cache.get(app_name):
|
||||||
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
|
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
|
||||||
return package_cache[app_name]
|
return package_cache[app_name]
|
||||||
|
|
||||||
@@ -84,8 +84,6 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
|||||||
use_fallback=use_fallback,
|
use_fallback=use_fallback,
|
||||||
)
|
)
|
||||||
if not hopper_output.found or not hopper_output.output:
|
if not hopper_output.found or not hopper_output.output:
|
||||||
if isinstance(package_cache, dict):
|
|
||||||
package_cache[app_name] = None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
package_name = hopper_output.output.strip()
|
package_name = hopper_output.output.strip()
|
||||||
@@ -94,13 +92,13 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
|
|||||||
f"Hopper returned package '{package_name}' for '{app_name}', "
|
f"Hopper returned package '{package_name}' for '{app_name}', "
|
||||||
"but it is NOT physically installed on the device!"
|
"but it is NOT physically installed on the device!"
|
||||||
)
|
)
|
||||||
if isinstance(package_cache, dict):
|
|
||||||
package_cache[app_name] = None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(package_cache, dict):
|
if isinstance(package_cache, dict):
|
||||||
package_cache[app_name] = package_name
|
package_cache[app_name] = package_name
|
||||||
return package_name
|
return package_name
|
||||||
|
except HopperError:
|
||||||
|
raise
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error(f"Failed to find package for '{app_name}': {e}")
|
logger.error(f"Failed to find package for '{app_name}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ Flash 开关保存在全局配置中,网页切换后自动保存,失败恢
|
|||||||
|
|
||||||
诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。
|
诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。
|
||||||
|
|
||||||
|
新任务还会在开始和失败收尾时保存一次运行环境快照,单任务“全部资料”和批次 ZIP 各任务目录收录为 `运行环境诊断.json`。内容包括 ARTEMIS/Python 版本、可取得的源码提交与本地修改标记、实际解析的 ADB/scrcpy 路径、版本与 SHA256,以及采集时的内存和本机 ADB 监听进程信息;仅 PC 任务不查询安卓工具。Windows 的系统提交量、提交上限与余量按字节记录,不代表页面文件单独大小。快照不持续监控、不控制任务派发,也不能还原采集间隔内的内存变化;远程 ADB 进程、未保留的安装 ZIP 哈希及旧任务缺失字段均保留未知,导出时不会用当前环境补写历史。批次重启仍需手动继续,原暂停原因保留,恢复事件另行留档。
|
||||||
|
|
||||||
Flash、Pro 和探索的最终验收使用独立总预算,不再复用中间检查点的 180 秒时限。预算按实际 Checker 的单次请求超时 `T`、重试次数 `R`,以及普通/限流重试间隔的较大值 `D` 自动计算:`min(3600, T × (3 + R) + D × R + 60)` 秒。它为取证、结论及必要的结构化整理预留三次正常请求时间,再共用一份重试时间余量和 60 秒工具读取余量;不会按 20 轮循环逐轮放大。每个请求仍最多重试 `R` 次,但全部请求和重试必须在总预算内完成。默认配置的上限为 2760 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。
|
Flash、Pro 和探索的最终验收使用独立总预算,不再复用中间检查点的 180 秒时限。预算按实际 Checker 的单次请求超时 `T`、重试次数 `R`,以及普通/限流重试间隔的较大值 `D` 自动计算:`min(3600, T × (3 + R) + D × R + 60)` 秒。它为取证、结论及必要的结构化整理预留三次正常请求时间,再共用一份重试时间余量和 60 秒工具读取余量;不会按 20 轮循环逐轮放大。每个请求仍最多重试 `R` 次,但全部请求和重试必须在总预算内完成。默认配置的上限为 2760 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。
|
||||||
|
|
||||||
页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。
|
页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ARTEMIS 2.7.1
|
||||||
|
|
||||||
|
本版在正式 2.7.0 基础上改进 ADB 一致性、模型提取失败处理和故障证据留存,便于排查设备离线及批次中断。
|
||||||
|
|
||||||
|
## 任务操作与录屏共用 ADB
|
||||||
|
|
||||||
|
- scrcpy 录屏显式使用 ARTEMIS 选定的 ADB 可执行文件和服务端地址,覆盖普通录屏、分段及驱动直接录屏入口,减少各自选择不同 ADB 的情况。
|
||||||
|
- 运行环境诊断记录解析到的 ADB/scrcpy 路径、版本和 SHA256,以及可取得的本机 ADB 监听进程信息;远程服务端进程保留未知。
|
||||||
|
|
||||||
|
## 模型空结果与应用查找
|
||||||
|
|
||||||
|
- 结构化模型输出为空或解析失败时,进入现有的有限重试和兜底流程;Hopper 对“已找到但提取内容为空”的结果也执行校验,取消请求仍直接中断。
|
||||||
|
- 模型提取或初始化失败明确报错,与有效查询后未找到已安装应用分开;仅缓存已确认安装的包名,避免失败结果影响后续查找。
|
||||||
|
|
||||||
|
## 故障留档与批次恢复
|
||||||
|
|
||||||
|
- 新任务在开始和失败收尾时分别保存运行环境快照,包含程序/Python 版本、可取得的源码提交与本地修改标记、工具来源、宿主内存及任务进程内存。Windows 系统提交量、提交上限和余量按字节记录,不能当作页面文件单独大小。
|
||||||
|
- 单任务“全部资料”及批次 ZIP 各任务目录增加 `运行环境诊断.json`。升级前的批次 ZIP 缓存会重新打包;旧任务未采集的运行信息保留未知,导出时不使用当前环境补写历史。
|
||||||
|
- 服务重启后保留原暂停原因,恢复事件单独记录,批次仍需手动继续。诊断采集失败仅记日志,不改变任务结果。
|
||||||
|
- 快照仅在上述时点采集,不增加持续内存监控、保护阈值、自动暂停或录屏处理并发限制。它们用于减少干扰和保留证据,尚不能证明本次 ADB 崩溃的唯一原因,也不代表宿主内存压力已经解决。
|
||||||
|
|
||||||
|
详见[工作区诊断说明](../automation-workspace.md)。
|
||||||
|
|
||||||
|
## 版本与验证
|
||||||
|
|
||||||
|
- 主程序与前端根版本升级到 2.7.1;SDK、插件、Android 助手及锁定的第三方依赖保持现有独立版本。
|
||||||
|
- 回归覆盖录屏 ADB 环境隔离、模型空结果与解析错误重试、Hopper 兜底与取消、包名缓存、批次恢复、诊断导出和旧 ZIP 缓存升级。
|
||||||
|
- 最终源码 ZIP 使用独立解压目录和空 npm 缓存,分别验证官方源和国内镜像的锁定安装与生产构建。实际运行时、源码提交、SHA256、检查结果和未测范围见附件 `INSTALL-VERIFICATION.md`。
|
||||||
|
- 本次不恢复用户批次,不新增实机业务测试或付费模型调用;自动化回归不能代替现场内存压力与 ADB 崩溃复现。
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
已有安装等待任务结束后运行 `update.bat` 或 `update-cn.bat`,再重启 ARTEMIS;Codex 用户重新加载 MCP。升级程序会终止本项目运行进程,请保留需要继续的暂停任务。
|
||||||
|
|
||||||
|
新安装下载 `artemis-2.7.1-source.zip`,对照 `SHA256SUMS.txt` 校验后解压启动,不覆盖正在运行的安装目录。
|
||||||
|
|
||||||
|
继续沿用 [2.7.0 的 WebSocket 默认协议及一次性配置迁移](2.7.0.md#默认协议与旧配置迁移)。已经手动切回 HTTP 的配置保持原选择。
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "2.7.0"
|
version = "2.7.1"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -181,18 +181,23 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
|||||||
exporter.assert_not_called()
|
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 = service.create_batch(payload())
|
||||||
batch_id = batch["id"]
|
batch_id = batch["id"]
|
||||||
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||||
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
path.parent.mkdir(parents=True)
|
path.parent.mkdir(parents=True)
|
||||||
path.write_bytes(b"legacy archive")
|
path.write_bytes(b"legacy archive")
|
||||||
|
if signature:
|
||||||
|
reports.remember(path, signature)
|
||||||
base = f"/api/automation/batches/{batch_id}/archive"
|
base = f"/api/automation/batches/{batch_id}/archive"
|
||||||
assert client.get(base + "/status").json()["status"] == "idle"
|
assert client.get(base + "/status").json()["status"] == "idle"
|
||||||
response = client.get(base)
|
response = client.get(base)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert zipfile.is_zipfile(BytesIO(response.content))
|
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"
|
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"]
|
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
|
||||||
archive = reports.export_batch(batch["id"])
|
archive = reports.export_batch(batch["id"])
|
||||||
with zipfile.ZipFile(archive) as z:
|
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")]
|
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
|
||||||
assert len(diagnostics) == 2
|
assert len(diagnostics) == 2
|
||||||
for name in diagnostics:
|
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"}
|
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(
|
def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
|
||||||
isolated, monkeypatch
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_trace_finalization_failure_does_not_escape():
|
async def test_trace_finalization_failure_does_not_escape():
|
||||||
agent = object.__new__(Agent)
|
agent = object.__new__(Agent)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for the LLM gateway: complete(), classified recovery, and fallback."""
|
"""Tests for the LLM gateway: complete(), classified recovery, and fallback."""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||||
import pytest
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_gateway_state(monkeypatch, tmp_path):
|
def _isolate_gateway_state(monkeypatch, tmp_path):
|
||||||
llm_service._ENDPOINT_BREAKER._states.clear()
|
llm_service._ENDPOINT_BREAKER._states.clear()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import pytest
|
|||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||||
from artemis.utils.video import (
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
|
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
|
||||||
silent_video = tmp_path / "silent.mp4"
|
silent_video = tmp_path / "silent.mp4"
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
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.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
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_cont
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
@patch("artemis.tools.mobile.launch_app.list_packages_async")
|
||||||
@patch("artemis.tools.mobile.launch_app.hopper")
|
@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"
|
mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
|
||||||
|
|
||||||
# Hopper doesn't find it
|
# 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")
|
pkg1 = await find_package(mock_context, "App 3")
|
||||||
assert pkg1 is None
|
assert pkg1 is None
|
||||||
assert mock_hopper.call_count == 1
|
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.reset_mock()
|
||||||
|
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
|
||||||
pkg2 = await find_package(mock_context, "App 3")
|
pkg2 = await find_package(mock_context, "App 3")
|
||||||
assert pkg2 is None
|
assert pkg2 == "com.example.app1"
|
||||||
mock_hopper.assert_not_called()
|
mock_hopper.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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",
|
data="com.example.app1\ncom.example.app2",
|
||||||
use_fallback=False,
|
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