Compare commits

..
11 Commits
105 changed files with 4565 additions and 1511 deletions

No files matched your search

+5
View File
@@ -62,6 +62,10 @@ OCR_API_KEY=
ADB_HOST=127.0.0.1
ADB_PORT=5037
# ADB_DEVICE_SERIAL=
# 自动发现本机模拟器,并重连通过网页连接过的 TCP 设备;不扫描局域网。
ARTEMIS_ADB_AUTO_CONNECT=true
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS=10
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS=5
ARTEMIS_KEEP_DEVICE_AWAKE=true
ARTEMIS_HIERARCHY_BACKEND=auto
ARTEMIS_HELPER_AUTO_INSTALL=true
@@ -69,4 +73,5 @@ ARTEMIS_DEFAULT_PROFILE=flash
# 批量任务与 Excel 导入限制
ARTEMIS_IMPORT_MAX_ROWS=1000
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
ARTEMIS_IMPORT_MAX_BYTES=10485760
+2
View File
@@ -0,0 +1,2 @@
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
*.bat text eol=crlf
+2 -1
View File
@@ -27,7 +27,7 @@
| 核心类型检查 | `uv run pyright --project pyright-core.json` |
| 源码打包 | `uv run --no-sync python scripts/package_source.py` |
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开发中需要保留当前源码时传入 `-SkipUpdate`,避免启动脚本拉取远端更新。
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启动只提示源码更新;升级使用 `update.bat` / `update-cn.bat`,会终止本项目运行进程。开发中可传入 `-SkipUpdate` 跳过更新提示。
## Key Paths
@@ -58,6 +58,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
- `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。
## Definition of Done
+10 -8
View File
@@ -29,17 +29,17 @@ cd Artemis-Eapil
常用选项:
```powershell
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
.\start.bat -Port 8001 # 指定端口
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
.\start.bat -SkipUpdate # 跳过源码更新提示
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 国内下载源失败时自动尝试备用源,每个安装包均校验官方 SHA256;也可预先安装,或通过 `overrides.scrcpy_url` 和 `scrcpy_fallback_urls` 自定义 HTTPS 地址。
### macOS / Linux
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和启动时自动更新目前由 Windows 脚本提供。
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和独立升级脚本目前由 Windows 提供。
## 配置与使用
@@ -58,13 +58,13 @@ cd Artemis-Eapil
## 让 AI 使用
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装:
```sh
uv run artemis mcp --install codex
```
安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
示例指令:
@@ -72,9 +72,11 @@ uv run artemis mcp --install codex
## 更新与排错
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
启动脚本只检查并提示源码更新,不自动拉取。升级运行 **`.\update.bat`**,国内网络运行 **`.\update-cn.bat`**;支持 `-Help`。升级会终止当前项目的 ARTEMIS 服务、MCP 和任务进程,然后快进更新源码,同步依赖、网页构建及 Codex MCP。升级程序先复制到项目外运行,避免更新正在执行的脚本;其他项目、Codex 本身、模拟器和共享 ADB 服务不会被终止。完成后重新启动 ARTEMIS,并在 Codex 中重载 MCP。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
本地修改、分支分叉或网络认证失败会停止升级,保留现有文件;`.env`、密钥和历史资料不会清空。启动/升级中的依赖或 MCP 同步失败会明确报错,修复后可重新执行;仅启动网页时可用 `-SkipMcpSync`。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树;包含独立升级入口的新版源码包可直接用上述脚本升级。1.1.0 及更早 ZIP 不含 Git 信息,需改用 Git 克隆或新版源码包。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
+74 -4
View File
@@ -13,6 +13,7 @@ from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import
from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result
router = APIRouter(prefix="/api/automation", tags=["automation"])
@@ -23,6 +24,12 @@ class ReportPreferences(BaseModel):
)
class CaseReviewRequest(BaseModel):
model_config = {"extra": "forbid"}
status: Literal["passed", "failed"]
reason: str = Field(default="", max_length=2000)
class BatchRequest(ReportPreferences):
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
name: str = Field(min_length=1, max_length=200)
@@ -30,6 +37,7 @@ class BatchRequest(ReportPreferences):
device_serial: str = Field(min_length=1, max_length=200)
profile: Literal["flash", "pro"] = "flash"
stop_on_failure: bool = False
repeat_count: int = Field(default=1, ge=1, strict=True)
timeout_seconds: int = Field(
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
ge=30,
@@ -52,6 +60,7 @@ def preferences():
return {
**store.preferences(),
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
}
@@ -120,7 +129,12 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}")
def batch_detail(batch_id: str):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
jobs = store.jobs(batch_id)
for job in jobs:
job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store
)
return {**require("batch", batch_id), "jobs": jobs}
@router.delete("/batches/{batch_id}")
@@ -199,6 +213,42 @@ async def download_report(
raise HTTPException(404, str(exc)) from exc
@router.put("/reports/{session_id}/review")
def review_case(session_id: str, value: CaseReviewRequest):
report(session_id)
job = store.job(session_id) or {}
batch_id = job.get("batch_id")
if (
batch_id
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
):
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
with report_export.EXPORT_LOCK:
current = report(session_id)
if current["status"] not in {
"completed",
"success",
"failed",
"cancelled",
"interrupted",
"skipped",
}:
raise HTTPException(409, "请在任务结束后进行人工复验")
try:
store.save_case_review(session_id, value.status, value.reason)
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
if batch_id:
archive_service.forget(batch_id)
# A finishing worker can publish report_ready just after export;
# removing only the generated ZIP also prevents that stale cache hit.
try:
archive_service.archive_path(batch_id).unlink(missing_ok=True)
except OSError:
pass # An open download may hold the file; report_ready is already invalidated.
return report(session_id)
@router.get("/history")
def history(
q: str = Query(default="", max_length=200),
@@ -207,6 +257,7 @@ def history(
end: float | None = None,
page: int = Query(default=1, ge=1),
size: int = Query(default=30, ge=1, le=100),
batch_id: str = Query(default="", max_length=100),
):
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
query = """WITH history AS (
@@ -218,7 +269,7 @@ def history(
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
COALESCE(json_extract(j.payload,'$.error'),'') error
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
@@ -229,11 +280,22 @@ def history(
COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
COALESCE(json_extract(j.payload,'$.error'),'')
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
args = []
if batch_id == "__batch__":
where += " AND batch_id IS NOT NULL"
elif batch_id == "__single__":
where += " AND batch_id IS NULL"
elif batch_id:
try:
identifier(batch_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
where += " AND batch_id = ?"
args.append(batch_id)
if q:
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
@@ -256,7 +318,15 @@ def history(
[*args, size, (page - 1) * size],
).fetchall()
items = [dict(row) for row in rows]
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
for item in items:
if item["case_id"]:
item["test_result"] = read_reviewed_result(
item["session_id"], item["status"], item.get("error") or "", storage=store
)
if job := jobs.get(item["session_id"]):
item["name"] = task_import.execution_title(job)
item["iteration"] = job.get("iteration", 1)
item["repeat_count"] = job.get("repeat_count", 1)
elif item["case_id"]:
item["name"] = task_import.case_title(item)
return {"items": items, "total": total, "page": page, "size": size}
+21 -4
View File
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException
from artemis.config import DB_PATH, TRACES_PATH
from artemis.runtime import trace_store
from apps.admin_console.services.case_review import read_reviewed_result
try:
from admin_console.core.state import state
@@ -28,7 +29,7 @@ try:
from admin_console.services.media_service import media_service
from admin_console.services.model_service import model_service
from admin_console.services.automation_store import store
from admin_console.services.task_import import case_title
from admin_console.services.task_import import execution_title
except ImportError:
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
@@ -36,7 +37,7 @@ except ImportError:
from apps.admin_console.services.media_service import media_service
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.task_import import case_title
from apps.admin_console.services.task_import import execution_title
router = APIRouter(tags=["sessions"])
@@ -82,8 +83,11 @@ def _list_sessions_sync():
job = jobs_by_id.get(s_id)
if job:
case = job.get("case") or {}
row_dict["batch_id"] = job.get("batch_id")
row_dict["case_id"] = case.get("case_id") or ""
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
row_dict["iteration"] = job.get("iteration", 1)
row_dict["repeat_count"] = job.get("repeat_count", 1)
row_dict["error"] = job.get("error") or ""
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
row_dict["status"] = job["status"]
@@ -128,6 +132,13 @@ def _list_sessions_sync():
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
row_dict["test_result"] = read_reviewed_result(
s_id,
row_dict.get("status") or "",
row_dict.get("error") or "",
root=TRACES_PATH,
storage=store,
)
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
@@ -187,7 +198,13 @@ async def get_session_details(session_id: str):
row = session_repo.get_session_by_id(session_id)
if not row:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return dict(row)
result = dict(row)
job = store.job(session_id) or {}
result["batch_id"] = job.get("batch_id")
result["test_result"] = read_reviewed_result(
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
)
return result
@router.get("/api/sessions/{session_id}/usage")
+17 -1
View File
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.task_import import execution_title
from artemis.runtime import DeviceExecutionLock, device_pool
from artemis.runtime.task_control import (
add_instruction,
@@ -274,7 +276,12 @@ async def get_run_defaults():
async def list_devices():
"""List all connected Android devices with their busy / idle status."""
devices = await device_pool.list_devices_async()
return {"devices": [d.to_dict() for d in devices]}
from artemis.runtime.adb_discovery import adb_device_discovery
return {
"devices": [d.to_dict() for d in devices],
"automatic_connections": adb_device_discovery.results.copy(),
}
@router.post("/api/stop")
@@ -418,6 +425,15 @@ async def get_status():
seen_ids.add(sid)
queue_data.append(g_item)
batch_jobs = store.jobs_for_sessions(
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
)
queue_data = [{**item} for item in queue_data]
for item in [*queue_data, *active_tasks]:
if job := batch_jobs.get(str(item.get("session_id"))):
item["batch_id"] = job.get("batch_id")
item["display_name"] = execution_title(job)
if is_running:
is_paused = state.is_paused
return {
@@ -9,7 +9,7 @@ from uuid import UUID
from apps.admin_console.core.state import state
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
from apps.admin_console.services.task_import import case_goal, case_title
from apps.admin_console.services.task_import import case_goal, execution_title
from artemis.config.settings import settings
from artemis.runtime.task_limits import task_timeout
@@ -297,7 +297,7 @@ async def tick_batches():
runtime_overrides=options,
session_id=job["session_id"],
ingress="batch",
display_name=case_title(job["case"]),
display_name=execution_title(job),
)
if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务")
@@ -330,6 +330,15 @@ def has_legacy_replay(batch_id: str) -> bool:
def create_batch(payload: dict) -> dict:
repeat_count = payload.get("repeat_count", 1)
if type(repeat_count) is not int or repeat_count < 1:
raise ValueError("循环次数必须为正整数")
cases = payload["cases"]
total = len(cases) * repeat_count
if not cases or total > settings.ARTEMIS_BATCH_MAX_EXECUTIONS:
raise ValueError(
f"请选择用例;用例数 × 循环次数不能超过 {settings.ARTEMIS_BATCH_MAX_EXECUTIONS} 次执行"
)
bid = new_id()
batch = {
"id": bid,
@@ -342,21 +351,28 @@ def create_batch(payload: dict) -> dict:
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
"stop_on_failure": payload.get("stop_on_failure", False),
"request_id": payload.get("request_id"),
"repeat_count": repeat_count,
"case_count": len(cases),
"total_executions": total,
}
jobs = []
for number, case in enumerate(payload["cases"], 1):
jobs.append(
{
"session_id": new_id(),
"batch_id": bid,
"position": number,
"case": case,
"status": "pending",
"formats": batch["formats"],
"created_at": time.time(),
"device_serial": batch["device_serial"],
}
)
for iteration in range(1, repeat_count + 1):
for number, case in enumerate(cases, 1):
jobs.append(
{
"session_id": new_id(),
"batch_id": bid,
"position": len(jobs) + 1,
"iteration": iteration,
"repeat_count": repeat_count,
"case_position": number,
"case": case,
"status": "pending",
"formats": batch["formats"],
"created_at": batch["created_at"],
"device_serial": batch["device_serial"],
}
)
return store.create_batch(batch, jobs, payload)
@@ -412,7 +428,13 @@ def batch_action(batch_id: str, action: str) -> dict:
if not failed:
raise ValueError("没有可重跑的失败或中断任务")
revised = create_batch(
{**batch, "name": batch["name"] + " · 失败项重跑", "cases": [j["case"] for j in failed]}
{
**batch,
"name": batch["name"] + " · 失败项重跑",
"cases": [j["case"] for j in failed],
"repeat_count": 1,
"request_id": None,
}
)
return revised
else:
@@ -149,6 +149,52 @@ class AutomationStore:
def preferences(self) -> dict:
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
"""Append reviews atomically; invalidate the batch's generated report."""
identifier(session_id)
if status not in {"passed", "failed"}:
raise ValueError("人工复验结果只能为通过或不通过")
review = {
"status": status,
"reason": reason.strip()
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
"reviewed_at": time.time(),
}
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
(session_id,),
).fetchone()
record = json.loads(row[0]) if row else {"reviews": []}
record["reviews"].append(review)
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(
"case_review",
session_id,
json.dumps(record, ensure_ascii=False),
review["reviewed_at"],
),
)
job = conn.execute(
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
if job and job[0]:
batch = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
).fetchone()
if not batch or json.loads(batch[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能保存复验")
payload = json.loads(batch[0])
payload["report_ready"] = False
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(payload, ensure_ascii=False), review["reviewed_at"], job[0]),
)
conn.commit()
return review
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
request_id = payload.get("request_id")
@@ -247,6 +293,11 @@ class AutomationStore:
batch = json.loads(row[0])
if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除")
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,),
)
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
@@ -260,9 +311,14 @@ class AutomationStore:
with self.connection() as conn:
if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
(session_id,),
)
else:
conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
conn.commit()
@@ -0,0 +1,40 @@
"""Human review overlays the AI verdict without changing execution evidence."""
from pathlib import Path
from apps.admin_console.services.automation_store import AutomationStore
from artemis.runtime.case_result import read_case_result
def reviewed_result(
session_id: str, ai_result: dict | None, storage: AutomationStore
) -> dict | None:
original = {**ai_result, "source": "ai"} if ai_result else None
record = storage.get("case_review", session_id)
reviews = (record or {}).get("reviews") or []
if not reviews:
return original
latest = reviews[-1]
return {
"status": latest["status"],
"reason": latest["reason"],
"evidence": [],
"source": "human",
"reviewed_at": latest["reviewed_at"],
"ai_result": original,
}
def read_reviewed_result(
session_id: str,
execution_status: str,
error: str = "",
*,
storage: AutomationStore,
root: Path | None = None,
) -> dict | None:
return reviewed_result(
session_id,
read_case_result(session_id, execution_status, error, root=root),
storage,
)
@@ -166,6 +166,13 @@ def persist_environment(values: dict[str, str]) -> None:
class CapabilitySettings(BaseModel):
adb_auto_connect: bool = Field(default_factory=lambda: settings.ARTEMIS_ADB_AUTO_CONNECT)
adb_discovery_interval_seconds: float = Field(
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS, ge=1, le=300
)
adb_discovery_timeout_seconds: float = Field(
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS, gt=0, le=30
)
video_enabled: bool = False
recording_enabled: bool = False
outputter_enabled: bool = True
@@ -206,6 +213,9 @@ def get_capability_settings() -> CapabilitySettings:
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
values = {
"ARTEMIS_ADB_AUTO_CONNECT": update.adb_auto_connect,
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
"ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS": update.adb_discovery_timeout_seconds,
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
+38 -5
View File
@@ -22,7 +22,9 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.database.repositories.step_repository import StepRepository
from apps.admin_console.services.automation_store import store, identifier
from apps.admin_console.services.task_import import case_title
from apps.admin_console.services.task_import import execution_title
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock()
@@ -62,6 +64,12 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result")
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
label = RESULT_LABELS[result["status"]]
if result.get("source"):
label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
return label
tests = (outcome or {}).get("tests") or {}
if tests.get("failed", 0):
return "验证失败"
@@ -179,6 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
case = meta.get("case") or {}
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
error = failure_reason(directory, status, meta.get("error"))
ai_result = resolve_case_result(outcome, status, error)
test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store)
if test_result:
outcome = {**outcome, "test_result": test_result}
verification = verdict(outcome)
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)"
@@ -190,11 +202,14 @@ def session_report(session_id: str, db_path=None) -> dict:
)
return {
"id": session_id,
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
"iteration": meta.get("iteration", 1),
"repeat_count": meta.get("repeat_count", 1),
"goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""),
"status": status,
"verdict": verification,
"test_result": test_result,
"outcome": outcome,
"content": content,
"steps": steps,
@@ -231,6 +246,18 @@ def metadata(report: dict) -> list[tuple]:
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
("验证结论", report["verdict"]),
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
(
"原 AI 结论",
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
if (report.get("test_result") or {}).get("source") == "human"
else "—",
),
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
(
"验收证据",
"\n".join((report.get("test_result") or {}).get("evidence") or []) or "未记录",
),
("开始时间", time_text(report["start"])),
("结束时间", time_text(report["end"])),
("设备", report["device"]),
@@ -432,6 +459,11 @@ def word_report(report: dict, path: Path):
if report["error"]:
doc.add_heading("异常原因", 1)
doc.add_paragraph(text(report["error"]))
if result := report.get("test_result"):
doc.add_heading("测试结论与依据", 1)
doc.add_paragraph(text(result.get("reason") or "未记录"))
for evidence in result.get("evidence") or []:
doc.add_paragraph(text(evidence), style="List Bullet")
doc.add_heading("任务信息", 1)
details = [
(label, value)
@@ -439,10 +471,11 @@ def word_report(report: dict, path: Path):
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
]
word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标", 2)
doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
doc.add_paragraph(report["goal"] or "未记录")
doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"] or "未指定")
if report["expected"]:
doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"])
if report.get("preconditions"):
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
if report.get("task_config"):
@@ -189,3 +189,11 @@ def case_title(case: dict) -> str:
case_id = str(case.get("case_id") or "").strip()
name = str(case.get("name") or "").strip()
return " ".join(part for part in (case_id, name) if part)
def execution_title(job: dict) -> str:
"""Distinguish repeated executions without altering the original case or prompt."""
title = case_title(job.get("case") or {})
if job.get("repeat_count", 1) > 1:
title += f" · 第 {job['iteration']} / {job['repeat_count']} 轮"
return title
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.3.1",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.3.1",
"version": "1.5.1",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.3.1",
"version": "1.5.1",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -44,7 +44,7 @@ limitations under the License.
</button>
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
</div>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue -->
<ng-template
@@ -61,6 +61,7 @@ limitations under the License.
<span class="material-symbols-outlined">list_alt</span>
<span>任务队列</span>
</div>
<app-task-batch-filter />
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span>
@@ -86,6 +87,7 @@ limitations under the License.
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
<div class="item-header-actions">
@if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -549,7 +551,7 @@ limitations under the License.
}
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
<div class="phase-container">
@if (hasVisibleBlocks(phase)) {
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
<div
class="phase-header"
[class.first-phase]="first && startupWorkItems().length === 0"
@@ -584,6 +586,9 @@ limitations under the License.
and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). -->
@if (isCheckerBlock(block)) {
@if (block.data.phase === 'outcome' && block.data.test_result) {
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
} @else {
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
<div class="check-header">
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
@@ -605,6 +610,7 @@ limitations under the License.
</ol>
}
</div>
}
}
<!-- Text, tool calls, and actions share one chronological timeline. -->
@if (getSortedStepEvents(block.data).length > 0) {
@@ -1268,7 +1274,7 @@ limitations under the License.
</div>
}
<!-- Check block result: one row per check item, keyed by its number above -->
@if (hasCheckerResult(block)) {
@if (hasCheckerResult(block) && !(block.data.phase === 'outcome' && block.data.test_result)) {
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
@for (verdict of getCheckerVerdicts(block); track $index) {
<div class="check-verdict" [ngClass]="verdict.status">
@@ -3702,9 +3702,12 @@ $stream-row-height: 25px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700;
padding: 2px 5px;
padding: 0 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
@@ -25,6 +25,9 @@ import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
@@ -237,7 +240,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -388,7 +391,7 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
@@ -852,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
// Top Nav Action Methods
public openBatchQueue(): void {
this.isAutomationOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isTaskDropdownOpen.set(true);
}
public toggleTaskDropdown(event: Event): void {
event.stopPropagation();
this.isTaskDropdownOpen.update(v => !v);
@@ -1754,7 +1763,9 @@ export class AgentStreamComponent implements AfterViewInit {
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
public getCheckerNotes(block: any): string[] {
const d = block?.data || {};
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
if (d.phase === 'outcome') {
return [d.test_result?.reason, ...(d.test_result?.evidence || []), ...(Array.isArray(d.last_findings) ? d.last_findings : [])].filter(Boolean);
}
const notes: string[] = [];
if (Array.isArray(d.unmet_subgoals)) {
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
@@ -1796,7 +1807,9 @@ export class AgentStreamComponent implements AfterViewInit {
public getCheckerTone(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive');
if (d.test_result) return d.test_result.status === 'blocked' ? 'inconclusive' : d.test_result.status;
if (d.tests?.failed) return 'failed';
return d.tests?.passed && !d.tests?.unchecked && !d.tests?.inconclusive ? 'passed' : 'inconclusive';
}
if (d.isCompleted === false) return 'running';
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
@@ -1811,12 +1824,12 @@ export class AgentStreamComponent implements AfterViewInit {
const d = block?.data || {};
if (d.phase === 'outcome') {
const t = d.tests || {};
const label = d.task_status === 'completed' ? "目标已完成"
: (d.task_status === 'blocked' ? "受阻" : "部分完成");
const label = d.test_result ? TEST_RESULT_LABELS[d.test_result.status as keyof typeof TEST_RESULT_LABELS]
: (d.task_status === 'completed' ? "执行完成" : (d.task_status === 'blocked' ? "执行受阻" : "部分完成"));
const counts = [
[t.passed, 'passed'],
[t.failed, 'failed'],
[t.inconclusive, 'inconclusive'],
[t.passed, '项通过'],
[t.failed, '项不通过'],
[t.inconclusive, '项无法确认'],
[t.unchecked, "未检查"]
]
.filter(([n]) => Number(n) > 0)
@@ -5,6 +5,7 @@
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
@if (tab() === 'reports') {
@if(batch(); as b){<button type="button" class="back-to-batch" (click)="tab.set('batches')">返回批次:{{b.name}}</button>}
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
@@ -14,10 +15,11 @@
@if (report(); as r) {
<div class="report-overview">
<strong class="report-title">{{r.name}}</strong>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div>
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
@@ -47,27 +49,44 @@
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
@if(preview(); as p) {
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
<div class="selection-toolbar"><span>已选 <strong>{{selectedCount()}}</strong> / {{p.tasks.length}} 个用例 · 错误 {{p.errors.length}} 条</span><button type="button" [disabled]="busy() || !p.tasks.length" (click)="toggleAllRows()">{{selectedCount() === p.tasks.length ? '全不选' : '全选'}}</button></div>
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
<div class="case-preview-list" aria-label="选择导入用例">
@for(t of p.tasks; track t.row){
<div class="case-preview" [class.unselected]="!selectedRows().has(t.row)">
<span class="selection-column"><input type="checkbox" [checked]="selectedRows().has(t.row)" [disabled]="busy()" (change)="toggleRow(t.row)" [attr.aria-label]="'导入用例:' + (t.case_id ? t.case_id + ' ' : '') + t.name"></span>
<div class="case-preview-content"><strong>{{t.case_id ? t.case_id + ' ' : ''}}{{t.name}}</strong><small>第 {{t.row}} 行</small>
<p class="case-goal" [title]="t.goal">{{t.goal}}</p>
@if(caseConfig(t.task_config); as config){<details><summary>自定义参数</summary><pre>{{config}}</pre></details>}@else{<small>使用批次 / 全局参数</small>}
</div>
</div>
}
</div>
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
<label for="batch-repeat-count">整批循环次数<input id="batch-repeat-count" type="number" min="1" [max]="maxExecutions" step="1" [(ngModel)]="repeatCount"></label>
<p class="execution-summary">{{selectedCount()}} 个用例 × {{repeatCount || 0}} 轮,共 {{selectedCount() * (repeatCount || 0)}} 次执行</p>
<p class="muted">每轮按表格顺序执行所选用例,分别保存结果。未勾选用例不会导入。</p>
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</p>}
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
}
</section>
<section><h3>批次记录</h3>
@for(b of batches(); track b.id){
<div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
</div>
}
@if(!batches().length){<p>暂无批次。</p>}
</section>
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
<div class="actions">
<button type="button" (click)="showBatchQueue(b.id)">在任务队列查看此批次</button>
@if(agent.currentSessionId()){<button type="button" (click)="tab.set('reports')">查看当前任务报告</button>}
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
@@ -87,6 +106,6 @@
</div>
}
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -1,4 +1,6 @@
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
@@ -16,6 +18,13 @@ input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-bo
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;}
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}}
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}}
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
@@ -12,7 +12,9 @@ describe('AutomationPanelComponent batch deletion', () => {
currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null),
agentStatus: signal('idle'),
sessions: signal<any[]>([])
sessions: signal<any[]>([]),
resultRevision: signal(0), batchFilter: signal(''), activeTab: signal('automation'),
selectSession: jasmine.createSpy('selectSession'), applyCaseReview: jasmine.createSpy('applyCaseReview')
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
@@ -22,6 +24,8 @@ describe('AutomationPanelComponent batch deletion', () => {
http.delete.and.returnValue(of({ status: 'deleted' }));
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
http.delete.calls.reset();
http.post.calls.reset();
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
await TestBed.configureTestingModule({
imports: [AutomationPanelComponent],
providers: [
@@ -32,6 +36,60 @@ describe('AutomationPanelComponent batch deletion', () => {
}).compileComponents();
});
it('selects all imported cases by default, toggles all and submits only checked cases with rounds', async () => {
const tasks = [
{ row: 2, case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } },
{ row: 3, case_id: '1.2', name: '观察首页', goal: '观察首页', task_config: {} }
];
(http.post.and as any).callFake((url: string) => of(url.endsWith('/preview')
? { valid: true, tasks, errors: [] } : url.endsWith('/batches')
? { id: 'selected-batch', status: 'draft' } : { id: 'selected-batch', status: 'running' }));
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
component.tab.set('batches');
component.upload({ target: { files: [new File(['test'], '用例.xlsx')] } } as unknown as Event);
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.detectChanges();
const checkboxes = () => [...fixture.nativeElement.querySelectorAll('.selection-column input')] as HTMLInputElement[];
expect(checkboxes().every(c => c.checked)).toBeTrue();
const toggle = fixture.nativeElement.querySelector('.selection-toolbar button') as HTMLButtonElement;
expect(toggle.textContent).toContain('全不选');
toggle.click(); fixture.detectChanges();
expect(component.selectedCount()).toBe(0);
expect((fixture.nativeElement.querySelector('.primary') as HTMLButtonElement).disabled).toBeTrue();
expect(toggle.textContent).toContain('全选');
toggle.click(); fixture.detectChanges();
expect(component.selectedCount()).toBe(2);
checkboxes()[1].click();
component.repeatCount = 3; component.device = 'test-device';
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.execution-summary').textContent).toContain('共 3 次执行');
component.startBatch();
await new Promise<void>(resolve => setTimeout(resolve, 0));
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
expect(request?.[1]).toEqual(jasmine.objectContaining({
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
repeat_count: 3, device_serial: 'test-device'
}));
});
it('rejects empty selections and invalid or excessive loop counts without creating a batch', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
component.preview.set({ valid: true, tasks: [{ row: 2, name: '检查', goal: '观察' }], errors: [] });
component.device = 'test-device';
component.startBatch();
await new Promise<void>(resolve => setTimeout(resolve, 0));
expect(component.error()).toContain('至少勾选');
component.selectedRows.set(new Set([2]));
for (const count of [0, 1.5, 10001]) {
component.repeatCount = count; component.startBatch();
await new Promise<void>(resolve => setTimeout(resolve, 0));
expect(component.error()).toContain('循环次数');
}
expect(http.post).not.toHaveBeenCalled();
});
it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
@@ -62,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
});
it('查看同批次用例时保留批次列表,可直接筛选该批次队列', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
component.tab.set('batches');
component.batch.set({id:'batch-1',name:'冒烟批次',status:'completed',jobs:[
{session_id:'task-a',status:'completed',case:{name:'用例 A',goal:'观察设置'}},
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]});
fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches');
expect(fixture.nativeElement.querySelectorAll('.job').length).toBe(2);
const queueRequested = jasmine.createSpy('queueRequested');
component.queueRequested.subscribe(queueRequested);
component.showBatchQueue('batch-1');
expect(agent.batchFilter()).toBe('batch-1'); expect(agent.activeTab()).toBe('tasks');
expect(queueRequested).toHaveBeenCalled();
fixture.destroy();
});
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.detectChanges();
@@ -1,4 +1,4 @@
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
@@ -6,6 +6,9 @@ import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model';
interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -18,13 +21,16 @@ interface ArchiveProgress {
}
@Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
})
export class AutomationPanelComponent {
queueRequested = output<void>();
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
batches = signal<any[]>([]); preview = signal<any>(null);
selectedRows = signal<Set<number>>(new Set());
selectedCount = computed(() => this.selectedRows().size);
batch = signal<any>(null); report = signal<any>(null);
archiveProgress = signal<ArchiveProgress | null>(null);
archiveBatchId = signal(''); archiveBusy = signal(false);
@@ -37,14 +43,16 @@ export class AutomationPanelComponent {
});
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
repeatCount = 1; maxExecutions = 10000;
private destroyed = false;
constructor() {
const destroy = inject(DestroyRef);
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; });
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; this.maxExecutions = p.batch_max_executions || 10000; });
void this.refresh();
const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
effect(() => {
this.agent.resultRevision();
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
});
@@ -52,6 +60,9 @@ export class AutomationPanelComponent {
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
openBatchTask(id: string) { this.agent.selectSession(id, true); }
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
private async prepareBatchArchive(id: string) {
@@ -104,13 +115,32 @@ export class AutomationPanelComponent {
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
upload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
void this.action(async () => { this.preview.set(null); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); });
void this.action(async () => { this.preview.set(null); this.selectedRows.set(new Set()); const data = new FormData(); data.append('file',file); const preview: any = await firstValueFrom(this.http.post('/api/automation/import/preview',data)); this.preview.set(preview); this.selectedRows.set(new Set<number>(preview.tasks.map((t: any) => t.row))); this.repeatCount = 1; this.name = file.name.replace(/\.xlsx$/i,''); });
}
toggleRow(row: number) {
const selected = new Set(this.selectedRows());
if (selected.has(row)) selected.delete(row); else selected.add(row);
this.selectedRows.set(selected);
}
toggleAllRows() {
const rows: number[] = (this.preview()?.tasks || []).map((t: any) => t.row);
this.selectedRows.set(this.selectedCount() === rows.length ? new Set() : new Set(rows));
}
validRepetitions() {
return Number.isInteger(this.repeatCount) && this.repeatCount >= 1
&& this.selectedCount() * this.repeatCount <= this.maxExecutions;
}
caseConfig(config: Record<string, unknown> | undefined) {
const overrides = Object.fromEntries(Object.entries(config || {}).filter(([, value]) => value !== null && value !== undefined));
return Object.keys(overrides).length ? JSON.stringify(overrides, null, 2) : '';
}
startBatch() { void this.action(async () => {
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
if (!this.device) throw Error('请选择执行设备');
const cases = this.preview().tasks.map(({row, ...c}: any) => c);
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
this.batch.set(created);
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
@@ -58,6 +58,7 @@ limitations under the License.
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter />
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -82,6 +83,7 @@ limitations under the License.
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -119,7 +121,7 @@ limitations under the License.
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
<div class="task-card-footer">
<app-task-id-copy [taskId]="session.session_id" />
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700;
padding: 2px 5px;
padding: 0 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
@@ -23,6 +23,8 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
@@ -31,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -54,7 +56,7 @@ export class ChatInterfaceComponent {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending';
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
@@ -4,6 +4,9 @@ import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
interface Capabilities {
adb_auto_connect: boolean;
adb_discovery_interval_seconds: number;
adb_discovery_timeout_seconds: number;
video_enabled: boolean;
recording_enabled: boolean;
outputter_enabled: boolean;
@@ -24,6 +27,8 @@ interface Capabilities {
<section aria-label="功能开关">
<h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -44,6 +49,8 @@ interface Capabilities {
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
<label>设备自动发现间隔(秒)<input type="number" min="1" max="300" [(ngModel)]="config.adb_discovery_interval_seconds" [disabled]="busy() || !loaded()" /></label>
<label>每轮自动连接预算(秒)<input type="number" min="1" max="30" [(ngModel)]="config.adb_discovery_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
</div>
</details>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
@@ -67,7 +74,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -89,6 +96,8 @@ export class CapabilitySettingsComponent implements OnInit {
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300;
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300
&& c.adb_discovery_interval_seconds >= 1 && c.adb_discovery_interval_seconds <= 300
&& c.adb_discovery_timeout_seconds > 0 && c.adb_discovery_timeout_seconds <= 30;
}
}
@@ -138,7 +138,7 @@ describe('持久化模型与功能设置', () => {
});
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
@@ -162,7 +162,7 @@ describe('持久化模型与功能设置', () => {
});
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
const capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
@@ -190,4 +190,26 @@ describe('持久化模型与功能设置', () => {
fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings');
});
it('自动连接默认开启,可独立关闭并保存发现预算', () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
expect(root.textContent).toContain('自动连接设备');
expect(fixture.componentInstance.config.adb_auto_connect).toBeTrue();
fixture.componentInstance.config.adb_auto_connect = false;
fixture.componentInstance.config.adb_discovery_timeout_seconds = 8;
fixture.componentInstance.save();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.adb_auto_connect).toBeFalse();
expect(request.request.body.adb_discovery_timeout_seconds).toBe(8);
expect(request.request.body.recording_enabled).toBeFalse();
request.flush(request.request.body);
fixture.componentInstance.config.adb_discovery_timeout_seconds = 0;
fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings');
});
});
@@ -43,6 +43,17 @@
border-color: rgba(203, 213, 225, 0.9);
}
.app-version {
padding: 0 10px 0 8px;
border-left: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 18px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.nav-tab-btn {
display: inline-flex;
align-items: center;
@@ -17,6 +17,7 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../../package.json';
@Component({
selector: 'app-nav-switcher',
@@ -43,9 +44,12 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
<span class="tab-label">工作区</span>
</a>
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
</nav>
`,
changeDetection: ChangeDetectionStrategy.Eager,
styleUrls: ['./nav-switcher.component.scss']
})
export class NavSwitcherComponent {}
export class NavSwitcherComponent {
readonly version = version;
}
@@ -0,0 +1,26 @@
import { signal } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AgentService } from '../../services/agent.service';
import { TaskBatchFilterComponent } from './task-batch-filter.component';
describe('队列批次筛选', () => {
it('支持全部批量、单独任务及具体批次,可清除筛选', fakeAsync(() => {
const agent = {batchFilter:signal('batch-1')};
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AgentService,useValue:agent}]});
const fixture = TestBed.createComponent(TaskBatchFilterComponent);
const http = TestBed.inject(HttpTestingController);
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
fixture.detectChanges(); tick(); fixture.detectChanges();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
for(const value of ['__batch__','__single__','batch-2']){
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
expect(agent.batchFilter()).toBe(value);
}
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
expect(agent.batchFilter()).toBe('');
fixture.destroy(); http.verify();
}));
});
@@ -0,0 +1,33 @@
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
})
export class TaskBatchFilterComponent {
agent = inject(AgentService); private http = inject(HttpClient);
batches = signal<{id: string; name: string; created_at: number}[]>([]); error = signal('');
private destroyed = false;
constructor() {
this.load();
const timer = setInterval(() => this.load(), 15000);
inject(DestroyRef).onDestroy(() => { this.destroyed = true; clearInterval(timer); });
}
hasSelectedBatch() { return this.batches().some(batch => batch.id === this.agent.batchFilter()); }
load() {
this.http.get<{id: string; name: string; created_at: number}[]>('/api/automation/batches').subscribe({
next: batches => { if (!this.destroyed) { this.batches.set(batches); this.error.set(''); } },
error: () => { if (!this.destroyed) this.error.set('批次列表加载失败'); }
});
}
}
@@ -10,11 +10,44 @@ describe('历史任务检索', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]});
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
const agent = TestBed.inject(AgentService);
agent.batchFilter.set('batch-2'); fixture.detectChanges();
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
expect(filtered.request.params.get('page')).toBe('1');
expect(filtered.request.params.get('q')).toBe('设置');
filtered.flush({total:0,items:[]});
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
fixture.destroy();
});
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
{session_id:'failed-test', name:'预期校验', status:'completed', start_time:100, test_result:{status:'failed', reason:'预期出现成功,实际出现错误', evidence:['步骤 3 显示错误']}},
{session_id:'passed-test', name:'已验收', status:'completed', start_time:100, test_result:{status:'passed', reason:'实际与预期一致', evidence:['步骤 3 显示成功']}},
{session_id:'old-task', name:'旧任务', status:'completed', start_time:100}
]});
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('button.row');
expect(rows[0].textContent).toContain('执行完成');
expect(rows[0].querySelector('app-test-result-badge').textContent).toContain('测试不通过');
expect(rows[0].querySelector('.test-result-badge').title).toContain('实际出现错误');
expect(rows[1].querySelector('app-test-result-badge').textContent).toContain('测试通过');
expect(rows[2].querySelector('app-test-result-badge').textContent).toContain('未验证');
fixture.destroy();
});
it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
const fixture = TestBed.createComponent(TaskHistoryComponent);
@@ -1,30 +1,42 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent],
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
constructor(){
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
this.load();
effect(()=>{
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
if(nextFilter!==filter || nextRevision!==resultRevision){
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
}
});
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
@@ -34,6 +46,7 @@ export class TaskHistoryComponent {
load(){
const revision=++this.revision;this.loading.set(true);this.error.set('');
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
@@ -0,0 +1,36 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞'
};
@Component({
selector: 'app-test-result-badge',
standalone: true,
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [`
:host{display:inline-flex;vertical-align:middle;max-width:100%}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
.material-symbols-outlined{font-size:16px;line-height:1}
.passed{background:#15803d;color:#fff;border-color:#15803d}
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
.compact .material-symbols-outlined{font-size:13px}
.prominent{padding:4px 9px;font-size:14px;gap:5px}
.prominent .material-symbols-outlined{font-size:18px}
`]
})
export class TestResultBadgeComponent {
result = input<CaseTestResult | null | undefined>();
executionStatus = input('');
compact = input(false);
prominent = input(false);
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
label = computed(() => this.result()?.source === 'human'
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
}
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestResultCardComponent } from './test-result-card.component';
describe('用例测试结果卡片', () => {
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
afterEach(() => TestBed.inject(HttpTestingController).verify());
it('显式展示不通过及原因,允许展开证据', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
fixture.detectChanges();
const view: HTMLElement = fixture.nativeElement;
expect(view.querySelector('[aria-label="用例测试结果"]')).not.toBeNull();
expect(view.querySelector('.prominent')?.textContent).toContain('测试不通过');
expect(view.querySelector('.reason')?.textContent).toContain('预期英文,实际为中文。');
const details = view.querySelector('details')!;
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.textContent).toContain('截图显示搜索设置');
});
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
fixture.componentRef.setInput('result', ai);
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '实际符合预期,已人工核对';
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
expect(saved).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('人工通过');
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
fixture.destroy();
});
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '人工发现异常';
fixture.nativeElement.querySelector('.confirm-fail').click();
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
fixture.detectChanges();
expect(fixture.componentInstance.note).toBe('人工发现异常');
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
fixture.destroy();
});
});
@@ -0,0 +1,76 @@
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
import { TestResultBadgeComponent } from './test-result-badge.component';
@Component({
selector: 'app-test-result-card',
standalone: true,
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
<p class="reason">{{result().reason}}</p>
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
@if(canReview()){
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
@if(reviewing()){
<div class="review-form" aria-label="人工复验">
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
</div>
}
}
</section>`,
styles: [`
:host{display:block;margin:8px 0}
.test-result-card{border:1px solid #cbd5e1;border-left:3px solid #64748b;border-radius:10px;padding:12px 16px;background:#f8fafc;color:#334155}
.passed{border-color:#bbf7d0;border-left-color:#15803d;background:#f0fdf4}
.failed{border-color:#fecaca;border-left-color:#b91c1c;background:#fef2f2}
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
.caption{font-size:12px;font-weight:600;color:#475569}
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
@media(max-width:480px){.test-result-card{padding:10px 12px}}
`]
})
export class TestResultCardComponent {
private http = inject(HttpClient);
result = input.required<CaseTestResult>();
sessionId = input<string | null>(null);
executionStatus = input('');
reviewSaved = output<CaseReviewResponse>();
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
constructor() {
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
}
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
saveReview(status: 'passed' | 'failed') {
const id = this.sessionId();
if (!id || !this.canReview() || this.saving()) return;
this.saving.set(true); this.error.set('');
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
next: response => {
this.saving.set(false); this.reviewSaved.emit(response);
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
},
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
});
}
}
@@ -21,8 +21,24 @@ export interface ModelInfo {
architecture?: string;
}
export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string;
evidence: string[];
source?: 'ai' | 'human';
reviewed_at?: number;
ai_result?: CaseTestResult | null;
}
export interface CaseReviewResponse {
id: string;
test_result: CaseTestResult;
verdict: string;
}
export interface TaskQueueItem {
session_id: string;
batch_id?: string | null;
goal: string;
profile?: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
@@ -34,6 +50,7 @@ export interface TaskQueueItem {
export interface Session {
session_id: string;
batch_id?: string | null;
initial_goal: string;
start_time: number;
end_time?: number;
@@ -41,6 +58,7 @@ export interface Session {
display_name?: string;
case_id?: string;
error?: string;
test_result?: CaseTestResult | null;
video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo;
@@ -203,6 +203,7 @@ export interface CheckerBlockData {
route?: string;
error?: string;
task_status?: 'completed' | 'partial' | 'blocked' | string;
test_result?: import('./session.model').CaseTestResult | null;
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
last_findings?: string[];
started_at?: number;
@@ -20,7 +20,7 @@ import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetection
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
import {
AdbServerConnectionResult,
AdbServerDevice,
@@ -548,10 +548,8 @@ export class HomeComponent implements OnInit, OnDestroy {
this.adbRestartFeedback.set(null);
this.systemService.restartAdb().subscribe({
next: (res) => {
this.adbRestartFeedback.set(
res?.restart_result?.skipped ? "设备已刷新 ✓" : "ADB 已刷新 ✓"
);
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
this.adbRestartFeedback.set(formatAdbRestartFeedback(res?.restart_result));
setTimeout(() => this.adbRestartFeedback.set(null), 6000);
},
error: () => {
this.adbRestartFeedback.set("重启失败");
@@ -18,7 +18,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
@@ -85,6 +85,23 @@ export class AgentService {
private rawSessions = signal<Session[]>([]);
private pendingQueue = signal<Session[]>([]);
public activeTasks = signal<any[]>([]);
public batchFilter = signal('');
public resultRevision = signal(0);
public matchesBatchFilter(session: Session): boolean {
const filter = this.batchFilter();
if (!filter) return true;
if (filter === '__batch__') return !!session.batch_id;
if (filter === '__single__') return !session.batch_id;
return session.batch_id === filter;
}
public applyCaseReview(review: CaseReviewResponse): void {
this.rawSessions.update(items => items.map(session => session.session_id === review.id
? {...session, test_result: review.test_result} : session));
this.resultRevision.update(value => value + 1);
this.fetchSessions();
}
// Persistent tracking of active/pending sessions across polling boundaries
private activeSessionTracking = new Map<string, Session>();
@@ -129,6 +146,7 @@ export class AgentService {
}
const finalSession: Session = {
...s,
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
status: sStatus,
device_serial: serial,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
@@ -149,6 +167,7 @@ export class AgentService {
|| !!activeMatch;
const finalPending: Session = {
...p,
batch_id: p.batch_id || activeMatch?.batch_id || null,
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
};
@@ -165,6 +184,8 @@ export class AgentService {
if (!existing) {
const newSession: Session = {
session_id: sid,
batch_id: at.batch_id || null,
display_name: at.display_name,
initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: at.status || 'running',
@@ -1578,6 +1599,8 @@ export class AgentService {
if (typeof item === 'object' && item !== null) {
return {
session_id: item.session_id || `pending-task-${index}`,
batch_id: item.batch_id || null,
display_name: item.display_name,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending',
@@ -3,7 +3,23 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { TestBed } from '@angular/core/testing';
import { SystemReadinessReport } from '../core/models/system.model';
import { SystemService } from './system.service';
import { SystemService, formatAdbRestartFeedback } from './system.service';
describe('ADB restart feedback', () => {
it('区分已连接与正在连接,不把等待就绪显示为失败', () => {
const message = formatAdbRestartFeedback({ success: true, automatic_connections: [{ success: true }, { success: false, pending: true }] });
expect(message).toContain('已连接 1 台设备');
expect(message).toContain('1 台设备连接中');
expect(message).not.toContain('失败');
});
it('显示连接失败数量,提示查看诊断', () => {
expect(formatAdbRestartFeedback({ success: true, automatic_connections: [{ success: false }] })).toContain('1 项连接失败,请查看诊断');
});
it('服务器重启失败不会显示成功,远程端点明确显示仅刷新', () => {
expect(formatAdbRestartFeedback({ success: false, message: '端口被占用' })).toBe('端口被占用');
expect(formatAdbRestartFeedback({ success: true, skipped: true })).toBe('设备已刷新 ✓');
});
});
describe('SystemService readiness polling', () => {
let service: SystemService;
@@ -27,6 +27,19 @@ import {
EmulatorLaunchStage
} from '../core/models/system.model';
export function formatAdbRestartFeedback(result?: {
success?: boolean; skipped?: boolean; message?: string;
automatic_connections?: { success?: boolean; pending?: boolean }[];
}): string {
if (!result?.success) return result?.message || '重启失败';
if (result.skipped) return '设备已刷新 ✓';
const connections = result.automatic_connections ?? [];
const connected = connections.filter(item => item.success).length;
const pending = connections.filter(item => item.pending).length;
const failed = connections.filter(item => !item.success && !item.pending).length;
return `ADB 已重启${connected ? `,已连接 ${connected} 台设备` : ''}${pending ? `,${pending} 台设备连接中,将自动重试` : ''}${failed ? `,${failed} 项连接失败,请查看诊断` : ' ✓'}`;
}
@Injectable({
providedIn: 'root'
})
+1
View File
@@ -14,6 +14,7 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
+2 -1
View File
@@ -5,5 +5,6 @@
"assert_semantics": "# `assert` items (test assertions)\n- An `assert` item is a test expectation, not an instruction. A failed assertion is a legitimate, valuable test result: record it truthfully with evidence. Nothing will (or should) try to repair it.\n- Never mark an assert `failed` merely because evidence is absent — absence of captured evidence is `inconclusive` with an explicit note that the evidence is missing.\n- Precondition first: before judging an assert, confirm from the execution history (step replay, notes, anchor evidence) that the action(s) meant to trigger the asserted behavior were actually and correctly performed by the executor (the toggle was really tapped, the intended screen was really reached).\n- If that precondition is not established — the executor never performed the trigger, performed the wrong action, or the evidence cannot show it — output `inconclusive` and state explicitly that the precondition failed / the triggering action is not confirmed as executed. Never output `failed` here: a failed assert is attributed to the device under test, and an execution problem must not be reported as a device defect.\n- Output `failed` only when the precondition is established AND the device state, probe output, or recorded evidence still contradicts the expectation. The evidence must cite the concrete step(s) that performed the trigger plus the probe output / observation that contradicts the expectation, so the verdict reads as a device-defect diagnosis.\n- `suggestion` is not needed for assert verdicts.",
"anchor_guide": "# Evidence anchoring (checkpoint audit)\n- You are auditing a subgoal at its completion moment. The authoritative evidence is the execution history around the anchor step (the anchor step's pre-action screenshot and the preceding step's post-action state) plus persistently probeable device state.\n- For transient expectations (toasts, dialogs, momentary states) and ordering expectations, you MUST inspect the recorded history near the anchor step — the live device has already moved on and is NOT valid evidence for them.\n- Prefer persistent state probes and recorded notes over inference from summaries.",
"final_guide": "# Final review (task exit audit)\n- Audit against the user's original goal as amended by the user guidance received during the run (listed in the prompt), not merely the plan's checkboxes. Guidance outranks the plan and its check lines: judge a check line the guidance retired or changed by the guidance, not by its original wording.\n- `@end` items: judge them using the FINAL device state provided to you.\n- `on_complete` items: cite the verdict ledger — an already-recorded PASS/FAIL from the item's checkpoint attempt is authoritative (an assert that ever failed stays failed). For items never checked, judge from the historical evidence near their subgoal's completion; if that evidence was never captured, output `inconclusive` stating the evidence is missing. NEVER re-judge an `on_complete` item from the current screen: the current screen only proves the present, not the past moment the item was declared for.\n- `unmet_subgoals`: list, verbatim, any plan subgoal marked complete whose verify criteria are demonstrably not met. Leave it empty otherwise; failed asserts alone do NOT make a subgoal unmet.",
"probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands."
"probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands.",
"case_result_guide": "# Overall test result (final review, for Flash and Pro)\nAlways produce `test_result`, even when no plan/check items exist. Infer acceptance criteria directly from the user's original task prompt and subsequent guidance; do not ask for an extra expected-results form. Evaluate explicit expectations and the observable outcome of requested actions. Do not invent requirements, turn examples into expectations, or relax an expectation because the executor says it completed the task.\nCompare the executor's final explanation with actual screenshots, UI elements, step history, notes, probes and the verdict ledger. The explanation is a claim to verify, never proof by itself. A final screen only proves the final state: use recorded history for intermediate/transient results and required sequences. Use read-only tools only. Do not repair a failed test or repeat device actions.\n- passed: all requirements in the prompt/guidance are supported by concrete evidence, and no active assertion failed. Cite the specific step/screenshot/probe observations.\n- failed: the triggering actions and prerequisites are confirmed as correctly executed, but concrete evidence contradicts at least one stated expectation. Cite both the trigger and the contradictory result. A confirmed failure stays failed even when other requirements are unverified.\n- inconclusive: evidence is missing/ambiguous, the task has no discernible acceptance criterion, or the conclusion cannot be checked. Explain which requirement/evidence is missing; never claim passed or failed merely from the report or an absent exception.\n- blocked: an execution or environment obstacle prevented the required actions/validation. This is not evidence of a product defect.\nWrite `reason` in concise Simplified Chinese, explaining the expected and actual result. `evidence` contains concrete observations with step numbers when available. Preserve user-supplied values, UI text and logs exactly.\nThe test result evaluates the system under test, NOT whether the testing agent obeyed the instruction to inspect/report. Explicit expectations (预期, 应当, 必须, should, expected) are test assertions even without plan checkboxes. Reporting a mismatch correctly does not satisfy that expectation. Example: 'Check the search box; expected Search settings; if mismatched report the actual text and finish.' If the box visibly says 搜索设置, output test_result.status=failed, not passed: the inspection completed but the expected product result was not met. 'Report any failures and stop' never converts a detected failure into a pass. Likewise, confirming a missing required entry is a failed test when the correct page was reached. First list the expected product behavior in reason, then compare the actual evidence; judge execution/reporting separately. If no product expectation is given, judge the observable result of the requested actions."
}
+23 -2
View File
@@ -46,6 +46,7 @@ from pydantic import BaseModel, Field, ValidationError
from artemis.core.tool_failure import ToolFailure, is_tool_failure
from artemis.constants import CHECKER_MAX_ITERATIONS
from artemis.runtime.case_result import CaseTestResult, normalize_case_result
from artemis.context import ArtemisContext
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
from artemis.data_engine.trace import trace, trace_langchain_tool
@@ -98,6 +99,10 @@ class CheckVerdict(BaseModel):
class CheckReport(BaseModel):
verdicts: list[CheckVerdict] = Field(default_factory=list)
test_result: CaseTestResult | None = Field(
default=None,
description="Final review only: overall test verdict against the user's prompt and guidance, independent of execution completion.",
)
unmet_subgoals: list[str] = Field(
default_factory=list,
description=(
@@ -287,6 +292,7 @@ def assemble_checker_prompt_segments(
segments.append(prompts.get("anchor_guide", ""))
else:
segments.append(prompts.get("final_guide", ""))
segments.append(prompts.get("case_result_guide", ""))
if probe_tool_registered:
segments.append(prompts.get("probe_guide", ""))
return "\n\n".join(s for s in segments if s)
@@ -458,7 +464,11 @@ def _normalize_report(report: CheckReport, check_items: list) -> CheckReport:
evidence="no verdict produced for this item",
)
)
return CheckReport(verdicts=verdicts, unmet_subgoals=list(report.unmet_subgoals))
return CheckReport(
verdicts=verdicts,
unmet_subgoals=list(report.unmet_subgoals),
test_result=normalize_case_result(report.test_result) if report.test_result else None,
)
async def _run_check_loop(
@@ -796,6 +806,7 @@ async def run_final_check(
ledger: list[dict],
check_items,
attempt_id: str | None = None,
execution_report: dict | str | None = None,
) -> CheckReport:
"""Final review at task exit: audits the user's original goal and all
declared check items, with the final screen state available."""
@@ -828,6 +839,8 @@ async def run_final_check(
human_text = (
f"# User's Original Goal\n{goal}\n\n"
f"# User guidance received during the run\n{_format_user_guidance(ctx, steps)}\n\n"
f"# Executor's Final Report (claims to verify, not independent evidence)\n"
f"{json.dumps(execution_report, ensure_ascii=False, default=str) if execution_report is not None else '(not provided)'}\n\n"
f"# Final Task Plan\n{plan_text or '(no plan)'}\n\n"
f"# Declared Check Items\n{_format_check_items(items)}\n\n"
f"# Verdict Ledger (checkpoint results, authoritative for on_complete"
@@ -852,7 +865,15 @@ async def run_final_check(
]
with CheckerStreamCapture(ctx) as capture:
try:
return await _run_check_loop(ctx, messages, tools, items)
report = await _run_check_loop(ctx, messages, tools, items)
report.test_result = (
normalize_case_result(report.test_result)
if report.test_result
else CaseTestResult(
status="inconclusive", reason="验收未生成测试结论,不能确认用例是否通过。"
)
)
return report
finally:
_persist_stream(
ctx, capture, attempt_id=attempt_id, phase="final", checkpoint_id="final"
+113
View File
@@ -0,0 +1,113 @@
"""Flash's exit-only acceptance check uses the same read-only Checker as Pro."""
import asyncio
from artemis.agents.checker.checker import run_final_check
from artemis.context import ArtemisContext
from artemis.graph.checkpoints import (
RunOutcome,
TestSummary,
final_check_enabled,
publish_checker_event,
verdict_payloads,
write_run_outcome,
)
from artemis.runtime.case_result import CaseTestResult, resolve_case_result
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
async def settle_flash_outcome(
ctx: ArtemisContext,
goal: str,
execution_report: dict,
*,
final_report_received: bool = False,
) -> dict:
"""Check once at exit, without operating the device or rerunning the task."""
status = str(execution_report.get("status") or "failed")
result = None
if status == "completed" or final_report_received:
if final_check_enabled(ctx):
timeout = float(getattr(ctx.execution_setup, "checkpoint_timeout", 180.0))
publish_checker_event(
ctx,
{
"event": "attempt_started",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"check_items": [],
"status": "running",
},
)
try:
report = await asyncio.wait_for(
run_final_check(
ctx,
goal=goal,
plan_text="",
ledger=[],
check_items=[],
attempt_id="final#1",
execution_report=execution_report,
),
timeout=timeout,
)
result = report.test_result
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"status": "done",
"verdicts": verdict_payloads(report.verdicts),
"test_result": result.model_dump() if result else None,
"route": "end",
},
)
except Exception as exc:
detail = (
f"超过 {timeout:g} 秒"
if isinstance(exc, TimeoutError)
else f"{type(exc).__name__}: {exc}"
)
logger.warning(f"Flash 测试验收未完成:{detail}")
result = CaseTestResult(
status="inconclusive", reason=f"操作已结束,但测试验收未完成:{detail}。"
)
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"status": "error",
"error": result.reason,
"route": "end",
},
)
else:
result = CaseTestResult(
status="inconclusive", reason="已关闭最终检查,未进行测试结果验收。"
)
outcome = RunOutcome(
task_status="completed" if status == "completed" else "blocked", tests=TestSummary()
)
payload = outcome.model_dump()
if result:
payload["test_result"] = result.model_dump()
resolved = resolve_case_result(payload, status, str(execution_report.get("explanation") or ""))
payload["test_result"] = resolved.model_dump() if resolved else None
if ctx.data_engine:
write_run_outcome(
ctx.data_engine.base_dir, outcome, {"test_result": payload["test_result"]}
)
publish_checker_event(ctx, {"event": "run_outcome", "phase": "outcome", **payload})
ctx.run_outcome = payload
return payload
+2
View File
@@ -145,6 +145,7 @@ class FlashRunner:
def __init__(self, ctx: ArtemisContext, goal: str, max_turns: int | None = None):
self.ctx = ctx
self.goal = goal
self.final_report_received = False
try:
cfg = load_agent_config()
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
@@ -570,6 +571,7 @@ class FlashRunner:
) -> dict:
"""Records the final step, acknowledges the tool call, and flushes."""
final_report = args
self.final_report_received = True
if self.ctx.data_engine:
try:
if self.ctx.data_engine.current_step_id is None:
+46 -2
View File
@@ -247,6 +247,48 @@ def _ensure_maestro_not_installed(device_id: str) -> None:
_uninstall_package(device_id, MAESTRO_PACKAGE)
class UIAutomatorConnectionError(RuntimeError):
"""Terminal connection failure with device context and the original cause."""
def __init__(self, device_id: str, attempts: int, cause: Exception) -> None:
# The library appends its whole output buffer on every readiness poll.
# Keep the original exception as __cause__, but do not repeat those
# identical startup lines dozens of times in the user-facing failure.
lines = [line for arg in cause.args for line in str(arg).splitlines()]
detail = "\n".join(dict.fromkeys(lines)) or str(cause)
super().__init__(
f"UIAutomator2 连接失败(设备:{device_id};已尝试 {attempts} 次)。"
f"\n底层原因:{type(cause).__name__}: {detail}"
)
class _ManagedUIAutomatorDevice(u2.Device):
"""Release a failed launch before the constructor can lose its handle.
UIAutomator2 registers exit cleanup only after startup succeeds. Its
partially constructed Device is not returned when readiness fails, so
the caller cannot stop that launch. Intercept the startup boundary here
instead of modifying the installed library or killing device processes.
"""
def start_uiautomator(self) -> None:
try:
super().start_uiautomator()
except BaseException:
# _process is set only for a server launched by this instance;
# attaching to a server already on the device leaves it None.
if self._process is not None:
try:
super().stop_uiautomator(wait=False)
except Exception as cleanup_error:
# Cleanup must never hide the startup error or interrupt.
logger.warning(
f"Failed to stop this UIAutomator2 launch on {self._dev.serial}: "
f"{cleanup_error}"
)
raise
class UIAutomatorClient:
"""UIAutomator2 client for Android screen data retrieval.
@@ -297,7 +339,7 @@ class UIAutomatorClient:
if attempt:
time.sleep(1.0 * attempt)
try:
self._device = u2.connect(self._device_id)
self._device = _ManagedUIAutomatorDevice(self._device_id)
break
except Exception as exc:
last_error = exc
@@ -307,7 +349,9 @@ class UIAutomatorClient:
)
else:
self._awake_strategy = None
raise last_error
raise UIAutomatorConnectionError(
self._device_id, attempt + 1, last_error
) from last_error
logger.info("UIAutomator2 connected successfully")
return self._device
+4
View File
@@ -124,6 +124,7 @@ class Settings(BaseSettings):
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
ARTEMIS_BATCH_MAX_EXECUTIONS: int = Field(default=10000, ge=1)
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
@@ -134,6 +135,9 @@ class Settings(BaseSettings):
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
ADB_DEVICE_SERIAL: str | None = None
ARTEMIS_ADB_AUTO_CONNECT: bool = True
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS: float = Field(default=10, ge=1, le=300)
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=5, gt=0, le=30)
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
+9 -1
View File
@@ -21,6 +21,7 @@ import time
from typing import Any
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.probes.credentials_probe import (
@@ -353,7 +354,12 @@ class ReadinessEngine:
}
logger.info("[ReadinessEngine] Restarting ADB server...")
return await asyncio.to_thread(_restart_sync)
result = await asyncio.to_thread(_restart_sync)
if result.get("success"):
result["automatic_connections"] = await asyncio.to_thread(
adb_device_discovery.refresh, adb_path, force=True
)
return result
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
@@ -417,8 +423,10 @@ class ReadinessEngine:
)
output = (res.stdout + "\n" + res.stderr).strip()
success = "connected to" in output.lower() and "failed" not in output.lower()
remembered = adb_device_discovery.remember(target) if success else False
return {
"success": success,
"remembered": remembered,
"target": target,
"output": output,
"message": f"Connected to {target}" if success else output,
@@ -33,6 +33,7 @@ from artemis.core.diagnostics.schema import (
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -477,6 +478,7 @@ class AdbDeviceProbe(BaseProbe):
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
await asyncio.to_thread(adb_device_discovery.refresh, adb_path)
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
@@ -668,6 +670,7 @@ class AdbDeviceProbe(BaseProbe):
"installed": True,
"adb_path": adb_path,
"adb_version": adb_ver,
"automatic_connections": adb_device_discovery.results.copy(),
"adb_keys": key_status.to_dict(),
"emulator_path": emulator_path,
"is_emulator_in_path": is_emu_in_path,
+25 -3
View File
@@ -284,6 +284,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3
verify_blocked = False
case_result = None
blocked_findings: list[str] = []
if final_check_enabled(ctx):
attempt_no = ctx.final_check_attempts + 1
@@ -320,9 +321,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
),
timeout=checkpoint_timeout,
)
case_result = report.test_result
except Exception as e:
# Fail-open: release, but the verdict value stays inconclusive.
logger.warning(f"Final check errored ({e}); releasing fail-open.")
from artemis.runtime.case_result import CaseTestResult
detail = (
f"超过 {checkpoint_timeout:g} 秒"
if isinstance(e, TimeoutError)
else f"{type(e).__name__}: {e}"
)
case_result = CaseTestResult(
status="inconclusive", reason=f"最终验收未完成:{detail}。"
)
error_verdicts: list[dict] = []
for ci in snapshot.all_check_items:
append_ledger_record(
@@ -444,10 +456,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
verify_blocked=verify_blocked,
retired=retired if isinstance(retired, set) else None,
)
if snapshot.all_check_items or records or verify_blocked:
update["run_outcome"] = outcome.model_dump()
if snapshot.all_check_items or records or verify_blocked or case_result is not None:
from artemis.runtime.case_result import resolve_case_result
payload = outcome.model_dump()
if case_result:
payload["test_result"] = case_result.model_dump()
resolved = resolve_case_result(
payload, "completed" if outcome.task_status == "completed" else "failed"
)
payload["test_result"] = resolved.model_dump() if resolved else None
update["run_outcome"] = payload
# BLOCKED/partial wrap-ups carry the last findings in the metadata file.
extra: dict = {"last_findings": blocked_findings} if blocked_findings else {}
extra["test_result"] = payload["test_result"]
# Which UI-hierarchy source served the run, so a report reader can tell a
# helper run from a UIAutomator2 fallback without reading logs.
from artemis.clients.screen_client_factory import hierarchy_backend_summary
@@ -461,7 +483,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
{
"event": "run_outcome",
"phase": "outcome",
**outcome.model_dump(),
**payload,
"last_findings": list(blocked_findings),
},
)
+55 -575
View File
@@ -51,212 +51,28 @@ ARTEMIS_MCP_TOOLS = (
)
def _eager_tools_config() -> dict[str, dict[str, bool]]:
"""Return the eager-loading extension used by clients that support it."""
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
def _get_vscode_user_dir() -> Path:
"""Returns the platform-specific VS Code user configuration directory."""
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / "Code" / "User"
elif sys.platform == "win32":
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
return Path(appdata) / "Code" / "User"
else:
return Path.home() / ".config" / "Code" / "User"
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
"""Generates MCP configuration dictionary for the specified client."""
config_body = {
"command": python_exe,
"args": ["-m", "mcp_server"],
"env": {
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": project_root,
"ARTEMIS_DESKTOP_NOTIFY": "true",
},
"""Generate the supported Codex MCP configuration."""
if client != "codex":
raise ValueError("Only Codex MCP configuration is supported")
return {
"mcp_servers": {
"artemis": {
"command": python_exe,
"args": ["-m", "mcp_server"],
"cwd": project_root,
"env": {
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": project_root,
"ARTEMIS_DESKTOP_NOTIFY": "true",
},
"enabled": True,
"required": True,
"startup_timeout_sec": 120,
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
}
}
}
config_with_cwd = {**config_body, "cwd": project_root}
if client == "antigravity":
return {
"mcpServers": {
"artemis": {
**config_with_cwd,
# This is the documented way to expose every tool. Avoid
# proprietary eager fields in the current config so a
# strict parser can always load the server.
"disabledTools": [],
}
}
}
elif client == "jetski":
return {
"mcpServers": {
"artemis": {
**config_with_cwd,
"disabledTools": [],
# Retain the legacy Jetski eager extension only in its
# legacy config file. Current Antigravity gets the strict,
# documented configuration above as a safe fallback.
"tools": _eager_tools_config(),
}
}
}
elif client == "openclaw":
return {
"mcp": {
"servers": {
"artemis": {
**config_with_cwd,
"enabled": True,
"connectionTimeoutMs": 120000,
}
}
}
}
elif client == "codex":
return {
"mcp_servers": {
"artemis": {
**config_with_cwd,
# Codex has no eager/deferred MCP setting. Requiring the
# server makes it initialize during host startup, while an
# explicit allow-list guarantees all Artemis tools are
# registered. The host may still present schemas through
# tool search to control context size.
"enabled": True,
"required": True,
"startup_timeout_sec": 120,
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
}
}
}
elif client == "vscode":
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
elif client in ("claude", "claude_code", "claude_desktop"):
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
elif client == "cline":
return {
"mcpServers": {
"artemis": {
**config_body,
"disabled": False,
"autoApprove": [],
"timeout": 120,
}
}
}
elif client in ("roo", "roo_code"):
return {
"mcpServers": {
"artemis": {
**config_body,
"disabled": False,
"alwaysAllow": [],
"timeout": 120,
}
}
}
else: # cursor, windsurf, generic
return {"mcpServers": {"artemis": config_body}}
def _parse_json_lenient(text: str) -> dict | None:
"""Parses JSON or JSONC (JSON with comments / trailing commas) safely.
Returns ``None`` when the text cannot be parsed as a JSON object, so callers
can tell a genuinely empty ``{}`` file apart from a corrupt one.
"""
try:
data = json.loads(text)
return data if isinstance(data, dict) else None
except ValueError:
# Not strict JSON; fall through to the lenient JSONC cleanup below.
pass
# Remove // single-line comments
cleaned = re.sub(r"//.*", "", text)
# Remove /* ... */ multi-line comments
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
# Remove trailing commas before } or ]
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
try:
data = json.loads(cleaned)
return data if isinstance(data, dict) else None
except Exception:
return None
def _merge_json_file(
file_path: Path,
server_name: str,
server_config: dict,
key_name: str | tuple[str, ...] = "mcpServers",
remove_paths: tuple[tuple[str, ...], ...] = (),
strict_parse: bool = False,
) -> bool:
"""Merges server configuration into a target JSON file without overwriting existing servers.
With ``strict_parse=True``, an existing non-empty file that cannot be parsed
aborts the merge instead of being replaced. Use this for files that hold
state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite
would destroy unrelated data.
"""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
data: dict = {}
if file_path.exists() and file_path.stat().st_size > 0:
raw_text = file_path.read_text(encoding="utf-8")
parsed = _parse_json_lenient(raw_text)
data = parsed or {}
if parsed is None and raw_text.strip():
if strict_parse:
logger.warning(
f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. "
"Fix or remove the file, then re-run the install."
)
return False
# Backup unparseable existing file to prevent accidental loss
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
try:
backup_path.write_text(raw_text, encoding="utf-8")
logger.warning(
f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}"
)
except OSError as exc:
logger.warning(
f"Existing JSON in {file_path} could not be parsed AND the backup to"
f" {backup_path} failed ({exc}); refusing to rewrite it."
" Fix or remove the file, then re-run the install."
)
return False
for remove_path in remove_paths:
parent = data
for part in remove_path[:-1]:
child = parent.get(part)
if not isinstance(child, dict):
parent = {}
break
parent = child
if parent:
parent.pop(remove_path[-1], None)
key_path = (key_name,) if isinstance(key_name, str) else key_name
target = data
for part in key_path:
if not isinstance(target.get(part), dict):
target[part] = {}
target = target[part]
target[server_name] = server_config
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return True
except Exception as e:
logger.warning(f"Could not update MCP config file {file_path}: {e}")
return False
def _codex_toml_block(server_config: dict) -> str:
@@ -302,7 +118,9 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
managed_pattern = re.compile(
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
)
updated = managed_pattern.sub(managed_block, existing, count=1)
# Windows paths contain backslashes; a replacement string would
# interpret them as regex escapes on a second installation.
updated = managed_pattern.sub(lambda _: managed_block, existing, count=1)
else:
# Remove an older, unmanaged Artemis table before adding the managed block.
kept_lines: list[str] = []
@@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
return False
def _remove_rules_block(file_path: Path) -> bool:
"""Removes a previously injected managed ARTEMIS block from a rules file.
Used to migrate clients that load both their main rules file and a
standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND
~/.claude/rules/*.md), where keeping the injected block would duplicate
the rules in the model's context. If the file contains nothing but the
managed block, the file is deleted entirely.
"""
try:
if not file_path.exists():
return True
begin_marker = "<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->"
end_marker = "<!-- END ARTEMIS MOBILE TESTING RULES -->"
existing = file_path.read_text(encoding="utf-8")
if begin_marker not in existing or end_marker not in existing:
return True
prefix = existing.split(begin_marker)[0]
suffix = existing.split(end_marker)[1]
remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip()
if remainder:
file_path.write_text(remainder + "\n", encoding="utf-8")
else:
file_path.unlink()
return True
except Exception as e:
logger.warning(f"Could not remove rules block from {file_path}: {e}")
return False
def _inject_rules_block(file_path: Path, content: str) -> bool:
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
try:
@@ -404,297 +192,36 @@ def _inject_rules_block(file_path: Path, content: str) -> bool:
return False
def _write_rule_file(file_path: Path, content: str) -> bool:
"""Writes or overwrites a standalone rule file."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
new_text = content.strip() + "\n"
if file_path.exists():
try:
if file_path.read_text(encoding="utf-8") == new_text:
return True
except (OSError, UnicodeDecodeError):
# Unreadable existing file: skip the no-op check and rewrite it.
pass
file_path.write_text(new_text, encoding="utf-8")
return True
except Exception as e:
logger.warning(f"Could not write rule file {file_path}: {e}")
return False
def _write_cursor_mdc(file_path: Path, content: str) -> bool:
"""Writes a Cursor .mdc rule file with required YAML frontmatter."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
mdc_header = (
"---\n"
"description: Artemis Mobile Testing Mindset & Rules\n"
"globs: **/*\n"
"alwaysApply: true\n"
"---\n\n"
)
new_text = mdc_header + content.strip() + "\n"
if file_path.exists():
try:
if file_path.read_text(encoding="utf-8") == new_text:
return True
except (OSError, UnicodeDecodeError):
# Unreadable existing file: skip the no-op check and rewrite it.
pass
file_path.write_text(new_text, encoding="utf-8")
return True
except Exception as e:
logger.warning(f"Could not write Cursor rule file {file_path}: {e}")
return False
def install_rules(client: str, project_root: str) -> list[str]:
"""Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS."""
installed_paths: list[str] = []
"""Install testing rules only into the Codex instructions file."""
if client != "codex":
raise ValueError("Only Codex MCP configuration is supported")
rules_src = Path(project_root) / "mcp_server" / "rules.md"
if not rules_src.exists():
alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
if alt_src.exists():
rules_src = alt_src
else:
logger.warning(f"Could not locate rules.md at {rules_src}")
return []
rules_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
try:
raw_rules = rules_src.read_text(encoding="utf-8").strip()
except Exception as e:
logger.warning(f"Could not read rules file {rules_src}: {e}")
except OSError:
logger.warning(f"Could not read rules file {rules_src}")
return []
targets = (
[
"antigravity",
"cursor",
"claude",
"windsurf",
"vscode",
"cline",
"roo",
"openclaw",
"codex",
]
if client == "all"
else [client]
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
override_file = codex_home / "AGENTS.override.md"
agents_file = (
override_file
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
else codex_home / "AGENTS.md"
)
for target in targets:
if target in ("antigravity", "jetski"):
gemini_md = Path.home() / ".gemini" / "GEMINI.md"
global_rule = Path.home() / ".gemini" / "rules" / "artemis.md"
if _inject_rules_block(gemini_md, raw_rules):
installed_paths.append(str(gemini_md))
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target == "cursor":
cursor_rules_file = Path.home() / ".cursorrules"
global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc"
if _inject_rules_block(cursor_rules_file, raw_rules):
installed_paths.append(str(cursor_rules_file))
if _write_cursor_mdc(global_mdc, raw_rules):
installed_paths.append(str(global_mdc))
elif target in ("claude", "claude_code"):
# Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md
# into context every session, so install the rules in exactly one
# place (the standalone rule file) and migrate away any block a
# previous version injected into CLAUDE.md.
claude_md = Path.home() / ".claude" / "CLAUDE.md"
global_rule = Path.home() / ".claude" / "rules" / "artemis.md"
_remove_rules_block(claude_md)
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target == "claude_desktop":
# Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so
# the rules block stays injected there for this target.
claude_md = Path.home() / ".claude" / "CLAUDE.md"
if _inject_rules_block(claude_md, raw_rules):
installed_paths.append(str(claude_md))
elif target == "windsurf":
global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md"
global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md"
if _inject_rules_block(global_mem, raw_rules):
installed_paths.append(str(global_mem))
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target == "vscode":
global_rule = Path.home() / ".vscode" / "rules" / "artemis.md"
user_rule = _get_vscode_user_dir() / "rules" / "artemis.md"
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
if _write_rule_file(user_rule, raw_rules):
installed_paths.append(str(user_rule))
elif target == "cline":
clinerules = Path.home() / ".clinerules"
global_rule = Path.home() / ".cline" / "rules" / "artemis.md"
if _inject_rules_block(clinerules, raw_rules):
installed_paths.append(str(clinerules))
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target in ("roo", "roo_code"):
roorules = Path.home() / ".roorules"
global_rule = Path.home() / ".roo" / "rules" / "artemis.md"
if _inject_rules_block(roorules, raw_rules):
installed_paths.append(str(roorules))
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target == "openclaw":
openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md"
global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md"
if _inject_rules_block(openclaw_md, raw_rules):
installed_paths.append(str(openclaw_md))
if _write_rule_file(global_rule, raw_rules):
installed_paths.append(str(global_rule))
elif target == "codex":
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
override_file = codex_home / "AGENTS.override.md"
agents_file = (
override_file
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
else codex_home / "AGENTS.md"
)
if _inject_rules_block(agents_file, raw_rules):
installed_paths.append(str(agents_file))
return installed_paths
return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else []
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
"""Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS."""
installed_paths: list[str] = []
targets = (
[
"antigravity",
"cursor",
"claude",
"windsurf",
"vscode",
"cline",
"roo",
"openclaw",
"codex",
]
if client == "all"
else [client]
)
for target in targets:
snippet = _get_config_snippet(target, python_exe, project_root)
if target in ("antigravity", "jetski"):
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
"mcpServers"
]["artemis"]
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
"mcpServers"
]["artemis"]
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
installed_paths.append(str(jetski_path))
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
installed_paths.append(str(antigravity_legacy_path))
if _merge_json_file(config_path, "artemis", current_server_cfg):
installed_paths.append(str(config_path))
elif target in ("claude", "claude_code", "claude_desktop"):
server_cfg = snippet["mcpServers"]["artemis"]
if sys.platform == "darwin":
claude_path = (
Path.home()
/ "Library"
/ "Application Support"
/ "Claude"
/ "claude_desktop_config.json"
)
elif sys.platform == "win32":
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
else:
claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
if _merge_json_file(claude_path, "artemis", server_cfg):
installed_paths.append(str(claude_path))
# Also install to Claude Code CLI global config (~/.claude.json).
# This file holds Claude Code state well beyond MCP config, so a
# parse failure must abort the merge rather than rewrite the file.
claude_code_path = Path.home() / ".claude.json"
if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True):
installed_paths.append(str(claude_code_path))
elif target == "cursor":
server_cfg = snippet["mcpServers"]["artemis"]
cursor_path = Path.home() / ".cursor" / "mcp.json"
if _merge_json_file(cursor_path, "artemis", server_cfg):
installed_paths.append(str(cursor_path))
elif target == "windsurf":
server_cfg = snippet["mcpServers"]["artemis"]
windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
if _merge_json_file(windsurf_path, "artemis", server_cfg):
installed_paths.append(str(windsurf_path))
elif target == "vscode":
server_cfg = snippet["servers"]["artemis"]
vscode_path = _get_vscode_user_dir() / "mcp.json"
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
installed_paths.append(str(vscode_path))
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
installed_paths.append(str(copilot_path))
elif target == "cline":
server_cfg = snippet["mcpServers"]["artemis"]
cline_paths = (
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
Path.home() / ".cline" / "mcp.json",
_get_vscode_user_dir()
/ "globalStorage"
/ "saoudrizwan.claude-dev"
/ "settings"
/ "cline_mcp_settings.json",
)
for cline_path in cline_paths:
if _merge_json_file(cline_path, "artemis", server_cfg):
installed_paths.append(str(cline_path))
elif target in ("roo", "roo_code"):
server_cfg = snippet["mcpServers"]["artemis"]
roo_settings_dir = (
_get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings"
)
roo_paths = (
roo_settings_dir / "mcp_settings.json",
roo_settings_dir / "cline_mcp_settings.json",
Path(project_root) / ".roo" / "mcp.json",
)
for roo_path in roo_paths:
if _merge_json_file(roo_path, "artemis", server_cfg):
installed_paths.append(str(roo_path))
elif target == "openclaw":
server_cfg = snippet["mcp"]["servers"]["artemis"]
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
if _merge_json_file(
openclaw_path,
"artemis",
server_cfg,
key_name=("mcp", "servers"),
remove_paths=(("plugins", "artemis_mcp"),),
):
installed_paths.append(str(openclaw_path))
elif target == "codex":
server_cfg = snippet["mcp_servers"]["artemis"]
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
codex_path = codex_home / "config.toml"
if _merge_codex_toml(codex_path, server_cfg):
installed_paths.append(str(codex_path))
rules_paths = install_rules(client, project_root)
installed_paths.extend(rules_paths)
unique_paths: list[str] = []
for p in installed_paths:
if p not in unique_paths:
unique_paths.append(p)
return unique_paths
"""Install ARTEMIS configuration and rules only for Codex."""
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
codex_path = codex_home / "config.toml"
if not _merge_codex_toml(codex_path, server_cfg):
raise typer.Exit(1)
return [str(codex_path), *install_rules(client, project_root)]
def mcp_command(
@@ -733,7 +260,7 @@ def mcp_command(
typer.Option(
"--install",
"-i",
help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
help="Install ARTEMIS MCP configuration and testing rules for Codex: --install codex.",
),
] = None,
generate_config: Annotated[
@@ -741,7 +268,7 @@ def mcp_command(
typer.Option(
"--generate-config",
"-g",
help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
help="Output ready-to-use Codex TOML configuration: --generate-config codex.",
),
] = None,
) -> None:
@@ -751,24 +278,9 @@ def mcp_command(
if install_config:
client = install_config.lower()
if client not in (
"antigravity",
"jetski",
"claude",
"claude_code",
"claude_desktop",
"cursor",
"windsurf",
"vscode",
"cline",
"roo",
"roo_code",
"openclaw",
"codex",
"all",
):
if client != "codex":
console.print(
f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]"
"[bold red]目前仅支持 Codex MCP 配置,请使用 --install codex。[/bold red]"
)
raise typer.Exit(1)
installed_paths = install_mcp_config(client, python_exe, project_root)
@@ -784,46 +296,14 @@ def mcp_command(
if generate_config:
client = generate_config.lower()
if client == "all":
all_configs = {
"antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet(
"antigravity", python_exe, project_root
),
"cursor (.cursor/mcp.json)": _get_config_snippet(
"cursor", python_exe, project_root
),
"claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet(
"claude", python_exe, project_root
),
"windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet(
"windsurf", python_exe, project_root
),
"vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root),
"cline (cline_mcp_settings.json)": _get_config_snippet(
"cline", python_exe, project_root
),
"roo (cline_mcp_settings.json)": _get_config_snippet(
"roo", python_exe, project_root
),
"openclaw (openclaw.json)": _get_config_snippet(
"openclaw", python_exe, project_root
),
"codex (~/.codex/config.toml)": _get_config_snippet(
"codex", python_exe, project_root
),
}
json_str = json.dumps(all_configs, indent=2)
syntax_language = "json"
elif client == "codex":
server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][
"artemis"
]
json_str = _codex_toml_block(server_cfg)
syntax_language = "toml"
else:
snippet = _get_config_snippet(client, python_exe, project_root)
json_str = json.dumps(snippet, indent=2)
syntax_language = "json"
if client != "codex":
console.print(
"[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]"
)
raise typer.Exit(1)
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
json_str = _codex_toml_block(server_cfg)
syntax_language = "toml"
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
+2 -1
View File
@@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.platform import platform
from artemis.runtime.adb_endpoint import AdbEndpoint
from artemis.utils.app_launch_utils import launch_app_with_retries
@@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None):
port_str = os.environ.get("ADB_PORT", "5037")
port = int(port_str) if port_str.isdigit() else 5037
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket
adb = AdbClient(host=host, port=port)
devices = adb.device_list()
+285
View File
@@ -0,0 +1,285 @@
"""Bounded TCP device discovery shared by the console, CLI and MCP.
USB and Android SDK emulators are discovered by ADB itself. Third-party
emulators expose TCP adbd listeners; only listeners owned by known emulator
processes are probed. Explicitly connected TCP devices are remembered separately.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import socket
import struct
import subprocess
import tempfile
import threading
import time
import psutil
from artemis.config import settings
from artemis.config.paths import get_app_dir
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def _canonical_target(target: str) -> str:
host, separator, port = target.rpartition(":")
if separator and host.lower() in {"localhost", "127.0.0.1"}:
return f"127.0.0.1:{port}"
return target
def emulator_listeners() -> list[list[str]]:
"""Return listener groups, one per running emulator (never scan a network)."""
owners = set()
for process in psutil.process_iter(["pid", "name", "exe"]):
info = process.info
name = str(info.get("name") or "").lower().removesuffix(".exe")
path = str(info.get("exe") or "").lower()
known = name in {
"mumunxdevice",
"nemuheadless",
"mumuheadless",
"ldvboxheadless",
"noxvmhandle",
"memuheadless",
"hd-player",
}
vendor_vm = name == "vboxheadless" and any(
vendor in path for vendor in ("mumu", "nemu", "ldplayer", "leidian", "nox", "memu")
)
if known or vendor_vm:
owners.add(info["pid"])
if not owners:
return []
groups: dict[int, set[int]] = {}
for connection in psutil.net_connections(kind="tcp"):
address = connection.laddr
pid = connection.pid
if (
pid is not None
and pid in owners
and address
and connection.status == psutil.CONN_LISTEN
and address.ip in {"127.0.0.1", "0.0.0.0"}
):
groups.setdefault(pid, set()).add(address.port)
# Prefer current MuMu ports over its compatibility aliases (same adbd).
return [
[f"127.0.0.1:{port}" for port in sorted(ports, key=lambda p: (p in {5555, 7555}, p))]
for ports in groups.values()
]
def is_adbd(target: str, timeout: float) -> bool:
"""Confirm an ADB CNXN/AUTH header before giving a local port to adb connect."""
host, port = target.rsplit(":", 1)
payload = b"host::\x00"
command = int.from_bytes(b"CNXN", "little")
packet = struct.pack(
"<6I", command, 0x01000000, 4096, len(payload), sum(payload), command ^ 0xFFFFFFFF
)
try:
with socket.create_connection((host, int(port)), timeout=timeout) as stream:
stream.sendall(packet + payload)
header = b""
deadline = time.monotonic() + timeout
while len(header) < 24:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
stream.settimeout(remaining)
chunk = stream.recv(24 - len(header))
if not chunk:
return False
header += chunk
kind, _, _, length, _, magic = struct.unpack("<6I", header)
return (
kind.to_bytes(4, "little") in {b"CNXN", b"AUTH"}
and magic == kind ^ 0xFFFFFFFF
and length <= 1048576
)
except (OSError, ValueError):
return False
class AdbDeviceDiscovery:
"""Coalesce discovery and reconnect only missing transports, with a total budget."""
def __init__(self):
self._gate = threading.Lock()
self._last_scan = float("-inf")
self.results: list[dict[str, object]] = []
self._cursor = 0
@staticmethod
def _directory() -> Path:
return get_app_dir() / "adb-devices"
def remember(self, target: str) -> bool:
"""Save an explicitly connected device, without credentials or shared-list races."""
directory = self._directory()
temporary = None
try:
directory.mkdir(parents=True, exist_ok=True)
destination = directory / (hashlib.sha256(target.encode()).hexdigest() + ".json")
fd, temporary = tempfile.mkstemp(dir=directory, prefix=".device-")
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump({"target": target}, stream)
os.replace(temporary, destination)
return True
except OSError as exc:
logger.warning(f"Cannot remember ADB device {target}: {exc}")
return False
finally:
if temporary:
Path(temporary).unlink(missing_ok=True)
def _remembered(self) -> list[str]:
targets = []
for path in self._directory().glob("*.json"):
try:
target = json.loads(path.read_text(encoding="utf-8"))["target"]
host, port = target.rsplit(":", 1)
if host and 0 < int(port) < 65536:
targets.append(target)
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
logger.warning(f"Invalid remembered ADB device {path.name}: {exc}")
return targets
def refresh(self, adb_path: str, *, force: bool = False) -> list[dict[str, object]]:
if not settings.ARTEMIS_ADB_AUTO_CONNECT or not current_adb_endpoint().is_local_default:
self.results = []
return []
if not self._gate.acquire(blocking=False):
return self.results.copy()
try:
if (
not force
and time.monotonic() - self._last_scan
< settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS
):
return self.results.copy()
self._last_scan = time.monotonic()
self.results = []
self._refresh(adb_path)
except (OSError, psutil.Error, ValueError, subprocess.SubprocessError) as exc:
logger.warning(f"ADB automatic discovery unavailable: {exc}")
self.results.append({"success": False, "message": str(exc)})
finally:
self._gate.release()
return self.results.copy()
def _refresh(self, adb_path: str) -> None:
session = AdbSession(current_adb_endpoint(), adb_path=adb_path)
deadline = time.monotonic() + settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS
devices = session.run(
["devices"],
capture_output=True,
text=True,
timeout=max(deadline - time.monotonic(), 0.01),
)
if devices.returncode:
return
ready = {
_canonical_target(parts[0])
for line in devices.stdout.splitlines()
if len(parts := line.split()) >= 2 and parts[1] in {"device", "unauthorized"}
}
try:
groups = emulator_listeners()
except (OSError, psutil.Error) as exc:
# Missing process-inspection permissions must not stop reconnection
# of explicitly configured phones or make device enumeration fail.
groups = []
logger.warning(f"Cannot inspect local emulator listeners: {exc}")
self.results.append({"success": False, "message": str(exc)})
remembered = self._remembered()
targets = [(group, True) for group in groups] + [([target], False) for target in remembered]
# Resume at a different group if a slow/offline peer exhausts the budget.
# Large multi-instance installations must not starve later devices.
count = len(targets)
start = self._cursor % count if count else 0
for offset in range(count):
index = (start + offset) % count
candidates, probe = targets[index]
self._cursor = (index + 1) % count
if any(_canonical_target(target) in ready for target in candidates):
continue
for target in candidates:
remaining = deadline - time.monotonic()
if remaining <= 0:
return
if probe and not is_adbd(target, min(remaining, 0.3)):
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
return
try:
result = session.run(
["connect", target],
capture_output=True,
text=True,
timeout=min(remaining, 2),
)
except subprocess.TimeoutExpired:
self.results.append(
{"target": target, "success": False, "message": "ADB 连接超时"}
)
continue
output = (result.stdout + result.stderr).strip()
success = (
result.returncode == 0
and "connected to" in output.lower()
and "failed" not in output.lower()
)
state = self._wait_for_transport(session, target, deadline) if success else None
pending = success and state is None
if success and state is None:
success = False
output = f"{output};已发起连接,等待设备就绪,下次刷新会重试"
self.results.append(
{
"target": target,
"success": success,
"state": state,
"pending": pending,
"message": output,
}
)
if success:
logger.info(f"ADB 自动连接:{target} — {output}")
ready.add(_canonical_target(target))
break
if pending:
logger.info(f"ADB 设备连接中:{target} — {output}")
break # Do not create a second alias while the first transport is settling.
logger.warning(f"ADB 自动连接失败:{target} — {output}")
@staticmethod
def _wait_for_transport(session: AdbSession, target: str, deadline: float) -> str | None:
"""adb connect can return before its transport appears in adb devices."""
while (remaining := deadline - time.monotonic()) > 0:
try:
result = session.run(["devices"], capture_output=True, text=True, timeout=remaining)
except subprocess.TimeoutExpired:
return None
for line in result.stdout.splitlines():
parts = line.split()
if (
len(parts) >= 2
and _canonical_target(parts[0]) == _canonical_target(target)
and parts[1] in {"device", "unauthorized"}
):
return parts[1]
time.sleep(min(0.1, max(deadline - time.monotonic(), 0)))
return None
adb_device_discovery = AdbDeviceDiscovery()
+15 -2
View File
@@ -92,6 +92,19 @@ class AdbEndpoint:
def mode(self) -> str:
return "local" if self.is_local_default else "remote"
@property
def client_host(self) -> str:
"""ADB only permits daemon auto-start for the literal host 'localhost'.
Keep forwarded/custom-port endpoints explicit, even on loopback, so a
missing tunnel never starts a different local daemon at that address.
"""
return "localhost" if self.is_local_default else self.host
@property
def client_socket(self) -> str:
return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket
def to_dict(self) -> dict[str, Any]:
return {
**asdict(self),
@@ -108,7 +121,7 @@ class AdbEndpoint:
target = environment if environment is not None else os.environ
target["ADB_HOST"] = self.host
target["ADB_PORT"] = str(self.port)
target["ADB_SERVER_SOCKET"] = self.socket
target["ADB_SERVER_SOCKET"] = self.client_socket
target[ADB_ENDPOINT_ID_ENV] = self.identity
return target
@@ -145,7 +158,7 @@ class AdbSession:
return [
self.adb_path,
"-H",
self.endpoint.host,
self.endpoint.client_host,
"-P",
str(self.endpoint.port),
*arguments,
+132
View File
@@ -0,0 +1,132 @@
"""A test verdict is independent of the task's execution/lifecycle status."""
import json
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from artemis.config import TRACES_PATH
class CaseTestResult(BaseModel):
status: Literal["passed", "failed", "inconclusive", "blocked"] = Field(
description="Judge the system under test against the user's expectations. A correctly executed inspection that reveals an expectation mismatch is failed, even if the executor completed and reported the mismatch correctly."
)
reason: str = Field(description="用简体中文说明提示词中的要求是否满足,以及不能判定的原因。")
evidence: list[str] = Field(
default_factory=list,
description="支撑结论的具体步骤、截图内容或只读探测结果;执行者声称完成不属于独立证据。",
)
RESULT_LABELS = {
"passed": "测试通过",
"failed": "测试不通过",
"inconclusive": "未验证",
"blocked": "测试阻塞",
}
_TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
def normalize_case_result(result: CaseTestResult) -> CaseTestResult:
evidence = [item.strip() for item in result.evidence if item.strip()]
if result.status in {"passed", "failed"} and not evidence:
return CaseTestResult(
status="inconclusive",
reason="验收结论缺少具体证据,不能确认测试是否通过。",
)
return result.model_copy(update={"evidence": evidence})
def resolve_case_result(
outcome: dict | None, execution_status: str, error: str = ""
) -> CaseTestResult | None:
"""Preserve confirmed defects; unfinished execution never becomes a pass."""
outcome = outcome if isinstance(outcome, dict) else {}
tests = outcome.get("tests") or {}
tests = tests if isinstance(tests, dict) else {}
candidate = None
if isinstance(outcome.get("test_result"), dict):
try:
candidate = normalize_case_result(CaseTestResult.model_validate(outcome["test_result"]))
except ValidationError:
candidate = None
failed_items = tests.get("failed_items") or []
if not isinstance(failed_items, list):
failed_items = []
failures = [
item
for item in failed_items
if isinstance(item, dict)
and item.get("kind", "assert") == "assert"
and item.get("evidence")
]
if failures:
return CaseTestResult(
status="failed",
reason=";".join(str(item.get("item_text") or "测试断言不满足") for item in failures),
evidence=[str(item["evidence"]) for item in failures],
)
if candidate and candidate.status == "failed":
return candidate
if execution_status not in _TERMINAL:
return None
if execution_status not in {"completed", "success"} or outcome.get("task_status") in {
"blocked",
"partial",
"failed",
"cancelled",
"interrupted",
}:
return CaseTestResult(
status="blocked",
reason=error
or str(
outcome.get("error")
or outcome.get("explanation")
or "任务未完整执行,无法完成验收。"
),
)
if candidate:
if candidate.status == "passed" and (tests.get("inconclusive") or tests.get("unchecked")):
return CaseTestResult(
status="inconclusive",
reason="部分检查项缺少证据或尚未验证。",
evidence=candidate.evidence,
)
return candidate
# Existing Pro runs already have a verdict ledger. A bare 'completed' from
# Flash (or a process exit code) is never used as a positive test result.
if tests.get("failed"):
return CaseTestResult(
status="blocked", reason="执行检查未满足;没有证据确认被测功能不符合预期。"
)
if tests.get("inconclusive") or tests.get("unchecked"):
return CaseTestResult(status="inconclusive", reason="部分检查项缺少证据或尚未验证。")
if tests.get("passed"):
return CaseTestResult(
status="passed",
reason="已记录的验证项全部通过。",
evidence=[f"已有检查结果:{tests['passed']} 项通过。"],
)
return CaseTestResult(
status="inconclusive", reason="没有保存测试验收结论;执行完成不等于测试通过。"
)
def read_case_result(
session_id: str, execution_status: str, error: str = "", *, root: Path | None = None
) -> dict | None:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
outcome = {}
try:
outcome = json.loads(
((root or TRACES_PATH) / session_id / "run_outcome.json").read_text(encoding="utf-8")
)
except (OSError, ValueError):
pass # Old tasks and active tasks may not have an outcome yet.
result = resolve_case_result(outcome, execution_status, error)
return result.model_dump() if result else None
+17 -7
View File
@@ -25,6 +25,8 @@ import threading
import time
from typing import Any
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
@@ -107,6 +109,7 @@ class DevicePool:
adb = self._resolve_adb()
if not adb:
return None
adb_device_discovery.refresh(adb)
try:
res = subprocess.run(
[adb, "devices", "-l"],
@@ -131,6 +134,7 @@ class DevicePool:
adb = self._resolve_adb()
if not adb:
return None
await asyncio.to_thread(adb_device_discovery.refresh, adb)
proc = None
try:
proc = await asyncio.create_subprocess_exec(
@@ -242,23 +246,29 @@ class DevicePool:
async def _start_adb_server(self, timeout: float) -> None:
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
endpoint = current_adb_endpoint()
if not endpoint.is_local_default:
return
adb = self._resolve_adb()
if not adb:
return
proc = None
try:
clean_env = os.environ.copy()
clean_env.pop("ADB_SERVER_SOCKET", None)
session = AdbSession(endpoint, adb_path=adb)
proc = await asyncio.create_subprocess_exec(
adb,
"start-server",
*session.command(["start-server"]),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=clean_env,
env=session.environment(),
)
await asyncio.wait_for(proc.communicate(), timeout=timeout)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if proc.returncode != 0:
output = (stderr or stdout).decode(errors="replace").strip()
logger.warning(
f"Local ADB server startup failed (exit {proc.returncode}): {output}"
)
except Exception as exc:
logger.debug(f"adb start-server warm-up did not complete: {exc}")
logger.warning(f"Local ADB server startup did not complete: {exc}")
if proc is not None:
try:
proc.kill()
+16 -6
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import uuid
from artemis.config import TRACES_PATH
from artemis.runtime.case_result import resolve_case_result
def read_worker_outcome(session_id: str) -> dict:
@@ -37,12 +38,19 @@ def save_failure_result(session_id: str, error: str, *, code: str = "execution_f
directory = TRACES_PATH / session_id
directory.mkdir(parents=True, exist_ok=True)
payload: dict = {}
try:
previous = json.loads((directory / "result.json").read_text(encoding="utf-8"))
if isinstance(previous, dict):
payload.update(previous)
except (OSError, ValueError):
pass # Missing/truncated results are expected after a killed worker.
for name in ("result.json", "run_outcome.json"):
try:
previous = json.loads((directory / name).read_text(encoding="utf-8"))
if isinstance(previous, dict):
prior_result = resolve_case_result(payload, "failed")
prior_tests = payload.get("tests")
payload.update(previous)
if isinstance(prior_tests, dict) and isinstance(previous.get("tests"), dict):
payload["tests"] = {**prior_tests, **previous["tests"]}
if prior_result and prior_result.status == "failed":
payload["test_result"] = prior_result.model_dump()
except (OSError, ValueError):
pass # Missing/truncated results are expected after a killed worker.
payload.update(
{
"status": "failed",
@@ -54,6 +62,8 @@ def save_failure_result(session_id: str, error: str, *, code: str = "execution_f
"tests": payload.get("tests") or {},
}
)
test_result = resolve_case_result(payload, "failed", error)
payload["test_result"] = test_result.model_dump() if test_result else None
for name in ("result.json", "run_outcome.json"):
target = directory / name
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
+22 -6
View File
@@ -701,7 +701,16 @@ class Agent:
runner = FlashRunner(context, goal=request.goal, max_turns=max_turns)
flash_result = await runner.run(state)
output = flash_result
from artemis.agents.checker.flash import settle_flash_outcome
run_outcome = await settle_flash_outcome(
context,
request.goal,
flash_result,
final_report_received=runner.final_report_received,
)
output = attach_test_summary(flash_result, run_outcome)
state.run_outcome = run_outcome
last_state_snapshot = state.model_dump()
status = flash_result.get("status")
@@ -1545,17 +1554,21 @@ def attach_test_summary(output, run_outcome: dict | None):
+ int(tests.get("inconclusive", 0))
+ int(tests.get("unchecked", 0))
)
if total <= 0:
test_result = run_outcome.get("test_result")
if total <= 0 and not test_result:
return output
summary = {"task_status": run_outcome.get("task_status"), **tests}
additions = {"test_summary": summary} if total > 0 else {}
if test_result:
additions["test_result"] = test_result
if isinstance(output, dict):
merged = dict(output)
merged.setdefault("test_summary", summary)
merged.update(additions)
return merged
if output is None:
return {"test_summary": summary}
return additions
if isinstance(output, str):
return {"result": output, "test_summary": summary}
return {"result": output, **additions}
# Typed/structured outputs keep their shape; the summary stays available in
# run_outcome.json and the session state.
return output
@@ -1566,6 +1579,9 @@ def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
if task_status != "completed":
return "_FAIL"
tests = (run_outcome or {}).get("tests") or {}
if int(tests.get("failed", 0)) > 0:
if (
int(tests.get("failed", 0)) > 0
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed"
):
return "_TESTFAIL"
return "_PASS"
+6 -1
View File
@@ -7,7 +7,11 @@
"npm_registry": "https://registry.npmmirror.com",
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip"
"scrcpy_url": "https://ghfast.top/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"scrcpy_fallback_urls": [
"https://gh-proxy.com/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip"
]
},
"overrides": {
"python_index": "",
@@ -16,6 +20,7 @@
"node_mirror": "",
"adb_url": "",
"scrcpy_url": "",
"scrcpy_fallback_urls": null,
"uv_installer_url": ""
}
}
+10 -4
View File
@@ -2,7 +2,7 @@
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
目前仅支持 Codex MCP 配置。Windows 一键脚本检测并同步 Codex;未检测到配置目录时,在项目目录执行 `uv run artemis mcp --install codex`。配置模板使用 `uv run artemis mcp --generate-config codex` 查看;其他名称和 `all` 不再支持。更新后重新加载 Codex MCP;复制 Skill 后重新加载技能。
可以向 AI 这样下达任务:
@@ -32,11 +32,13 @@ Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。1.1.1 及更新版本的发布 ZIP 是精简的 Git 工作树,解压后也能在后续运行时检查更新。1.1.0 及更早 ZIP 缺少 `.git`,需手动下载新版源码包一次。没有 Git、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。`-SkipUpdate` 可跳过源码更新检查。
Windows 启动入口 `start.bat` / `start-cn.bat` 只检查并提示源码更新,`-SkipUpdate` 跳过提示。独立升级入口为 `update.bat` / `update-cn.bat`:先把升级程序复制到项目外的临时目录,检查本地修改,再终止本项目的 ARTEMIS 服务、MCP 和任务进程;之后仅做 Git 快进更新,并用新版启动程序准备环境,完成后由用户重新启动。其他项目、Codex、模拟器和共享 ADB 不受影响;运行中的任务会被中断,不会作为成功任务处理。1.1.1 及更新版本的发布 ZIP 含 Git 工作树,旧版无 `.git` 的 ZIP 需改用新版包或 Git 克隆。没有 Git、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。未运行过启动脚本的 Git 源码目录也可直接升级:先自动准备最小 Python 环境,快进源码后再安装新版依赖。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。双击升级失败时会保留原因并等待回车;自动化调用可设置 `ARTEMIS_NO_PAUSE=1`,输入重定向时也不会等待。
MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
scrcpy 固定版本的国内下载采用多个来源,失败或 SHA256 不符时自动尝试下一来源,全部失败会显示 HTTP 状态、超时或校验原因。`config/downloads.json` 的 `overrides.scrcpy_url` 指定首选 HTTPS 地址,`overrides.scrcpy_fallback_urls` 指定备用地址数组;`null` 使用默认备用源,`[]` 禁用备用源。国内模式不自动回退到 GitHub 直连。
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
@@ -52,6 +54,10 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
UIAutomator2 启动失败时,先释放本次连接启动的服务,再按现有策略重试;最终错误保留设备编号、尝试次数和底层原因。初始化失败且没有操作步骤时,用例目标属于未执行、未验证,应先检查设备诊断和启动日志,不能据此认定被测功能失败。
Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中的要求核对执行结论、步骤截图及探测证据,无需另填预期结果。`test_result` 包含 `status`(`passed`、`failed`、`inconclusive`、`blocked`)、中文 `reason` 和 `evidence`;任务列表、报告与 MCP 使用同一结论。`completed` 仍只代表执行结束,可同时存在测试不通过。Flash 只在退出时验收,不添加计划或中途检查;沿用 Checker 的超时和开关,关闭、超时或缺少证据时显示未验证,不重跑用例。
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
+13 -3
View File
@@ -8,17 +8,23 @@
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
“执行完成”与“验证通过”分开记录。没有实际检查记录时显示“未验证”,不会把模型结束执行当作业务测试通过。正在执行的任务也可以下载,但文件只代表下载时的快照。
“执行完成”与测试结论分开记录。预期直接写在任务提示词中即可;Flash 和 Pro 默认用只读终检核对提示词、用户指导、执行结论及实际证据,不增加必填表单。任务列表显式标注“测试通过 / 测试不通过 / 未验证 / 测试阻塞”,结果区域和 Word/Excel 同时列出判定依据及证据。终检关闭、出错或缺少证据时显示“未验证”,设备或执行异常不当作产品缺陷;已确认的断言失败会保留。正在执行的任务也可以下载,但文件只代表下载时的快照。
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
4. 上传文件,查看逐行校验结果。默认勾选全部有效用例,可逐项取消,或用“全选 / 全不选”切换;未勾选用例不会导入。所有错误修正后才能提交。
5. 明确选择设备、执行模式、超时时间和整批循环次数(默认 1),查看“已选用例数 × 轮数”的执行总数,然后点击“确认导入并开始执行”。
循环次数独立保存在每个批次中。每轮按表格顺序执行整批所选用例,然后进入下一轮;各次执行有独立任务编号、状态和报告,队列及报告标题标注轮次。暂停、重启恢复只继续未开始项;“失败即停止”跳过全部后续轮次,“复制失败项为新批次”只复制失败记录各执行一次,不沿用原循环次数。单批默认最多 10000 次执行,可用 `.env` 中 `ARTEMIS_BATCH_MAX_EXECUTIONS` 调整;网页和 MCP 使用同一限制。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
@@ -34,6 +40,10 @@
## 环境配置
设备刷新、启动和重启 ADB 后会自动连接本机运行中的 MuMu、雷电、夜神、逍遥及蓝叠等常见模拟器:读取进程拥有的监听端口,验证 ADB 协议后连接,不扫描局域网。同一模拟器的兼容端口不重复连接,已有在线或等待授权的连接保持不动。USB 手机和 Android SDK 模拟器由 ADB 原生发现。其他模拟器或无线手机可在首页“无线 ADB”中填写实际地址和端口;成功后地址保存在本机应用数据目录的 `adb-devices` 中,后续自动重连。Android 无线调试须先完成配对和授权,端口变化后需要重新连接;自动连接不会绕过授权。
“功能配置”提供“自动连接设备”开关,默认开启;每轮连接预算默认 5 秒,发现间隔默认 10 秒,避免离线设备拖住刷新。对应环境变量为 `ARTEMIS_ADB_AUTO_CONNECT`、`ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS`、`ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS`。MCP `mobile_config(section="capabilities")` 可配置 `adb_auto_connect`、`adb_discovery_timeout_seconds`、`adb_discovery_interval_seconds`;`mobile_workspace` 的设备列表和 `mobile_diagnose` 返回自动连接结果。使用远程或转发 ADB 服务时不发现本机端口。连接设备不等于选择任务设备,多个设备时仍需明确选择。
`.env` 可配置 `ARTEMIS_TASK_TIMEOUT_SECONDS`(单任务总执行时限,默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值,每行 JSON 的 `timeout_seconds` 优先于批次。单任务、批次和 MCP 均受总执行时限保护,不计排队和用户暂停时间;即使执行进程卡住,也由父进程终止并保存失败原因。
守护服务通过轻量状态接口探测,`ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS` 默认 2 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
+19
View File
@@ -0,0 +1,19 @@
# ARTEMIS-Eapil 1.3.2
## 更新与修复
- MCP 配置安装与更新仅支持 Codex。一键启动不再检查或修改 VS Code、Cursor 等其他客户端的配置,其配置错误不会阻止网页启动。手动 CLI 仅接受 `--install codex` 和 `--generate-config codex`,不再支持其他客户端或 `all`。
- 启动日志明确显示实际检查的 Codex 配置路径,以及安装、更新、已是最新、未检测到或自定义跳过的结果。未设置或空值的 `CODEX_HOME` 使用当前用户的 `.codex` 目录;保留已有自定义路径设置。
- 修复 Windows 手动重复安装 Codex MCP 时,路径反斜杠被误当成正则替换符的问题。配置写入失败会返回失败状态。
- 固定批处理脚本为 CRLF 换行,修复中文启动脚本在部分源码包中将中文截断并当成命令执行的问题。
- 同步 README、AI 接入说明及随源码分发的 Skill。
## 安装与升级
下载附件 `artemis-eapil-1.3.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和任务记录。请使用发布附件,Gitea 自动生成的“源代码”压缩包不含 Git 元数据。
已有安装可通过启动脚本更新。更新后重启服务、刷新网页,并重新加载 Codex MCP。未检测到 Codex 配置目录时,可运行 `uv run artemis mcp --install codex` 手动安装。仅需网页时使用 `-SkipMcpSync`。
## 验证
默认 Python 测试范围分批验证共 2267 项通过,6 项跳过,8 项按标记排除;Ruff、质量基线、核心类型检查和 Git 差异检查通过。已验证 Windows 中文启动与帮助输出、Codex 默认及自定义目录识别、重复安装、其他客户端配置保留,以及源码包 CRLF 和干净 Git 状态。本次没有修改前端交互,也未运行真实 Android 任务。
+13 -3
View File
@@ -27,6 +27,9 @@ MODEL_FIELDS = {
"reasoning_effort",
}
CAPABILITY_FIELDS = {
"adb_auto_connect",
"adb_discovery_interval_seconds",
"adb_discovery_timeout_seconds",
"video_enabled",
"recording_enabled",
"outputter_enabled",
@@ -147,7 +150,8 @@ async def mobile_config(
step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),
observation_timeout_seconds (60), observation_max_attempts (3),
device_command_timeout_seconds (10), action_failure_attempts (3),
worker_cleanup_timeout_seconds (30).
worker_cleanup_timeout_seconds (30), adb_auto_connect (true),
adb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).
reports: formats=['docx','xlsx'] (at least one).
API keys are never accepted or returned: use the Web UI or .env for credentials.
Saving settings does not change an already running task. Flash/Pro are execution
@@ -221,13 +225,19 @@ async def mobile_batch(
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}.
repeat_count is a positive integer: execute all selected cases in order for each
round, with independent session IDs, statuses and reports. The default total limit
(cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS.
Only include user-selected cases; get returns iteration/repeat_count per job.
Required device
must be user-selected. Creation validates and saves a DRAFT; start executes it.
Reuse the same request_id and exact payload after a connection failure; never invent
a new ID for the same submission. lookup(request_id) resolves an uncertain outcome.
get returns per-case status. pause stops future dispatch, not the active task;
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
retry_failed creates a new draft only after an explicit rerun request; it is not
retry_failed copies each failed execution once into a new draft (repeat_count=1)
only after an explicit rerun request; it is not
transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch and its task sessions, reports and files after
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
+2
View File
@@ -115,6 +115,8 @@ def _render_check(result: ProbeResult) -> dict[str, Any]:
"summary": result.summary,
}
if result.status is ProbeStatus.PASS:
if result.id == "android_adb" and result.metadata.get("automatic_connections"):
check["automatic_connections"] = _scrub(result.metadata["automatic_connections"])
return check
check["category"] = result.category.value
check["detail"] = result.description
+15 -1
View File
@@ -271,7 +271,9 @@ def mobile_manage_task(
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable
passed/failed/inconclusive/unchecked counts plus failed-item details —
no need to parse report prose), and `progress` (Flash:
no need to parse report prose), `test_result` (Flash and Pro: status
passed/failed/inconclusive/blocked, reason and evidence; completed only
means execution ended), and `progress` (Flash:
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal).
@@ -419,11 +421,23 @@ def mobile_manage_task(
"task_status": run_outcome.get("task_status"),
**tests,
}
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(
run_outcome, current_status, response.get("error") or ""
)
response["test_result"] = test_result.model_dump() if test_result else None
except (OSError, ValueError, TypeError):
# Unreadable or malformed run_outcome.json: omit the summary.
pass
break
if "test_result" not in response:
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(None, current_status, response.get("error") or "")
response["test_result"] = test_result.model_dump() if test_result else None
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
progress: dict[str, Any] = {}
+3 -1
View File
@@ -279,7 +279,9 @@ def mobile_run_task(
UI path. No step cap by default (history is compressed, and the agent
can recall earlier steps and query the session recording), but no ADB
shell, no pre-execution safety net, no persistent plan or notes, and
no verification or written report — unsuitable for exploration,
no midway checkpoints or persistent written notes. An exit-only read-only
Checker judges the prompt's expectations and returns test_result;
execution completion is separate from test pass. Unsuitable for exploration,
multi-branch recovery, structured monitoring/polling, or tasks that
must report back large amounts of detail.
- **Pro**: complex, exploratory, or multi-branch tasks; continuous
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "1.3.1"
version = "1.5.1"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+5 -1
View File
@@ -8,7 +8,7 @@ function Get-BootstrapText {
}
function Initialize-ArtemisPython {
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
param([Parameter(Mandatory=$true)][string]$ProjectRoot, [switch]$RuntimeOnly)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
@@ -65,6 +65,10 @@ function Initialize-ArtemisPython {
throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
}
# The frozen source updater uses only Python's standard library. Install
# project dependencies once, from the new lock file AFTER the fast-forward.
if ($RuntimeOnly) { return $pythonExe }
# A cheap check skips installation when the lock and installed packages agree.
# --inexact preserves any optional development tools already installed.
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
+104 -5
View File
@@ -1,8 +1,95 @@
# Download settings are process-local; never edit global pip/npm/uv settings.
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Get-DownloadText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
$script:ArtemisDownloadError = ''
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
$previousErrors = $ErrorActionPreference
try {
# Native stderr is an ErrorRecord in PowerShell 5.1. Inspect the code,
# and log only HTTP status/exit code, never a URL containing credentials.
$ErrorActionPreference = 'Continue'
$status = & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec --proto '=https' --proto-redir '=https' --write-out '%{http_code}' -o $OutFile $Uri 2>$null
$curlCode = $LASTEXITCODE
if ($curlCode -eq 0 -and (Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { return $true }
$script:ArtemisDownloadError = if ($curlCode -eq 22) { "HTTP $status" }
elseif ($curlCode -eq 28) { Get-DownloadText "Download timed out after $TimeoutSec seconds" "下载超时($TimeoutSec 秒)" }
else { "curl exit $curlCode" }
# Do not repeat an HTTP rejection or a full timeout with a second client.
if ($curlCode -in @(22, 28)) { return $false }
} catch {
$script:ArtemisDownloadError = 'curl failed'
} finally {
$ErrorActionPreference = $previousErrors
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
}
$previousProgress = $ProgressPreference
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
if ((Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) {
$script:ArtemisDownloadError = ''
return $true
}
$script:ArtemisDownloadError = Get-DownloadText 'Empty download' '下载内容为空'
} catch {
$response = $_.Exception.Response
$script:ArtemisDownloadError = if ($response) { "HTTP $([int]$response.StatusCode)" }
else { Get-DownloadText 'Network/TLS error or download timeout' '网络、TLS 连接失败或下载超时' }
} finally {
$ProgressPreference = $previousProgress
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
return $false
}
function Get-ArtemisFileSha256 {
param([Parameter(Mandatory=$true)][string]$Path)
# Do not depend on Get-FileHash module autoloading: PowerShell 7 can pass its
# own module search path to the Windows PowerShell 5.1 launcher.
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
finally { $stream.Dispose(); $hasher.Dispose() }
}
function Invoke-ArtemisVerifiedDownload {
param(
[Parameter(Mandatory=$true)][string[]]$Urls,
[Parameter(Mandatory=$true)][string]$OutFile,
[Parameter(Mandatory=$true)][string]$Sha256
)
$sources = @($Urls | Select-Object -Unique)
for ($index = 0; $index -lt $sources.Count; $index++) {
$sourceHost = ([Uri]$sources[$index]).Host
Write-Host (Get-DownloadText " [DOWNLOAD $($index + 1)/$($sources.Count)] scrcpy: $sourceHost" " [下载 $($index + 1)/$($sources.Count)] scrcpy:$sourceHost") -ForegroundColor Cyan
if (Invoke-DownloadFile -Uri $sources[$index] -OutFile $OutFile) {
$actualHash = Get-ArtemisFileSha256 -Path $OutFile
if ($actualHash -eq $Sha256) { return $true }
$script:ArtemisDownloadError = Get-DownloadText 'Official SHA256 verification failed; archive rejected' '官方 SHA256 校验失败,已拒绝此安装包'
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
}
Write-Host (Get-DownloadText " [WARN] $sourceHost`: $script:ArtemisDownloadError" " [提示] $sourceHost 下载失败:$script:ArtemisDownloadError") -ForegroundColor Yellow
}
return $false
}
function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$path = Join-Path $ProjectRoot 'config\downloads.json'
$config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if (-not $Mode) { $Mode = $config.mode }
@@ -28,6 +115,21 @@ function Initialize-ArtemisDownloads {
}
}
}
$fallbacks = @('https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip')
if ($Mode -eq 'China' -and $null -ne $config.china.scrcpy_fallback_urls) { $fallbacks = $config.china.scrcpy_fallback_urls }
if ($null -ne $config.overrides.scrcpy_fallback_urls) { $fallbacks = $config.overrides.scrcpy_fallback_urls }
if ($fallbacks -isnot [Array]) { throw 'config/downloads.json: scrcpy_fallback_urls must be an array of HTTPS URLs.' }
foreach ($url in $fallbacks) {
$uri = $null
if ($url -isnot [string] -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') {
throw 'config/downloads.json: scrcpy_fallback_urls must contain only HTTPS URLs.'
}
}
$values.scrcpy_urls = @(@($values.scrcpy_url) + @($fallbacks) | Select-Object -Unique)
if ($Mode -eq 'China') {
# China mode must never silently fall back to a direct GitHub download.
$values.scrcpy_urls = @($values.scrcpy_urls | Where-Object { ([Uri]$_).Host -notin @('github.com', 'www.github.com') })
}
# Explicit caller environment settings take priority over the selected preset.
if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX }
elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL }
@@ -42,7 +144,7 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') {
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mode -eq 'China') {
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
} else {
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
@@ -67,10 +169,7 @@ function Install-ArtemisUvFromIndex {
if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' }
$zip = Join-Path $work 'uv.zip'
if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' }
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($zip)
try { $digest = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') }
finally { $stream.Dispose(); $hasher.Dispose() }
$digest = Get-ArtemisFileSha256 -Path $zip
if ($digest -ne $candidate.Groups['hash'].Value) {
throw 'The downloaded uv package failed SHA256 verification.'
}
+17 -6
View File
@@ -1,4 +1,4 @@
# Copyright 2026 Google LLC
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -91,12 +91,23 @@ function Update-EnvironmentPath {
function Start-LocalAdbServer {
if (Test-CommandExists "adb") {
$adbErrorPreference = $ErrorActionPreference
try {
$origSocket = $env:ADB_SERVER_SOCKET
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
& adb start-server 2>$null | Out-Null
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
} catch {}
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
# ADB prints normal daemon-start messages there, so inspect its exit code.
$ErrorActionPreference = "Continue"
# Explicit options ignore inherited remote-server environment settings.
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
}
} catch {
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $adbErrorPreference
}
}
}
+20
View File
@@ -0,0 +1,20 @@
# Shared preparation/upgrade mutex. ASCII for Windows PowerShell 5.1.
function Enter-ArtemisInstallationLock {
param([string]$ProjectRoot)
$normalized = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\').ToLowerInvariant()
$hash = [Security.Cryptography.SHA256]::Create()
try { $digest = [BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($normalized))).Replace('-', '') }
finally { $hash.Dispose() }
$mutex = New-Object Threading.Mutex($false, "Local\ArtemisInstallation-$digest")
try {
try { $entered = $mutex.WaitOne(0) }
catch [Threading.AbandonedMutexException] { $entered = $true }
if (-not $entered) { throw 'Another startup preparation or upgrade is running. Please wait and retry.' }
return $mutex
} catch { $mutex.Dispose(); throw }
}
function Exit-ArtemisInstallationLock {
param($Mutex)
if ($null -ne $Mutex) { $Mutex.ReleaseMutex(); $Mutex.Dispose() }
}
+108 -152
View File
@@ -17,8 +17,10 @@ param(
[int]$Port = 8000,
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$PrepareOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[switch]$Help = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
@@ -31,37 +33,72 @@ function Get-LaunchText {
return $English
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
$ProjectVersion = 'unknown'
try {
$inProjectSection = $false
foreach ($line in Get-Content -LiteralPath (Join-Path $RootDir 'pyproject.toml') -Encoding UTF8 -ErrorAction Stop) {
if ($line -match '^\[project\]\s*$') { $inProjectSection = $true; continue }
if ($inProjectSection -and $line -match '^\[') { break }
if ($inProjectSection -and $line -match '^version\s*=\s*"([^"]+)"') {
$ProjectVersion = $Matches[1]
break
}
}
} catch {}
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files.
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
if ($Help) {
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
Write-Host ''
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
Write-Host (Get-LaunchText ' -NoOpen Do not open the browser automatically' ' -NoOpen 启动服务后不自动打开浏览器')
Write-Host (Get-LaunchText ' -SkipUpdate Skip the source update notification' ' -SkipUpdate 跳过源码更新提示(启动不会拉取源码)')
Write-Host (Get-LaunchText ' -SkipMcpSync Skip Codex MCP installation and updates' ' -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新')
Write-Host (Get-LaunchText ' -Mirror China Use China download mirrors' ' -Mirror China 使用国内下载镜像')
Write-Host (Get-LaunchText ' -Help Show this help' ' -Help 显示本说明')
Write-Host ''
Write-Host (Get-LaunchText 'Download sources: config\downloads.json' '国内镜像设置:config\downloads.json')
Write-Host (Get-LaunchText 'First launch requires Internet access. Upgrade with update.bat; startup only checks for updates.' '首次运行需联网;启动仅提示源码更新,升级请运行 update-cn.bat。')
exit 0
}
Write-Host '======================================================'
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
Write-Host '======================================================'
Write-Host '正在检查已有环境、更新提示及依赖;首次启动可能需要下载组件。'
Write-Host '镜像地址和下载超时可在 config\downloads.json 中调整。'
} else {
Write-Host (Get-LaunchText ' Artemis Autonomous Mobile Agent UI' ' ARTEMIS 手机自动化测试')
Write-Host '======================================================'
}
Write-Host ''
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
} catch {}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
Set-Location $RootDir
# Existing installations check source BEFORE importing bootstrap helpers.
# Startup never mutates source; upgrades run from a staged, independent process.
. (Join-Path $ScriptDir 'installation_lock.ps1')
$installationLock = $null
if ($env:ARTEMIS_UPGRADE_CHILD -ne '1') {
try { $installationLock = Enter-ArtemisInstallationLock -ProjectRoot $RootDir }
catch { Write-Host (Get-LaunchText "[ERROR] $_" '[错误] 另一个环境准备或升级正在运行,请稍后重试。') -ForegroundColor Red; exit 1 }
}
$existingPython = Join-Path $RootDir '.venv\Scripts\python.exe'
if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source --port $Port
$updateExit = $LASTEXITCODE
if ($updateExit -eq 20) { exit 1 }
if ($updateExit -eq 10) {
# Reparse the freshly updated launcher; do not continue running stale functions.
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs
exit $LASTEXITCODE
}
if ($updateExit -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' ' [错误] 源码更新检查失败。请处理上方错误;要运行当前源码可传入 -SkipUpdate。') -ForegroundColor Red
exit 1
}
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check
# Network/authentication failures are warnings, never a reason to prevent startup.
}
. (Join-Path $ScriptDir 'windows_tools.ps1')
. (Join-Path $ScriptDir 'download_config.ps1')
try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
@@ -75,112 +112,26 @@ Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI
Write-Host "======================================================" -ForegroundColor Cyan
Write-Host ""
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
function Start-LocalAdbServer {
if (Test-CommandExists "adb") {
$adbErrorPreference = $ErrorActionPreference
try {
$origSocket = $env:ADB_SERVER_SOCKET
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
& adb start-server 2>$null | Out-Null
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
} catch {}
}
}
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
# 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11)
if (Test-CommandExists "curl.exe") {
try {
& curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
# ADB prints normal daemon-start messages there, so inspect its exit code.
$ErrorActionPreference = "Continue"
# Explicit options ignore inherited remote-server environment settings.
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
}
} catch {}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
} catch {
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $adbErrorPreference
}
}
# 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs)
try {
$prevProgress = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
$ProgressPreference = $prevProgress
if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
}
} catch {
$ProgressPreference = $prevProgress
}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
return $false
}
function Install-PortablePlatformTools {
@@ -226,38 +177,37 @@ function Install-PortableScrcpy {
}
Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow
if ($ArtemisDownloads.scrcpy_urls.Count -eq 0) {
Write-Host (Get-LaunchText ' [WARN] No scrcpy download source is configured. Set overrides.scrcpy_url.' ' [提示] 未配置可用的 scrcpy 下载源;请设置 overrides.scrcpy_url。') -ForegroundColor Yellow
return $false
}
$work = Join-Path ([IO.Path]::GetTempPath()) ('artemis-scrcpy-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $work -ErrorAction Stop | Out-Null
try {
$zipPath = "$env:TEMP\scrcpy-win64.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
}
$extractDir = "$env:TEMP\scrcpy_extract"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
$zipPath = Join-Path $work 'scrcpy.zip'
if (Invoke-ArtemisVerifiedDownload -Urls $ArtemisDownloads.scrcpy_urls -OutFile $zipPath -Sha256 $ArtemisDownloads.scrcpy_sha256) {
$extractDir = Join-Path $work 'extracted'
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDir)
$extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1
if ($extractedFolder) {
if (-not (Test-Path $scrcpyDir)) {
New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null
}
Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force
}
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") {
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
$contentDir = if (Test-Path -LiteralPath (Join-Path $extractDir 'scrcpy.exe')) { $extractDir }
elseif ($extractedFolder) { $extractedFolder.FullName }
else { throw 'scrcpy ZIP contains no application directory.' }
if (-not (Test-Path -LiteralPath (Join-Path $contentDir 'scrcpy.exe'))) { throw 'scrcpy ZIP contains no scrcpy.exe.' }
New-Item -ItemType Directory -Path $scrcpyDir -Force -ErrorAction Stop | Out-Null
Get-ChildItem -LiteralPath $contentDir | Copy-Item -Destination $scrcpyDir -Recurse -Force -ErrorAction Stop
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
} catch {
Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
} finally {
$fullWork = [IO.Path]::GetFullPath($work)
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if ($fullWork.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $fullWork -Leaf) -like 'artemis-scrcpy-*') {
Remove-Item -LiteralPath $fullWork -Recurse -Force -ErrorAction SilentlyContinue
}
}
return $false
}
@@ -356,16 +306,16 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
}
}
if (-not $SkipMcpSync) {
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
Write-Host (Get-LaunchText ' [CHECK] Installing/updating Codex MCP...' ' [检查] 正在安装或更新 Codex MCP...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
if ($LASTEXITCODE -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
Write-Host (Get-LaunchText ' [ERROR] Codex MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] Codex MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
exit 1
}
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
}
if ($SetupOnly) {
if ($SetupOnly -and -not $PrepareOnly) {
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
exit 0
}
@@ -414,13 +364,13 @@ if ($missingCore.Count -gt 0) {
}
if (-not (Test-CommandExists "scrcpy")) {
if (-not (Install-PortableScrcpy)) {
Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow
Write-Host (Get-LaunchText ' [WARN] All scrcpy sources failed. Check the HTTP/timeout/checksum error above, or configure overrides.scrcpy_url and scrcpy_fallback_urls in config/downloads.json.' ' [提示] scrcpy 下载源均未成功;请查看上方 HTTP、超时或校验错误,或在 config/downloads.json 中设置 overrides.scrcpy_url 与备用地址 scrcpy_fallback_urls。') -ForegroundColor Yellow
}
}
}
# Ensure local ADB daemon is warm & listening so probes do not hit 'Connection refused'
Start-LocalAdbServer
if (-not $PrepareOnly) { Start-LocalAdbServer }
# 6. Check and build Showcase UI if not already compiled
$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html"
@@ -512,9 +462,15 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
# MCP clients were refreshed after Python dependency sync above.
Exit-ArtemisInstallationLock -Mutex $installationLock
$installationLock = $null
if ($PrepareOnly) {
Write-Host (Get-LaunchText '[OK] Python, frontend and Codex MCP preparation complete.' '[完成] Python、网页构建和 Codex MCP 已准备好。') -ForegroundColor Green
exit 0
}
# 8. Launch unified Showcase UI & open browser
# Ensure local ADB daemon is warm & listening
Start-LocalAdbServer
if (-not $PrepareOnly) { Start-LocalAdbServer }
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
+102 -231
View File
@@ -1,4 +1,4 @@
"""Check an existing checkout and synchronize installed local MCP clients."""
"""Check an existing checkout and synchronize the local Codex MCP configuration."""
from __future__ import annotations
@@ -13,7 +13,6 @@ import subprocess
import sys
import tempfile
import tomllib
import urllib.request
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
@@ -42,34 +41,16 @@ def git(root: Path, *args: str, timeout: int = 15) -> str:
return result.stdout.strip()
def update_source(root: Path, port: int = 8000) -> int:
"""Return 10 after a fast-forward (caller reloads launcher); never overwrite local work."""
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/api/status", timeout=2) as response:
status = json.load(response)
if (
status.get("active_tasks")
or status.get("queue")
or (status.get("status") == "running" and status.get("pid"))
):
print(
launch_text(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.",
"[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。",
)
)
return 20
except (OSError, ValueError):
pass
def update_source(root: Path, *, check_only: bool = False) -> int:
"""Return 10 for an available/applied fast-forward, 1 on failure; preserve local work."""
if not shutil.which("git"):
print(
launch_text(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.",
"[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。",
"[ERROR] Install Git before upgrading; local source is preserved.",
"[提示] 未安装 Git,无法升级;当前源码保持不变。",
)
)
return 0
return 1
if not (root / ".git").exists():
print(
launch_text(
@@ -77,8 +58,16 @@ def update_source(root: Path, port: int = 8000) -> int:
"[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。",
)
)
return 0
return 1
try:
if not check_only and git(root, "status", "--porcelain"):
print(
launch_text(
"[ERROR] Local changes exist; commit or back up first.",
"[错误] 发现本地修改;请先提交或备份后再升级。",
)
)
return 1
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
remote = git(root, "config", "--get", f"branch.{branch}.remote")
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
@@ -98,45 +87,53 @@ def update_source(root: Path, port: int = 8000) -> int:
"[检查] 正在检查当前分支的远程更新...",
)
)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=5 if check_only else 45)
target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD")
if current == target:
print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0
if check_only:
print(
launch_text(
"[UPDATE] A different upstream revision is available. Run update.bat (update-cn.bat for China mirrors).",
"[更新] 远程版本有变化;请运行 update-cn.bat 或 update.bat 升级。",
)
)
return 10
if git(root, "status", "--porcelain"):
print(
launch_text(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally.",
"[跳过] 发现本地修改;请先提交或备份。此次继续使用本地源码。",
"[ERROR] Local changes appeared during the check. Upgrade stopped; files preserved.",
"[错误] 检查期间出现本地修改;已停止升级,保留现有文件。",
)
)
return 0
return 1
ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current:
print(
launch_text(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.",
"[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。",
"[ERROR] Local branch is ahead or diverged. Upgrade stopped; no merge/rebase performed.",
"[错误] 本地分支领先或分叉;已停止升级,不自动合并。",
)
)
return 0
return 1
git(root, "merge", "--ff-only", target, timeout=30)
print(
launch_text(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。",
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Preparing the updated installation.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},准备同步新版环境。",
)
)
return 10
except (RuntimeError, subprocess.TimeoutExpired) as exc:
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
print(
launch_text(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.",
"[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。",
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git check failed or timed out.'} Local source is preserved.",
"[提示] Git 检查/更新失败或超时;请检查网络、凭据和仓库状态。当前源码保持不变。",
)
)
return 0
return 1
def _atomic_write(path: Path, text: str) -> None:
@@ -184,54 +181,6 @@ def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, .
return updated
def refresh_json(
path: Path,
keys: tuple[str, ...],
root: Path,
python: str,
names: tuple[str, ...],
template: dict | None = None,
install_missing: bool = False,
) -> bool | None:
if not path.is_file() and not install_missing:
return False
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("MCP configuration must be a JSON object")
parent = data
for key in keys:
child = parent.get(key)
if child is None and install_missing:
child = parent[key] = {}
if not isinstance(child, dict):
raise ValueError(f"Invalid MCP configuration section: {key}")
parent = child
config = parent.get("artemis")
if config is None and install_missing:
if template is None:
raise ValueError("A standard MCP template is required for installation")
updated = template
elif isinstance(config, dict) and _is_standard_local_artemis(config):
if template is None:
template = {
"command": python,
"args": ["-m", "mcp_server"],
"env": {"PYTHONPATH": str(root)},
}
updated = _updated_entry(config, template, root, names)
elif config is not None and not isinstance(config, dict):
raise ValueError("The Artemis MCP configuration is not an object")
else:
return None
if updated == config:
return False
parent["artemis"] = updated
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True
def _toml_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
@@ -363,152 +312,73 @@ def refresh_codex(
def refresh_mcp(root: Path) -> int:
"""Synchronize only Codex; never inspect or modify other clients."""
sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import (
ARTEMIS_MCP_TOOLS,
_get_config_snippet,
_get_vscode_user_dir,
)
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
home = Path.home()
vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
(
home / ".gemini/antigravity/mcp_config.json",
("mcpServers",),
"antigravity",
home / ".gemini",
),
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
(
home / ".codeium/windsurf/mcp_config.json",
("mcpServers",),
"windsurf",
home / ".codeium/windsurf",
),
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
(
appdata / "Claude/claude_desktop_config.json",
("mcpServers",),
"claude",
appdata / "Claude",
),
(vscode / "mcp.json", ("servers",), "vscode", vscode),
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
(
home / ".cline/data/settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
home / ".cline",
),
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
(
cline_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"cline",
cline_store,
),
(
roo_store / "settings/mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(
roo_store / "settings/cline_mcp_settings.json",
("mcpServers",),
"roo",
roo_store,
),
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
]
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
paths.append((codex_home / "config.toml", (), "codex", codex_home))
installed = updated_count = ready = skipped = errors = 0
for path, keys, client, marker in paths:
if not path.is_file() and not marker.is_dir():
continue
existed = path.is_file()
template = _get_config_snippet(client, sys.executable, str(root))
for key in keys:
template = template[key]
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
try:
changed = (
refresh_json(
path,
keys,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
if keys
else refresh_codex(
path,
root,
sys.executable,
ARTEMIS_MCP_TOOLS,
template=template,
install_missing=True,
)
)
if changed:
print(
launch_text(
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
)
)
if existed:
updated_count += 1
else:
installed += 1
elif changed is None:
print(
launch_text(
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
)
)
skipped += 1
else:
ready += 1
except (OSError, ValueError, TypeError, AttributeError):
print(
launch_text(
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
f"[错误] 无法安全同步 {path};原配置已保留。",
)
)
errors += 1
print(
launch_text(
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
)
)
print(
launch_text(
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
)
)
if not (installed or updated_count or ready or skipped or errors):
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
path = codex_home / "config.toml"
print(launch_text(f"[CHECK] Codex MCP configuration: {path}", f"[检查] Codex MCP 配置:{path}"))
if not path.is_file() and not codex_home.is_dir():
print(
launch_text(
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
"[SKIP] Codex configuration directory not found. Install with: uv run artemis mcp --install codex",
"[跳过] 未发现 Codex 配置目录。可执行 uv run artemis mcp --install codex 安装。",
)
)
return 1 if errors else 0
return 0
existed = path.is_file()
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
try:
changed = refresh_codex(
path, root, sys.executable, ARTEMIS_MCP_TOOLS, template=template, install_missing=True
)
except (OSError, ValueError, TypeError, AttributeError) as exc:
if isinstance(exc, OSError):
reason = launch_text(
"Cannot read/write the configuration; check file permissions.",
"无法读写配置,请检查文件权限。",
)
else:
reason = launch_text(
"Invalid TOML or Artemis configuration structure; check the file in Codex.",
"TOML 格式或 ARTEMIS 配置结构无效,请在 Codex 中检查配置文件。",
)
print(
launch_text(
f"[ERROR] Cannot safely synchronize {path}; original preserved. {reason}",
f"[错误] 无法安全同步 {path};原配置已保留。{reason}",
)
)
return 1
if changed:
print(
launch_text(
f"[{'UPDATED' if existed else 'INSTALLED'}] Codex MCP: {path}",
f"[{'已更新' if existed else '已安装'}] Codex MCP:{path}",
)
)
elif changed is None:
print(
launch_text(
"[SKIP] Custom Codex Artemis MCP command preserved; check it manually.",
"[跳过] Codex 使用自定义 ARTEMIS MCP 启动方式,已保留,请手动检查。",
)
)
return 0
else:
print(
launch_text(
"[OK] Codex Artemis MCP is already current.", "[完成] Codex ARTEMIS MCP 已是最新。"
)
)
print(
launch_text(
"[ACTION] Reload MCP or restart Codex to activate the updated tools.",
"[操作] 请在 Codex 中重新加载 MCP 或重启 Codex,使新版进程和工具签名生效。",
)
)
return 0
def frontend_fingerprint(root: Path) -> str:
@@ -533,12 +403,13 @@ def frontend_fingerprint(root: Path) -> str:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["source", "mcp", "frontend-check", "frontend-mark"])
parser.add_argument(
"action", choices=["source", "source-check", "mcp", "frontend-check", "frontend-mark"]
)
parser.add_argument("--root", type=Path, default=ROOT)
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
if args.action == "source":
sys.exit(update_source(args.root, args.port))
if args.action in {"source", "source-check"}:
sys.exit(update_source(args.root, check_only=args.action == "source-check"))
elif args.action == "mcp":
sys.exit(refresh_mcp(args.root))
else:
+96
View File
@@ -0,0 +1,96 @@
[CmdletBinding()]
param(
[string]$ProjectRoot,
[ValidateSet('Official', 'China')][string]$Mirror,
[switch]$Help,
[switch]$Staged
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
if ($Help) {
Write-Host '用法:update.bat 或 update-cn.bat [-Help]'
Write-Host '未运行过启动脚本也可升级;缺少的 Python 环境会自动准备。'
Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。'
Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。'
exit 0
}
if (-not $ProjectRoot) { $ProjectRoot = Split-Path $PSScriptRoot -Parent }
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
. (Join-Path $PSScriptRoot 'installation_lock.ps1')
if (-not $Staged) {
$lock = $null
$stage = $null
$stageCreated = $false
$code = 1
try {
$lock = Enter-ArtemisInstallationLock -ProjectRoot $ProjectRoot
# Every program used before the merge is frozen outside the checkout.
$stage = Join-Path ([IO.Path]::GetTempPath()) ('artemis-upgrade-' + [Guid]::NewGuid().ToString('N'))
$fullStage = [IO.Path]::GetFullPath($stage)
$rootPrefix = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\') + '\'
if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' }
New-Item -ItemType Directory -Path $stage | Out-Null
$stageCreated = $true
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py', 'windows_tools.ps1', 'download_config.ps1', 'bootstrap_python.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name)
}
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $stage 'upgrade.ps1'), '-Staged', '-ProjectRoot', $ProjectRoot)
if ($Mirror) { $forward += @('-Mirror', $Mirror) }
& powershell.exe @forward
$code = $LASTEXITCODE
} catch {
Write-Host "[升级失败] $_" -ForegroundColor Red
} finally {
# Only remove the unique, validated staging directory created above.
if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
Exit-ArtemisInstallationLock -Mutex $lock
}
if ($code -ne 0 -and $env:ARTEMIS_NO_PAUSE -ne '1' -and -not [Console]::IsInputRedirected) {
try { Read-Host '[升级失败] 请查看上方原因。按回车键关闭窗口' | Out-Null } catch {}
}
exit $code
}
try {
Set-Location -LiteralPath $PSScriptRoot
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' }
# Refuse dirty checkouts before terminating the user's running processes.
$localChanges = & git -C $ProjectRoot status --porcelain 2>$null
if ($LASTEXITCODE -ne 0) { throw '无法检查 Git 工作区。' }
if ($localChanges) { throw '发现本地修改;请先提交或备份,未终止任何任务。' }
Write-Host '======================================================'
Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow
. (Join-Path $PSScriptRoot 'upgrade_processes.ps1')
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
if (-not (Test-Path -LiteralPath $python)) {
Write-Host '[首次准备] 未找到项目 Python 环境,正在自动安装;随后拉取源码并同步新版依赖。' -ForegroundColor Cyan
. (Join-Path $PSScriptRoot 'windows_tools.ps1')
. (Join-Path $PSScriptRoot 'download_config.ps1')
. (Join-Path $PSScriptRoot 'bootstrap_python.ps1')
Initialize-ArtemisDownloads -ProjectRoot $ProjectRoot -Mode $Mirror
Update-EnvironmentPath
$python = Initialize-ArtemisPython -ProjectRoot $ProjectRoot -RuntimeOnly
}
& $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot
if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' }
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
# Load the new launcher only AFTER the frozen Git updater has exited.
$prepare = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ProjectRoot 'scripts\start.ps1'), '-PrepareOnly', '-SkipUpdate', '-NoOpen')
if ($Mirror) { $prepare += @('-Mirror', $Mirror) }
$previousChild = $env:ARTEMIS_UPGRADE_CHILD
$env:ARTEMIS_UPGRADE_CHILD = '1'
try { & powershell.exe @prepare; $prepareCode = $LASTEXITCODE }
finally { $env:ARTEMIS_UPGRADE_CHILD = $previousChild }
if ($prepareCode -ne 0) { throw '新版源码已保留,但环境同步失败;请处理上方错误后重新运行升级脚本。' }
Write-Host '[升级完成] 源码、依赖、网页及 Codex MCP 已检查并同步。' -ForegroundColor Green
Write-Host '[下一步] 运行启动脚本,并在 Codex 中重载 MCP 或重启 Codex。'
exit 0
} catch {
Write-Host "[升级失败] $_" -ForegroundColor Red
exit 1
}
+65
View File
@@ -0,0 +1,65 @@
# Process ownership is determined by checkout paths, never by a port or name alone.
function Get-ArtemisUpgradeProcesses {
param([string]$ProjectRoot, [object[]]$Processes, [int[]]$ProtectedIds = @())
$root = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\') + '\'
$venv = $root + '.venv\'
$selected = @{}
foreach ($process in $Processes) {
if ($ProtectedIds -contains [int]$process.ProcessId) { continue }
$executable = [string]$process.ExecutablePath
$command = [string]$process.CommandLine
$python = $process.Name -match '^(python(w)?|artemis(-admin)?)\.exe$'
$inVenv = $executable.StartsWith($venv, [StringComparison]::OrdinalIgnoreCase)
$explicitRoot = $command.Replace('/', '\').IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0
$entrypoint = $command -match '(?i)(?:-m\s+(?:artemis(?:\s|\.|$)|apps\.admin_console(?:\.|\s)|mcp_server(?:\.|\s|$))|[\\/]artemis(?:\.exe|[\\/]__main__\.py)|[\\/]mcp_server[\\/])'
if (($python -and $inVenv) -or ($explicitRoot -and $entrypoint -and ($python -or $process.Name -eq 'uv.exe'))) {
$selected[[int]$process.ProcessId] = $process
}
}
do {
$added = $false
foreach ($process in $Processes) {
$id = [int]$process.ProcessId
if ($selected.ContainsKey($id) -or $ProtectedIds -contains $id) { continue }
# Shared device services and desktop applications can outlive a task.
if ($process.Name -match '^(adb|codex|chrome|msedge|firefox|emulator|MuMu.*|Nemu.*|Nox.*|MEmu.*|LdVBox.*|HD-Player)\.exe$') { continue }
if ($selected.ContainsKey([int]$process.ParentProcessId)) {
$selected[$id] = $process
$added = $true
}
}
} while ($added)
return @($selected.Values)
}
function Stop-ArtemisUpgradeProcesses {
param([string]$ProjectRoot)
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
$protected = @($PID)
$parentId = $PID
do {
$parent = $snapshot | Where-Object { [int]$_.ProcessId -eq $parentId } | Select-Object -First 1
$parentId = if ($parent) { [int]$parent.ParentProcessId } else { 0 }
if ($parentId -and $protected -notcontains $parentId) { $protected += $parentId } else { break }
} while ($true)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
$targets = @(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected)
if (-not $targets.Count) { return }
foreach ($target in $targets) {
$id = [int]$target.ProcessId
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop
if (-not $current -or $current.CreationDate -ne $target.CreationDate) { continue }
Write-Host "[STOP] ARTEMIS PID $id ($($target.Name))"
try { Stop-Process -Id $id -Force -ErrorAction Stop }
catch {
if (Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop) { throw }
}
}
Start-Sleep -Milliseconds 200
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
} while ([DateTime]::UtcNow -lt $deadline)
if (@(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected).Count) {
throw 'ARTEMIS processes are still running. Source/dependency updates were not started.'
}
}
+58
View File
@@ -0,0 +1,58 @@
# Process-local Windows tool discovery shared by startup and the frozen upgrader.
# Keep UTF-8 BOM for Windows PowerShell 5.1.
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
+7 -1
View File
@@ -5,6 +5,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
# ARTEMIS 自动化
当前仅提供 Codex MCP 配置安装与更新:`uv run artemis mcp --install codex`;模板使用 `uv run artemis mcp --generate-config codex` 查看。启动日志会打印实际 Codex 配置路径,更新后重载 MCP。
Windows 启动脚本只提示源码更新。升级使用 `update.bat`(国内镜像 `update-cn.bat`),会终止该项目的 ARTEMIS 服务、MCP 和运行任务;仅在用户授权升级或终止任务后执行,先告知任务将中断。升级会同步依赖、网页及 Codex MCP,保留配置和历史;完成后启动 ARTEMIS 并重载 Codex MCP。不要通过启动脚本拉取源码或强行覆盖本地修改。
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
## 从需求到执行
@@ -18,7 +22,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
## 结果、证据与交付
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。Flash 和 Pro 默认根据任务提示词、用户指导、执行结论和实际证据进行只读终检,不需要另填预期结果。读取 `test_result.status`(`passed` 通过、`failed` 不通过、`inconclusive` 未验证、`blocked` 阻塞)、`reason` 与 `evidence`,结合验证摘要和 `mobile_inspect_trace` 的步骤/截图交付结果。执行失败不能直接认定产品缺陷;已确认的断言失败不得被后续成功覆盖。终检关闭、出错或证据不足时不得宣称通过。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
@@ -26,6 +30,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
## 配置和恢复边界
- 本机常见模拟器在设备刷新、启动及重启 ADB 后自动发现并连接,不必猜固定端口。USB 与 Android SDK 模拟器由 ADB 原生发现。首次连接其他 TCP 设备/无线手机时,在网页“无线 ADB”填写实际地址和端口,成功后程序保存地址并自动重连;无线调试的配对和授权仍由用户完成,端口变化后需重新连接。远程/转发 ADB 不执行本机发现。用 `mobile_workspace` 或 `mobile_diagnose` 检查自动连接结果;多设备时仍先确认用户要测试哪台。默认开关及扫描预算可由 `mobile_config(section="capabilities")` 的 `adb_auto_connect`、`adb_discovery_interval_seconds`(10)、`adb_discovery_timeout_seconds`(5)配置,修改全局默认需要用户授权。
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
@@ -17,6 +17,7 @@
"formats": ["docx", "xlsx"],
"stop_on_failure": false,
"timeout_seconds": 1800,
"repeat_count": 1,
"cases": [
{
"case_id": "DEMO-001",
@@ -42,13 +43,15 @@
`create` 只保存已校验的草稿。检查返回批次编号和范围,用户已经授权执行时接着 `mobile_batch(action="start", batch_id=...)`;用户只要求准备方案时留在草稿,不启动。保存 `batch_id`,用 `get` 跟踪各项状态。
用户要求压测时,在 `batch.repeat_count` 设置正整数循环次数(默认 1),只向 `batch.cases` 提交用户选择的用例。每轮按原顺序执行整批,再进入下一轮;各次执行独立保存编号和报告,`get` 中的 job 包含 `iteration`、`repeat_count`。总执行数为用例数乘以轮数,默认上限 10000,由服务的 `ARTEMIS_BATCH_MAX_EXECUTIONS` 控制。不要重复调用 `start` 来实现循环,也不要将循环次数写入每项 `task_config`。失败即停止会跳过后续全部轮次;复制失败项时各失败执行只重跑一次,新的批次循环次数为 1。
创建请求超时可能已经落库。先 `lookup(request_id=...)`;需要重发时保留**相同 request_id 和相同内容**。服务会返回原批次,内容不同会拒绝。不要通过换编号“修复超时”,否则会形成重复批次。
## Excel 导入
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
`preview` 返回 `valid/tasks/errors`,不会执行。网页默认全选,可取消用例;AI 也应按用户指定范围筛选 `tasks`。所有错误处理完后,将所选项的预览字段 `row` 去掉,作为 `batch.cases` 提交,并说明实际选中数量。不要跳过错误行却声称全部导入成功。
## 状态与异常
+4 -20
View File
@@ -2,32 +2,16 @@
chcp 65001 >nul 2>&1
setlocal DisableDelayedExpansion
set "ARTEMIS_LAUNCH_LANG=zh-CN"
title ARTEMIS 国内镜像启动
title ARTEMIS
if /i "%~1"=="-Help" goto help
if /i "%~1"=="--help" goto help
echo ======================================================
echo ARTEMIS 中文启动 - 使用国内镜像
echo ======================================================
echo 正在检查已有环境、项目更新及依赖;首次启动可能需要下载组件。
echo 镜像地址和下载超时可在 config\downloads.json 中调整。
echo.
call "%~dp0start.bat" -Mirror China %*
set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_CN_EXIT_CODE%"=="0" echo 启动未完成;请查看上方错误信息,并检查网络或镜像设置。
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
:help
echo 用法:start-cn.bat [选项]
echo.
echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
echo -Help 显示本说明
echo.
echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
endlocal & exit /b 0
call "%~dp0start.bat" -Mirror China -Help
set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
+3 -8
View File
@@ -15,16 +15,11 @@ rem limitations under the License.
chcp 65001 >nul 2>&1
setlocal DisableDelayedExpansion
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (title ARTEMIS 手机自动化测试) else (title Artemis Mobile Agent UI)
title ARTEMIS
pushd "%~dp0" || exit /b 1
echo ======================================================
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试) else (echo Artemis Autonomous Mobile Agent UI)
echo ======================================================
echo.
rem First run installs uv/Python/.venv; later runs check Git, dependencies and existing MCP clients.
rem First run installs uv/Python/.venv; later runs check Git, dependencies and Codex MCP.
rem Use -SkipUpdate to skip the Git source check and run local code.
rem Use start.bat -SetupOnly to prepare Python without launching the UI.
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
@@ -33,7 +28,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_EXIT_CODE%"=="0" (
echo.
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 启动失败,请查看上方详细信息。) else (echo An error occurred while starting Artemis.)
echo ARTEMIS startup failed. Please see the details above.
if not defined ARTEMIS_NO_PAUSE pause
)
popd
+8
View File
@@ -25,9 +25,17 @@ NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
ARTEMIS_VERSION="$(awk '
/^\[project\]/ { in_project = 1; next }
/^\[/ { in_project = 0 }
in_project && /^version[[:space:]]*=/ { split($0, fields, "\""); print fields[2]; exit }
' "${SCRIPT_DIR}/pyproject.toml" 2>/dev/null || true)"
ARTEMIS_VERSION="${ARTEMIS_VERSION:-unknown}"
echo -e "${BOLD}${CYAN}======================================================${NC}"
echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
echo -e "${BOLD}${CYAN}======================================================${NC}"
echo -e "${CYAN} Version: v${ARTEMIS_VERSION}${NC}"
echo ""
# 1. Normalize PATH with standard toolchain and package manager paths
+14
View File
@@ -26,6 +26,20 @@ import pytest
from artemis.drivers.mock.mock_driver import MockDeviceDriver
@pytest.fixture(autouse=True)
def no_host_adb_discovery(monkeypatch, request):
"""Deterministic tests must never connect to host emulators or remembered phones."""
from artemis.runtime.adb_discovery import adb_device_discovery
if any(
request.node.get_closest_marker(name)
for name in ("android", "integration", "e2e", "manual")
):
return
monkeypatch.setattr(adb_device_discovery, "refresh", lambda *args, **kwargs: [])
monkeypatch.setattr(adb_device_discovery, "remember", lambda *args: True)
@pytest.fixture
def mock_driver():
"""Provide an isolated mock mobile driver."""
+276 -2
View File
@@ -185,6 +185,107 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_repetitions_are_ordered_independent_and_idempotent(client, isolated):
request = payload(repeat_count=3, request_id="repeat-test")
created = client.post("/api/automation/batches", json=request)
assert created.status_code == 200
batch = created.json()
assert (batch["case_count"], batch["repeat_count"], batch["total_executions"]) == (2, 3, 6)
jobs = isolated.jobs(batch["id"])
assert len({j["session_id"] for j in jobs}) == 6
assert [j["position"] for j in jobs] == list(range(1, 7))
assert [(j["iteration"], j["case_position"]) for j in jobs] == [
(1, 1),
(1, 2),
(2, 1),
(2, 2),
(3, 1),
(3, 2),
]
assert [j["case"]["goal"] for j in jobs] == ["观察设置", "观察首页"] * 3
assert all(j["status"] == "pending" for j in jobs)
assert client.post("/api/automation/batches", json=request).json()["id"] == batch["id"]
assert len(isolated.jobs(batch["id"])) == 6
assert (
client.post("/api/automation/batches", json={**request, "repeat_count": 2}).status_code
== 409
)
# Each selected failure is copied once, including failures from different rounds.
for job in (jobs[0], jobs[4]):
isolated.save_job({**job, "status": "failed"})
retry = service.batch_action(batch["id"], "retry_failed")
assert retry["repeat_count"] == 1
assert len(isolated.jobs(retry["id"])) == 2
report = reports.session_report(jobs[4]["session_id"])
assert "第 3 / 3 轮" in report["name"]
archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z:
assert len([n for n in z.namelist() if n.endswith(".xlsx")]) == 7
assert len(set(z.namelist())) == len(z.namelist())
@pytest.mark.parametrize("count", [0, -1, 1.5, True, "3", None])
def test_batch_rejects_invalid_repetitions(client, isolated, count):
assert (
client.post("/api/automation/batches", json=payload(repeat_count=count)).status_code == 422
)
assert isolated.records("batch") == []
def test_batch_limit_is_checked_before_creating_records(client, isolated, monkeypatch):
monkeypatch.setattr(service.settings, "ARTEMIS_BATCH_MAX_EXECUTIONS", 5)
assert client.get("/api/automation/preferences").json()["batch_max_executions"] == 5
response = client.post("/api/automation/batches", json=payload(repeat_count=3))
assert response.status_code == 409
assert "5" in response.json()["detail"]
assert not isolated.records("batch")
assert not isolated.jobs()
@pytest.mark.asyncio
async def test_loop_dispatch_continues_after_failure_and_resume_without_repeating(
isolated, monkeypatch
):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(repeat_count=2))
service.batch_action(batch["id"], "resume")
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
for index in range(4):
await service.tick_batches()
await service.tick_batches() # Polling must not enqueue the same execution twice.
assert enqueue.await_count == index + 1
job = isolated.jobs(batch["id"])[index]
assert enqueue.call_args.kwargs["session_id"] == job["session_id"]
assert f"第 {job['iteration']} / 2 轮" in enqueue.call_args.kwargs["display_name"]
isolated.save_job({**job, "status": "failed" if index == 0 else "completed"})
if index == 1:
service.batch_action(batch["id"], "pause")
await service.tick_batches()
assert enqueue.await_count == 2
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "completed"
assert enqueue.await_count == 4
@pytest.mark.asyncio
async def test_repeat_fail_fast_skips_future_rounds(isolated):
batch = service.create_batch(payload(repeat_count=3, stop_on_failure=True))
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "failed"})
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed"] + ["skipped"] * 5
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
@@ -679,7 +780,9 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
)
report = reports.session_report(job["session_id"])
assert "连续 3 次" in report["error"]
assert report["verdict"] == "未验证(任务异常终止)"
assert report["verdict"] == "测试阻塞(AI确认)"
assert report["test_result"]["status"] == "blocked"
assert "连续 3 次" in report["test_result"]["reason"]
assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
)
@@ -695,7 +798,10 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
)
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
assert "连续 3 次" in wb["任务结果"]["B15"].value
assert any(
row[0].value == "异常原因" and "连续 3 次" in row[1].value
for row in wb["任务结果"].iter_rows()
)
assert wb["结论"]["B2"].value == "结果"
assert wb["结果表1"]["A2"].value == "系统"
assert wb["执行证据"]["A1"].font.name == "微软雅黑"
@@ -709,6 +815,174 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
]
def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.save_job(
{**job, "status": "completed", "started_at": time.time() - 50, "ended_at": time.time()}
)
result = {
"status": "failed",
"reason": "预期出现成功提示,实际显示错误",
"evidence": ["步骤 3 点击后截图显示错误提示"],
}
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "run_outcome.json").write_text(
json.dumps(
{"task_status": "completed", "tests": {}, "test_result": result}, ensure_ascii=False
),
encoding="utf-8",
)
history = client.get("/api/automation/history").json()["items"][0]
batch_job = client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0]
report = client.get(f"/api/automation/reports/{job['session_id']}").json()
for item in (history, batch_job, report):
assert item["status"] == "completed"
assert item["test_result"] == {**result, "source": "ai"}
assert report["verdict"] == "测试不通过(AI确认)"
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
doc_text = "\n".join(
cell.text for table in doc.tables for row in table.rows for cell in row.cells
)
doc_text += "\n" + "\n".join(paragraph.text for paragraph in doc.paragraphs)
assert result["reason"] in doc_text
assert result["evidence"][0] in doc_text
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
assert "测试不通过(AI确认)" in values
assert result["reason"] in values
def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_cache(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
from apps.admin_console.routers import sessions as sessions_api
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
jobs = isolated.jobs(batch["id"])
for job in jobs:
isolated.save_job({**job, "status": "completed", "ended_at": time.time()})
session_id = jobs[0]["session_id"]
ai = {"status": "failed", "reason": "AI识别到预期不匹配", "evidence": ["步骤 3 截图"]}
directory = reports.TRACES_PATH / session_id
directory.mkdir(parents=True)
outcome_file = directory / "run_outcome.json"
original = json.dumps({"task_status": "completed", "test_result": ai}, ensure_ascii=False)
outcome_file.write_text(original, encoding="utf-8")
archive = reports.export_batch(batch["id"])
isolated.put("batch", batch["id"], {**batch, "report_ready": True})
response = client.put(
f"/api/automation/reports/{session_id}/review",
json={"status": "passed", "reason": "已人工核对,符合预期"},
)
assert response.status_code == 200
assert response.json()["status"] == "completed"
assert response.json()["verdict"] == "测试通过(人工确认)"
assert outcome_file.read_text(encoding="utf-8") == original
assert not archive.exists()
assert not isolated.get("batch", batch["id"])["report_ready"]
assert len(isolated.get("case_review", session_id)["reviews"]) == 1
for result in (
response.json(),
client.get("/api/automation/history", params={"q": session_id}).json()["items"][0],
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
):
assert result["test_result"]["source"] == "human"
assert result["test_result"]["status"] == "passed"
assert result["test_result"]["ai_result"] == {**ai, "source": "ai"}
assert result["test_result"]["reviewed_at"] > 0
monkeypatch.setattr(sessions_api, "store", isolated)
monkeypatch.setattr(sessions_api, "TRACES_PATH", reports.TRACES_PATH)
monkeypatch.setattr(
sessions_api.session_repo,
"get_session_by_id",
lambda sid: {"session_id": sid, "status": "completed"},
)
client.app.include_router(sessions_api.router)
detail = client.get(f"/api/sessions/{session_id}").json()
assert detail["test_result"]["source"] == "human"
assert detail["batch_id"] == batch["id"]
downloaded = client.get(f"/api/automation/batches/{batch['id']}/archive")
assert downloaded.status_code == 200
with zipfile.ZipFile(BytesIO(downloaded.content)) as bundle:
wb = load_workbook(BytesIO(bundle.read("批次结果.xlsx")))
assert wb.active["D2"].value == "测试通过(人工确认)"
assert "AI确认" in wb.active["D3"].value
doc = Document(BytesIO(bundle.read("批次汇总.docx")))
assert doc.tables[0].rows[1].cells[3].text == "测试通过(人工确认)"
changed = client.put(f"/api/automation/reports/{session_id}/review", json={"status": "failed"})
assert changed.status_code == 200
assert changed.json()["verdict"] == "测试不通过(人工确认)"
assert len(isolated.get("case_review", session_id)["reviews"]) == 2
assert outcome_file.read_text(encoding="utf-8") == original
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
url = f"/api/automation/reports/{job['session_id']}/review"
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
assert client.put(url, json={"status": "inconclusive"}).status_code == 422
assert client.put(url, json={"status": "passed", "source": "human"}).status_code == 422
assert client.put(url, json={"status": "passed", "reason": "字" * 2001}).status_code == 422
assert (
client.put("/api/automation/reports/missing/review", json={"status": "passed"}).status_code
== 404
)
isolated.save_job({**job, "status": "completed"})
monkeypatch.setattr(api.archive_service, "status", lambda *args: {"status": "preparing"})
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
def test_batch_filter_searches_full_history_and_reviews_are_removed_with_batch(client, isolated):
first, second = [service.create_batch(payload(name=name)) for name in ("第一批", "第二批")]
for batch in (first, second):
for job in isolated.jobs(batch["id"]):
isolated.save_job({**job, "status": "completed"})
with isolated.connection() as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
("single-case", "单独检查", time.time(), "completed"),
)
conn.commit()
filtered = client.get(
"/api/automation/history",
params={"batch_id": first["id"], "q": "用例", "size": 1, "page": 2},
).json()
assert filtered["total"] == 2
assert len(filtered["items"]) == 1
assert filtered["items"][0]["batch_id"] == first["id"]
assert (
client.get("/api/automation/history", params={"batch_id": "__batch__"}).json()["total"] == 4
)
singles = client.get("/api/automation/history", params={"batch_id": "__single__"}).json()
assert [item["session_id"] for item in singles["items"]] == ["single-case"]
assert client.get("/api/automation/history", params={"batch_id": "bad/path"}).status_code == 400
reviewed_id = isolated.jobs(first["id"])[0]["session_id"]
isolated.save_case_review(reviewed_id, "passed", "人工核对")
isolated.begin_delete_batch(first["id"])
isolated.delete_batch(first["id"])
assert isolated.get("case_review", reviewed_id) is None
assert len(isolated.jobs(second["id"])) == 2
isolated.save_case_review("single-case", "failed", "不符合预期")
isolated.delete_history("single-case")
assert isolated.get("case_review", "single-case") is None
def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
@@ -323,7 +323,9 @@ async def test_queue_worker_cmd_construction():
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"]
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == (
"tcp:localhost:5037" if endpoint["is_local_default"] else endpoint["socket"]
)
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
if sys.platform == "win32":
assert executed_kwargs[0]["creationflags"] == (
+118
View File
@@ -0,0 +1,118 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from artemis.agents.checker import flash
from artemis.agents.checker.checker import CheckReport
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.runtime.case_result import CaseTestResult
def context(tmp_path, **setup):
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = tmp_path
return ctx
@pytest.mark.asyncio
@pytest.mark.parametrize("verdict", ["passed", "failed", "inconclusive"])
async def test_flash_reuses_checker_with_original_prompt_and_final_report(
tmp_path, monkeypatch, verdict
):
result = CaseTestResult(
status=verdict, reason="提示词预期与实际对比", evidence=["步骤 3 截图显示实际状态"]
)
check = AsyncMock(return_value=CheckReport(test_result=result))
monkeypatch.setattr(flash, "run_final_check", check)
ctx = context(tmp_path)
execution = {"status": "completed", "explanation": "操作已执行"}
data = await flash.settle_flash_outcome(ctx, "打开设置,预期看到应用入口", execution)
assert check.call_count == 1
assert check.call_args.kwargs["goal"] == "打开设置,预期看到应用入口"
assert check.call_args.kwargs["execution_report"] == execution
assert check.call_args.kwargs["check_items"] == []
assert data["task_status"] == "completed"
assert data["test_result"]["status"] == verdict
assert (
json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))["test_result"]
== data["test_result"]
)
assert ctx.run_outcome == data
@pytest.mark.asyncio
async def test_acceptance_timeout_finishes_with_unknown_verdict(tmp_path, monkeypatch):
async def hanging_check(*args, **kwargs):
await asyncio.Event().wait()
monkeypatch.setattr(flash, "run_final_check", hanging_check)
data = await flash.settle_flash_outcome(
context(tmp_path, checkpoint_timeout=0.01), "任务", {"status": "completed"}
)
assert data["task_status"] == "completed"
assert data["test_result"]["status"] == "inconclusive"
assert "0.01 秒" in data["test_result"]["reason"]
@pytest.mark.asyncio
@pytest.mark.parametrize("setup", [{"disable_checker": True}, {"disable_final_check": True}])
async def test_disabled_acceptance_does_not_call_a_model_or_claim_pass(
tmp_path, monkeypatch, setup
):
check = AsyncMock()
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path, **setup), "任务", {"status": "completed"}
)
check.assert_not_called()
assert data["test_result"]["status"] == "inconclusive"
@pytest.mark.asyncio
async def test_execution_failure_is_blocked_without_another_model_retry(tmp_path, monkeypatch):
check = AsyncMock()
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path), "任务", {"status": "failed", "explanation": "设备观测失败"}
)
check.assert_not_called()
assert data["test_result"]["status"] == "blocked"
assert data["test_result"]["reason"] == "设备观测失败"
@pytest.mark.asyncio
async def test_reported_expectation_mismatch_is_checked_even_when_executor_reports_failed(
tmp_path, monkeypatch
):
check = AsyncMock(
return_value=CheckReport(
test_result=CaseTestResult(
status="failed",
reason="预期英文 Search settings,实际中文 搜索设置。",
evidence=["设置首页截图和 UI 元素 1 显示 搜索设置"],
)
)
)
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path),
"预期搜索框为英文 Search settings",
{"status": "failed", "explanation": "已检查,实际为中文"},
final_report_received=True,
)
check.assert_awaited_once()
assert data["test_result"]["status"] == "failed"
assert "Search settings" in data["test_result"]["reason"]
@pytest.mark.asyncio
async def test_cancellation_of_acceptance_propagates(tmp_path, monkeypatch):
check = AsyncMock(side_effect=asyncio.CancelledError)
monkeypatch.setattr(flash, "run_final_check", check)
with pytest.raises(asyncio.CancelledError):
await flash.settle_flash_outcome(context(tmp_path), "任务", {"status": "completed"})
assert not (tmp_path / "run_outcome.json").exists()
@@ -0,0 +1,250 @@
"""UIAutomator startup recovery, using the real library with isolated device IO."""
from dataclasses import dataclass, field
import json
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from uiautomator2 import base, core
from artemis.clients import ui_automator_client
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
@dataclass
class Clock:
now: float = 0.0
def time(self):
return self.now
def sleep(self, seconds):
self.now += seconds
class DeviceSocket:
def __init__(self, mode):
self.mode = mode
self.closed = threading.Event()
self.started = threading.Event()
def recv(self, _size):
if not self.started.is_set():
self.started.set()
output = b"[server] INFO: [UiAutomator2Server] Starting Server\n"
if self.mode == "conflict":
output += (
b"java.lang.IllegalStateException: UiAutomationService already registered!"
)
return output
self.closed.wait()
return b""
class DeviceConnection:
def __init__(self, mode):
self.conn = DeviceSocket(mode)
self.close_calls = 0
self.close_error = None
def close(self):
self.close_calls += 1
if self.close_error:
raise self.close_error
self.conn.closed.set()
@dataclass
class DeviceServer:
modes: list[str] = field(default_factory=lambda: ["not_ready"])
connections: list[DeviceConnection] = field(default_factory=list)
previous_closed_at_launch: list[bool] = field(default_factory=list)
clock: Clock = field(default_factory=Clock)
cleanup_callbacks: MagicMock = field(default_factory=MagicMock)
close_error: Exception | None = None
probe_error: BaseException | None = None
def shell(self, command, stream=False):
assert stream is True
assert "com.wetest.uia2.Main" in command
self.previous_closed_at_launch.append(all(c.conn.closed.is_set() for c in self.connections))
index = min(len(self.connections), len(self.modes) - 1)
connection = DeviceConnection(self.modes[index])
connection.close_error = self.close_error
self.connections.append(connection)
return connection
def http_request(self, _device, _port, method, path, *args, **kwargs):
if self.connections:
latest = self.connections[-1]
assert latest.conn.started.wait(timeout=2)
if self.probe_error is not None:
raise self.probe_error
if latest.conn.mode == "ready" and not latest.conn.closed.is_set():
if method == "POST":
return core.HTTPResponse(
json.dumps({"result": {"displayWidth": 1080}}).encode()
)
return core.HTTPResponse(b"pong")
raise core.HTTPError("simulated device server is not ready")
@pytest.fixture
def server(monkeypatch):
simulated = DeviceServer()
device = SimpleNamespace(serial="isolated-startup-device", shell=simulated.shell)
monkeypatch.setattr(base._BaseClient, "_wait_for_device", lambda _self: device)
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", lambda _self: None)
monkeypatch.setattr(core, "_http_request", simulated.http_request)
monkeypatch.setattr(core, "time", simulated.clock)
monkeypatch.setattr(core, "atexit", SimpleNamespace(register=simulated.cleanup_callbacks))
monkeypatch.setattr(ui_automator_client, "time", simulated.clock)
monkeypatch.setattr(ui_automator_client, "_ensure_maestro_not_installed", lambda _s: None)
monkeypatch.setattr(ui_automator_client, "ensure_device_awake", lambda _s: "simulated")
yield simulated
# Close fake sockets even when a regression leaves a real library reader alive.
for connection in simulated.connections:
connection.conn.closed.set()
def test_each_failed_launch_is_closed_before_retry_and_final_failure(server):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
assert server.clock.now == 93.0
assert server.cleanup_callbacks.call_count == 0
assert client._device is None
assert client._awake_strategy is None
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert "isolated-startup-device" in str(caught.value)
assert "3" in str(caught.value)
def test_retry_can_connect_after_failed_launch_was_stopped(server):
server.modes = ["not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
client.connect()
client.connect() # Cached device info also uses the real library RPC path.
assert len(server.connections) == 2
assert server.previous_closed_at_launch == [True, True]
assert server.connections[0].close_calls == 1
assert server.connections[1].close_calls == 0
assert server.cleanup_callbacks.call_count == 1
client.disconnect(stop_server=True)
assert server.connections[1].close_calls == 1
def test_later_connection_can_succeed_after_all_previous_attempts_failed(server):
server.modes = ["not_ready", "not_ready", "not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.connect()
assert len(server.connections) == 4
assert server.previous_closed_at_launch == [True, True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1, 0]
client.disconnect(stop_server=True)
assert server.connections[-1].close_calls == 1
def test_auto_fallback_cleans_failed_launches_before_next_attempt(server):
helper = MagicMock()
helper.connect.side_effect = HelperUnavailable("simulated helper unavailable")
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
uiautomator_factory=lambda: ui_automator_client.UIAutomatorClient(
"isolated-startup-device"
),
state_probe=lambda _serial: "device",
)
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.disconnect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
helper.disconnect.assert_called_once()
def test_service_conflict_closes_own_streams_and_preserves_cause(server):
server.modes = ["conflict"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="already registered") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.AccessibilityServiceAlreadyRegisteredError)
assert len(server.connections) == 3
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_cleanup_error_does_not_replace_original_startup_failure(server):
server.close_error = OSError("simulated cleanup connection error")
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_interrupt_during_startup_also_closes_own_stream(server):
server.probe_error = KeyboardInterrupt()
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(KeyboardInterrupt):
client.connect()
assert len(server.connections) == 1
assert server.connections[0].close_calls == 1
def test_failure_before_launch_does_not_stop_another_device_service(server, monkeypatch):
failure = OSError("simulated jar transfer failure")
def fail_before_launch(_self):
raise failure
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", fail_before_launch)
stop = MagicMock()
monkeypatch.setattr(core.BasicUiautomatorServer, "stop_uiautomator", stop)
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="jar transfer failure") as caught:
client.connect()
assert caught.value.__cause__ is failure
assert server.connections == []
stop.assert_not_called()
def test_default_helper_success_does_not_start_uiautomator(server):
factory = MagicMock()
client = FallbackScreenClient(
"isolated-startup-device",
helper=MagicMock(),
uiautomator_factory=factory,
state_probe=lambda _serial: "device",
)
client.connect()
client.disconnect()
factory.assert_not_called()
assert server.connections == []
+90 -1
View File
@@ -70,6 +70,34 @@ def test_adb_session_builds_explicit_endpoint_command_and_environment():
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"])
def test_local_endpoint_allows_adb_auto_start_without_changing_task_identity(host):
endpoint = AdbServerEndpoint(host, 5037)
session = AdbSession(endpoint, adb_path="adb")
inherited = {
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
"ANDROID_ADB_SERVER_PORT": "5040",
}
assert session.command(["devices"]) == ["adb", "-H", "localhost", "-P", "5037", "devices"]
environment = session.environment(inherited)
assert environment["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
assert environment["ADB_HOST"] == host
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
assert inherited["ADB_SERVER_SOCKET"] == "tcp:remote.example:5040"
@pytest.mark.parametrize(
("host", "port"), [("127.0.0.1", 5038), ("::1", 5038), ("server.example", 5037)]
)
def test_forwarded_endpoint_never_uses_local_daemon_auto_start(host, port):
endpoint = AdbServerEndpoint(host, port)
session = AdbSession(endpoint, adb_path="adb")
assert session.command(["devices"])[2] == host
assert session.environment({})["ADB_SERVER_SOCKET"] == endpoint.socket
@pytest.mark.asyncio
async def test_connect_activates_forwarded_server_after_success(monkeypatch):
captured: dict[str, object] = {}
@@ -253,7 +281,7 @@ async def test_use_local_server_restores_standard_endpoint(monkeypatch):
assert result["success"] is True
assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict()
assert settings.ADB_PORT == 5037
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5037"
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
@pytest.mark.asyncio
@@ -282,6 +310,67 @@ async def test_remote_endpoint_blocks_local_wireless_connection(monkeypatch):
assert "Switch to local ADB" in result["message"]
@pytest.mark.asyncio
async def test_restart_reconnects_tcp_devices_after_server_started(monkeypatch):
from artemis.runtime.adb_discovery import adb_device_discovery
monkeypatch.setattr(
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
)
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.inspect_adb_keys",
lambda: type("Keys", (), {"is_corrupted": False})(),
)
events = []
def run(args, **kwargs):
events.append(args[-1])
return subprocess.CompletedProcess(args, 0, "", "")
def reconnect(*args, **kwargs):
events.append("reconnect")
assert kwargs["force"] is True
return [{"target": "127.0.0.1:16384", "success": True}]
monkeypatch.setattr(subprocess, "run", run)
monkeypatch.setattr(adb_device_discovery, "refresh", reconnect)
result = await ReadinessEngine().restart_adb_server()
assert events == ["kill-server", "start-server", "reconnect"]
assert result["automatic_connections"][0]["success"] is True
@pytest.mark.asyncio
async def test_manual_tcp_connection_remembers_success_only(monkeypatch):
from unittest.mock import Mock
from artemis.runtime.adb_discovery import adb_device_discovery
monkeypatch.setattr(
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
)
remember = Mock(return_value=True)
monkeypatch.setattr(adb_device_discovery, "remember", remember)
monkeypatch.setattr(
subprocess,
"run",
lambda args, **kwargs: subprocess.CompletedProcess(
args, 0, "connected to 192.0.2.10:5555", ""
),
)
result = await ReadinessEngine().connect_wireless_adb("192.0.2.10", 5555)
assert result["remembered"] is True
remember.assert_called_once_with("192.0.2.10:5555")
remember.reset_mock()
monkeypatch.setattr(
subprocess,
"run",
lambda args, **kwargs: subprocess.CompletedProcess(args, 1, "", "connection refused"),
)
result = await ReadinessEngine().connect_wireless_adb("192.0.2.11", 5555)
assert result["success"] is False
assert result["remembered"] is False
remember.assert_not_called()
@pytest.mark.asyncio
async def test_remote_endpoint_blocks_local_emulator_launch(monkeypatch):
monkeypatch.setattr(
+6 -1
View File
@@ -53,9 +53,14 @@ async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
with pytest.raises(ValueError):
await tools.mobile_batch("create", batch={"name": "x"})
request.assert_not_awaited()
await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"})
selected = [{"name": "选中用例", "goal": "观察设置"}]
await tools.mobile_batch(
"create", request_id="stable-id", batch={"name": "x", "cases": selected, "repeat_count": 3}
)
assert request.await_count == 1
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
assert request.call_args.kwargs["json"]["cases"] == selected
assert request.call_args.kwargs["json"]["repeat_count"] == 3
assert request.call_args.args[1] == "/api/automation/batches"
+290
View File
@@ -0,0 +1,290 @@
"""TCP discovery tests never touch real processes, ports, devices or user state."""
import importlib
import json
import struct
import subprocess
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from artemis.config import settings
from artemis.runtime.adb_endpoint import AdbEndpoint
module = importlib.import_module("artemis.runtime.adb_discovery")
@pytest.fixture
def discovery(tmp_path, monkeypatch):
instance = module.AdbDeviceDiscovery()
monkeypatch.setattr(instance, "_directory", lambda: tmp_path)
monkeypatch.setattr(module, "emulator_listeners", lambda: [])
monkeypatch.setattr(
module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", 5037)
)
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True)
monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS", 5)
monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS", 10)
return instance
def fake_adb(monkeypatch, *, ready="", failures=()):
calls = []
connected = []
def run(self, arguments, **kwargs):
calls.append(arguments)
assert 0 < kwargs["timeout"] <= 5
if arguments == ["devices"]:
return subprocess.CompletedProcess(
arguments,
0,
"List of devices attached\n"
+ ready
+ "".join(f"{target} device\n" for target in connected),
"",
)
target = arguments[1]
if target in failures:
raise subprocess.TimeoutExpired(arguments, kwargs["timeout"])
connected.append(target)
return subprocess.CompletedProcess(arguments, 0, f"connected to {target}", "")
monkeypatch.setattr(module.AdbSession, "run", run)
return calls
@pytest.mark.parametrize(
"name",
[
"MuMuNxDevice.exe",
"NemuHeadless.exe",
"LdVBoxHeadless.exe",
"NoxVMHandle.exe",
"MEmuHeadless.exe",
"HD-Player.exe",
],
)
def test_supported_emulator_listener_ownership(monkeypatch, name):
monkeypatch.setattr(
module.psutil,
"process_iter",
lambda attrs: [
SimpleNamespace(info={"pid": 4, "name": name, "exe": "vendor.exe"}),
SimpleNamespace(info={"pid": 5, "name": "other.exe"}),
],
)
monkeypatch.setattr(
module.psutil,
"net_connections",
lambda kind: [
SimpleNamespace(pid=pid, status="LISTEN", laddr=SimpleNamespace(ip=ip, port=port))
for pid, ip, port in [
(4, "0.0.0.0", 16384),
(4, "127.0.0.1", 7555),
(4, "192.0.2.1", 5555),
(5, "0.0.0.0", 16385),
]
],
)
assert module.emulator_listeners() == [["127.0.0.1:16384", "127.0.0.1:7555"]]
def test_generic_virtualbox_is_not_an_android_emulator(monkeypatch):
monkeypatch.setattr(
module.psutil,
"process_iter",
lambda attrs: [
SimpleNamespace(
info={"pid": 4, "name": "VBoxHeadless.exe", "exe": "VirtualBox/VBoxHeadless.exe"}
)
],
)
monkeypatch.setattr(
module.psutil,
"net_connections",
lambda kind: pytest.fail("No known emulator; do not scan listeners"),
)
assert module.emulator_listeners() == []
@pytest.mark.parametrize("kind,valid", [(b"CNXN", True), (b"AUTH", True), (b"HTTP", False)])
def test_adbd_protocol_handles_fragmented_headers(monkeypatch, kind, valid):
command = int.from_bytes(kind, "little")
header = struct.pack("<6I", command, 1, 4096, 0, 0, command ^ 0xFFFFFFFF)
stream = Mock()
stream.__enter__ = Mock(return_value=stream)
stream.__exit__ = Mock(return_value=False)
stream.recv.side_effect = [header[:8], header[8:]]
monkeypatch.setattr(module.socket, "create_connection", lambda *args, **kwargs: stream)
assert module.is_adbd("127.0.0.1:16384", 0.3) is valid
assert stream.sendall.call_args.args[0][:4] == b"CNXN"
def test_discovery_connects_new_instance_without_duplicate_aliases(discovery, monkeypatch):
calls = fake_adb(monkeypatch)
monkeypatch.setattr(
module,
"emulator_listeners",
lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]],
)
monkeypatch.setattr(module, "is_adbd", lambda *args: True)
results = discovery.refresh("adb")
assert [r["target"] for r in results] == ["127.0.0.1:16384", "127.0.0.1:16416"]
discovery.refresh("adb")
assert len(calls) == 5 # cached: no subprocesses on repeated polling
discovery.refresh("adb", force=True)
assert len(calls) == 6 # force enumerates; connected instances are not reconnected
def test_ready_and_unauthorized_emulators_are_not_probed(discovery, monkeypatch):
calls = fake_adb(monkeypatch, ready="127.0.0.1:7555 device\n127.0.0.1:16416 unauthorized\n")
monkeypatch.setattr(
module,
"emulator_listeners",
lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]],
)
monkeypatch.setattr(
module, "is_adbd", lambda *args: pytest.fail("Do not touch an existing transport")
)
assert discovery.refresh("adb") == []
assert calls == [["devices"]]
def test_non_adb_port_is_not_connected(discovery, monkeypatch):
calls = fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", lambda: [["127.0.0.1:23579", "127.0.0.1:16384"]]
)
monkeypatch.setattr(module, "is_adbd", lambda target, timeout: target.endswith(":16384"))
assert discovery.refresh("adb")[0]["success"] is True
assert calls == [["devices"], ["connect", "127.0.0.1:16384"], ["devices"]]
@pytest.mark.parametrize("port", [5038, 5040])
def test_forwarded_adb_never_discovers_local_devices(discovery, monkeypatch, port):
monkeypatch.setattr(
module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", port)
)
monkeypatch.setattr(
module.AdbSession,
"run",
lambda *args, **kwargs: pytest.fail("Remote endpoint must stay untouched"),
)
assert discovery.refresh("adb", force=True) == []
def test_disabled_discovery_and_concurrent_call_do_not_connect(discovery, monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", False)
monkeypatch.setattr(
module.AdbSession, "run", lambda *args, **kwargs: pytest.fail("Must not spawn")
)
assert discovery.refresh("adb") == []
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True)
with discovery._gate:
assert discovery.refresh("adb") == []
def test_remembered_phone_survives_process_restart_and_offline_peer(
discovery, monkeypatch, tmp_path
):
assert discovery.remember("192.0.2.10:5555")
assert discovery.remember("192.0.2.11:5555")
second = module.AdbDeviceDiscovery()
monkeypatch.setattr(second, "_directory", lambda: tmp_path)
calls = fake_adb(monkeypatch, failures={"192.0.2.10:5555"})
results = second.refresh("adb")
assert len(results) == 2
assert any(r["success"] and r["target"] == "192.0.2.11:5555" for r in results)
assert len(calls) == 4
def test_total_budget_stops_unlimited_connection_attempts(discovery, monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module, "emulator_listeners", lambda: [[f"127.0.0.1:{16384 + i}"] for i in range(100)]
)
def probe(*args):
clock[0] += 1
return True
monkeypatch.setattr(module, "is_adbd", probe)
calls = fake_adb(monkeypatch)
discovery.refresh("adb")
assert len(calls) == 9 # listing plus four connects/readiness checks within 5s
def test_corrupt_persistence_and_missing_process_permissions_are_nonfatal(
discovery, monkeypatch, tmp_path
):
(tmp_path / "broken.json").write_text(json.dumps({"target": "bad"}))
assert discovery._remembered() == []
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied())
)
assert discovery.refresh("adb")[0]["success"] is False
def test_listener_permission_failure_still_reconnects_saved_phone(discovery, monkeypatch):
assert discovery.remember("192.0.2.10:5555")
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied())
)
results = discovery.refresh("adb")
assert results[-1]["target"] == "192.0.2.10:5555"
assert results[-1]["success"] is True
def test_localhost_alias_does_not_create_duplicate_transport(discovery, monkeypatch):
assert discovery.remember("localhost:16384")
calls = fake_adb(monkeypatch, ready="127.0.0.1:16384 device\n")
assert discovery.refresh("adb") == []
assert calls == [["devices"]]
def test_connect_waits_until_transport_is_visible(monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)
)
responses = iter(
["List of devices attached\n", "127.0.0.1:16384 offline\n", "127.0.0.1:16384 device\n"]
)
session = Mock()
session.run.side_effect = lambda *args, **kwargs: subprocess.CompletedProcess(
[], 0, next(responses), ""
)
assert module.AdbDeviceDiscovery._wait_for_transport(session, "localhost:16384", 1) == "device"
assert session.run.call_count == 3
def test_transport_settling_is_bounded(monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)
)
session = Mock()
session.run.return_value = subprocess.CompletedProcess([], 0, "List of devices attached\n", "")
assert module.AdbDeviceDiscovery._wait_for_transport(session, "127.0.0.1:16384", 0.2) is None
assert clock[0] == 0.2
def test_pending_transport_is_not_reported_as_failed_or_connected(discovery, monkeypatch):
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", lambda: [["127.0.0.1:16384", "127.0.0.1:7555"]]
)
monkeypatch.setattr(module, "is_adbd", lambda *args: True)
monkeypatch.setattr(discovery, "_wait_for_transport", lambda *args: None)
results = discovery.refresh("adb")
assert len(results) == 1
assert results[0]["pending"] is True
assert results[0]["success"] is False
assert "等待设备就绪" in results[0]["message"]
+40
View File
@@ -20,6 +20,7 @@ import pytest
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.device_pool import DevicePool, DeviceStatus
from artemis.config import settings
device_pool_module = importlib.import_module("artemis.runtime.device_pool")
@@ -245,6 +246,45 @@ def test_warm_up_without_adb_returns_false(monkeypatch):
assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False
def test_adb_warmup_does_not_start_local_server_for_remote_endpoint(monkeypatch):
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5038)
async def unexpected_spawn(*args, **kwargs):
pytest.fail("A forwarded endpoint must not start a local ADB daemon")
monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_spawn)
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
def test_adb_warmup_reports_failed_start_with_local_command(monkeypatch):
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:remote.example:5040")
captured = {}
warnings = []
class FailedProcess:
returncode = 1
async def communicate(self):
return b"", b"cannot bind listener"
async def spawn(*args, **kwargs):
captured["command"] = args
captured["env"] = kwargs["env"]
return FailedProcess()
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
monkeypatch.setattr(device_pool_module.logger, "warning", warnings.append)
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
assert captured["command"] == ("adb", "-H", "localhost", "-P", "5037", "start-server")
assert captured["env"]["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
assert len(warnings) == 1
assert "exit 1" in warnings[0]
assert "cannot bind listener" in warnings[0]
def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
+75
View File
@@ -0,0 +1,75 @@
"""Run just the ADB startup functions with fake commands; never touch host ADB."""
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.skipif(not shutil.which("powershell.exe"), reason="Windows PowerShell required")
@pytest.mark.parametrize("script_name", ["start.ps1", "install_deps.ps1"])
@pytest.mark.parametrize("outcome", ["success", "failure", "exception"])
def test_adb_startup_reports_failure_and_preserves_server_environment(
tmp_path, script_name, outcome
):
harness = tmp_path / "adb-startup.ps1"
harness.write_text(
"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$env:ADB_TEST_SCRIPT, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
$function = $ast.Find({ param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'Start-LocalAdbServer'
}, $true)
Invoke-Expression $function.Extent.Text
function Test-CommandExists { return $true }
function adb {
Write-Host ('ADB_ARGS=' + ($args -join '|'))
if ($env:ADB_TEST_OUTCOME -eq 'exception') { throw 'fixture launch error' }
Write-Error 'fixture stderr' -ErrorAction Continue
$global:LASTEXITCODE = $(if ($env:ADB_TEST_OUTCOME -eq 'failure') { 7 } else { 0 })
}
Start-LocalAdbServer
if ($env:ADB_SERVER_SOCKET -ne 'tcp:remote.example:5040') { throw 'Socket was changed' }
if ($env:ANDROID_ADB_SERVER_ADDRESS -ne 'remote.example') { throw 'Host was changed' }
if ($env:ANDROID_ADB_SERVER_PORT -ne '5040') { throw 'Port was changed' }
if ($ErrorActionPreference -ne 'Stop') { throw 'Error preference was changed' }
Write-Host 'HARNESS_OK'
""",
encoding="utf-8-sig",
)
result = subprocess.run(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(harness)],
env={
**os.environ,
"ADB_TEST_SCRIPT": str(ROOT / "scripts" / script_name),
"ADB_TEST_OUTCOME": outcome,
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
"ANDROID_ADB_SERVER_PORT": "5040",
},
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "ADB_ARGS=-H|localhost|-P|5037|start-server" in result.stdout
assert "HARNESS_OK" in result.stdout
if outcome == "success":
assert "[警告]" not in result.stdout
elif outcome == "failure":
assert "退出码 7" in result.stdout
assert "fixture stderr" in result.stdout
else:
assert "启动异常" in result.stdout
assert "fixture launch error" in result.stdout
+106
View File
@@ -0,0 +1,106 @@
"""Exercise CMD parsing and Windows PowerShell 5.1 without installing anything."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.skipif(
not shutil.which("cmd.exe") or not shutil.which("powershell.exe"),
reason="Windows CMD and Windows PowerShell required",
)
def copy_launchers(root: Path, newline: bytes) -> None:
root.mkdir()
(root / "scripts").mkdir()
for name in ("start.bat", "start-cn.bat"):
content = (ROOT / name).read_bytes().replace(b"\r\n", b"\n")
# ASCII batch wrappers remain safe even if a zip/editor changes EOLs.
content.decode("ascii")
(root / name).write_bytes(content.replace(b"\n", newline))
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
@pytest.mark.parametrize("exit_code", [0, 7])
def test_launch_forwards_options_and_exit_code_without_cmd_garbling(tmp_path, newline, exit_code):
root = tmp_path / "source with spaces"
copy_launchers(root, newline)
(root / "scripts" / "start.ps1").write_text(
"""param([string]$Mirror, [int]$Port, [switch]$NoOpen, [switch]$SkipUpdate, [switch]$SkipMcpSync)
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
Write-Host '镜像地址和下载超时可在 config\\downloads.json 中调整。'
Write-Host ('ARGS=' + (@{mirror=$Mirror;port=$Port;no_open=[bool]$NoOpen;skip_update=[bool]$SkipUpdate;skip_mcp=[bool]$SkipMcpSync;language=$env:ARTEMIS_LAUNCH_LANG} | ConvertTo-Json -Compress))
exit ([int]$env:LAUNCH_TEST_EXIT)
""",
encoding="utf-8-sig",
)
result = subprocess.run(
[
"cmd.exe",
"/d",
"/c",
"start-cn.bat",
"-Port",
"8123",
"-NoOpen",
"-SkipUpdate",
"-SkipMcpSync",
],
cwd=root,
env={**os.environ, "ARTEMIS_NO_PAUSE": "1", "LAUNCH_TEST_EXIT": str(exit_code)},
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == exit_code, result.stdout + result.stderr
assert result.stderr == ""
assert "镜像地址和下载超时可在" in result.stdout
args = json.loads(
next(
line.removeprefix("ARGS=")
for line in result.stdout.splitlines()
if line.startswith("ARGS=")
)
)
assert args == {
"mirror": "China",
"port": 8123,
"no_open": True,
"skip_update": True,
"skip_mcp": True,
"language": "zh-CN",
}
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
@pytest.mark.parametrize("help_flag", ["-Help", "--help"])
def test_actual_chinese_help_has_no_encoding_error_or_bootstrap(tmp_path, newline, help_flag):
root = tmp_path / "source with spaces"
copy_launchers(root, newline)
shutil.copyfile(ROOT / "scripts" / "start.ps1", root / "scripts" / "start.ps1")
assert (root / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
result = subprocess.run(
["cmd.exe", "/d", "/c", "start-cn.bat", help_flag],
cwd=root,
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
assert "用法:start-cn.bat [选项]" in result.stdout
assert "仅启动网页,跳过 Codex MCP" in result.stdout
assert "国内镜像设置:config\\downloads.json" in result.stdout
assert "�" not in result.stdout
assert not (root / ".venv").exists()
def test_batch_files_are_ascii_and_powershell_text_has_bom():
for name in ("start.bat", "start-cn.bat"):
(ROOT / name).read_bytes().decode("ascii")
assert (ROOT / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
+132
View File
@@ -0,0 +1,132 @@
import json
import pytest
from artemis.runtime.case_result import (
CaseTestResult,
normalize_case_result,
read_case_result,
resolve_case_result,
)
from artemis.sdk.agent import attach_test_summary, resolve_trace_suffix
def outcome(status="passed", **extra):
return {
"task_status": "completed",
"test_result": {
"status": status,
"reason": "预期与实际对比",
"evidence": ["步骤 2 截图显示实际结果"],
},
**extra,
}
@pytest.mark.parametrize("status", ["passed", "failed"])
def test_a_claim_without_evidence_is_never_a_verdict(status):
result = normalize_case_result(
CaseTestResult(status=status, reason="执行者说完成了", evidence=[" "])
)
assert result.status == "inconclusive"
assert "证据" in result.reason
def test_completed_execution_and_failed_test_are_independent():
result = resolve_case_result(outcome("failed"), "completed")
assert result.status == "failed"
returned = attach_test_summary(
{"status": "completed", "explanation": "已完成", "test_result": {"status": "passed"}},
outcome("failed"),
)
assert returned["status"] == "completed"
assert returned["test_result"]["status"] == "failed"
assert resolve_trace_suffix("completed", outcome("failed")) == "_TESTFAIL"
@pytest.mark.parametrize("status", ["failed", "cancelled", "interrupted", "skipped"])
def test_incomplete_execution_does_not_inherit_a_pass(status):
result = resolve_case_result(outcome(), status, "环境初始化失败")
assert result.status == "blocked"
assert result.reason == "环境初始化失败"
def test_a_previously_confirmed_assertion_failure_survives_interruption_and_later_pass():
data = outcome(
tests={
"failed": 1,
"failed_items": [
{
"kind": "assert",
"item_text": "预期显示成功提示",
"evidence": "步骤 4 正确点击后显示错误提示",
}
],
}
)
result = resolve_case_result(data, "interrupted", "设备掉线")
assert result.status == "failed"
assert result.evidence == ["步骤 4 正确点击后显示错误提示"]
def test_navigation_failure_is_not_reported_as_a_product_defect():
result = resolve_case_result(
{
"tests": {
"failed": 1,
"failed_items": [
{"kind": "verify", "item_text": "打开设置", "evidence": "页面未打开"}
],
}
},
"failed",
)
assert result.status == "blocked"
def test_legacy_completed_flash_without_verification_is_inconclusive():
assert resolve_case_result({}, "completed").status == "inconclusive"
assert resolve_case_result(None, "running") is None
assert resolve_case_result({"tests": {"passed": 2}}, "completed").status == "passed"
def test_missing_checks_prevent_an_overall_pass():
assert (
resolve_case_result(outcome(tests={"passed": 1, "unchecked": 1}), "completed").status
== "inconclusive"
)
def test_session_reader_handles_missing_and_corrupt_outcomes_without_claiming_pass(tmp_path):
assert read_case_result("case", "completed", root=tmp_path)["status"] == "inconclusive"
directory = tmp_path / "case"
directory.mkdir()
(directory / "run_outcome.json").write_text("{", encoding="utf-8")
assert read_case_result("case", "completed", root=tmp_path)["status"] == "inconclusive"
(directory / "run_outcome.json").write_text(json.dumps(outcome("failed")), encoding="utf-8")
assert read_case_result("case", "completed", root=tmp_path)["status"] == "failed"
with pytest.raises(ValueError):
read_case_result("../case", "completed", root=tmp_path)
def test_failure_persistence_keeps_confirmed_test_failure_and_checkpoint_evidence(
tmp_path, monkeypatch
):
from artemis.runtime import task_result
monkeypatch.setattr(task_result, "TRACES_PATH", tmp_path)
directory = tmp_path / "case"
directory.mkdir()
(directory / "result.json").write_text(
json.dumps(outcome("failed", tests={"checkpoint": {"passed": False}})),
encoding="utf-8",
)
(directory / "run_outcome.json").write_text(
json.dumps(outcome("passed", tests={"passed": 1})), encoding="utf-8"
)
task_result.save_failure_result("case", "任务超时")
for name in ("result.json", "run_outcome.json"):
saved = json.loads((directory / name).read_text(encoding="utf-8"))
assert saved["test_result"]["status"] == "failed"
assert saved["tests"]["checkpoint"]["passed"] is False
assert saved["test_result"]["evidence"] == ["步骤 2 截图显示实际结果"]
+37 -291
View File
@@ -14,6 +14,8 @@
"""Unit tests for ARTEMIS Unified CLI application."""
import pytest
from typer.testing import CliRunner
from artemis.interfaces.cli.main import app
@@ -169,56 +171,41 @@ def test_cli_mcp_help():
assert "--generate-config" in result.output
def test_cli_mcp_generate_config():
"""Verify 'artemis mcp --generate-config cursor' produces valid configuration."""
result = runner.invoke(app, ["mcp", "--generate-config", "cursor"])
assert result.exit_code == 0
assert "mcpServers" in result.output
assert "mcp_server" in result.output
def test_cli_mcp_generate_config_antigravity():
"""Verify current Antigravity uses only documented, load-safe fields."""
from artemis.interfaces.cli.commands.mcp import _get_config_snippet
result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"])
assert result.exit_code == 0
assert "mcpServers" in result.output
server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][
"artemis"
]
assert server_config["disabledTools"] == []
assert "tools" not in server_config
legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"]["artemis"]
assert set(legacy_config["tools"]) == {
"mobile_run_task",
"mobile_manage_task",
"mobile_get_device_state",
"mobile_inspect_trace",
"mobile_diagnose",
"mobile_workspace",
"mobile_config",
"mobile_task_file",
"mobile_batch",
"mobile_search_history",
"mobile_export_report",
}
assert all(tool["eager"] is True for tool in legacy_config["tools"].values())
def test_cli_mcp_generate_config_all():
"""Verify 'artemis mcp --generate-config all' includes every supported client."""
result = runner.invoke(app, ["mcp", "--generate-config", "all"])
assert result.exit_code == 0
assert "antigravity" in result.output
assert "cursor" in result.output
assert "windsurf" in result.output
assert "claude" in result.output
assert "vscode" in result.output
assert "cline" in result.output
assert "roo" in result.output
assert "codex" in result.output
@pytest.mark.parametrize("option", ["--install", "--generate-config"])
@pytest.mark.parametrize(
"client",
[
"cursor",
"vscode",
"claude",
"claude_code",
"claude_desktop",
"antigravity",
"jetski",
"windsurf",
"cline",
"roo",
"roo_code",
"openclaw",
"all",
],
)
def test_cli_mcp_rejects_other_clients_without_modifying_files(
tmp_path, monkeypatch, option, client
):
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex"))
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData/Roaming"))
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
sentinel = tmp_path / ".cursor/mcp.json"
sentinel.parent.mkdir()
sentinel.write_bytes(b"{invalid json with user data}")
before = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
result = runner.invoke(app, ["mcp", option, client])
assert result.exit_code == 1
assert "Codex" in result.output
after = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
assert after == before
def test_cli_mcp_generate_config_codex():
@@ -251,165 +238,6 @@ def test_cli_mcp_generate_config_codex():
]
def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
"""Verify 'artemis mcp --install antigravity' writes configuration and global rules into target files."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
result = runner.invoke(app, ["mcp", "--install", "antigravity"])
assert result.exit_code == 0
assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output
jetski_file = tmp_path / ".gemini" / "jetski" / "mcp_config.json"
assert jetski_file.exists()
import json
data = json.loads(jetski_file.read_text())
assert "artemis" in data["mcpServers"]
assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"]
assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"]
current_file = tmp_path / ".gemini" / "config" / "mcp_config.json"
current_data = json.loads(current_file.read_text())
assert current_data["mcpServers"]["artemis"]["disabledTools"] == []
assert "tools" not in current_data["mcpServers"]["artemis"]
# Verify global rule file installed
gemini_md = tmp_path / ".gemini" / "GEMINI.md"
assert gemini_md.exists()
assert "Mobile Testing Mindset" in gemini_md.read_text(encoding="utf-8")
rule_file = tmp_path / ".gemini" / "rules" / "artemis.md"
assert rule_file.exists()
assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8")
def test_cli_mcp_install_all(tmp_path, monkeypatch):
"""Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
result = runner.invoke(app, ["mcp", "--install", "all"])
assert result.exit_code == 0
assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output
assert (tmp_path / ".cursor" / "mcp.json").exists()
assert (tmp_path / ".codeium" / "windsurf" / "mcp_config.json").exists()
assert (tmp_path / ".openclaw" / "openclaw.json").exists()
assert (tmp_path / ".claude.json").exists()
assert (tmp_path / ".codex" / "config.toml").exists()
import json
cursor_data = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
assert cursor_data["mcpServers"]["artemis"]["command"]
windsurf_data = json.loads((tmp_path / ".codeium" / "windsurf" / "mcp_config.json").read_text())
assert windsurf_data["mcpServers"]["artemis"]["command"]
claude_data = json.loads((tmp_path / ".claude.json").read_text())
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir
vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text())
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
assert "mcpServers" not in vscode_data
copilot_data = json.loads((tmp_path / ".copilot" / "mcp-config.json").read_text())
assert copilot_data["servers"]["artemis"]["type"] == "stdio"
openclaw_data = json.loads((tmp_path / ".openclaw" / "openclaw.json").read_text())
assert openclaw_data["mcp"]["servers"]["artemis"]["enabled"] is True
cline_data = json.loads(
(tmp_path / ".cline" / "data" / "settings" / "cline_mcp_settings.json").read_text()
)
assert cline_data["mcpServers"]["artemis"]["disabled"] is False
roo_data = json.loads((tmp_path / ".roo" / "mcp.json").read_text())
assert roo_data["mcpServers"]["artemis"]["disabled"] is False
# Verify global rule files installed across IDEs
assert (tmp_path / ".gemini" / "GEMINI.md").exists()
assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists()
assert (tmp_path / ".cursorrules").exists()
assert (tmp_path / ".cursor" / "rules" / "artemis.mdc").exists()
# Claude Code loads both CLAUDE.md and rules/*.md, so rules are installed
# only as the standalone rule file to avoid duplicated context.
assert not (tmp_path / ".claude" / "CLAUDE.md").exists()
assert (tmp_path / ".claude" / "rules" / "artemis.md").exists()
assert (tmp_path / ".codeium" / "windsurf" / "memories" / "global_rules.md").exists()
assert (tmp_path / ".codeium" / "windsurf" / "rules" / "artemis.md").exists()
assert (tmp_path / ".vscode" / "rules" / "artemis.md").exists()
assert (tmp_path / ".clinerules").exists()
assert (tmp_path / ".cline" / "rules" / "artemis.md").exists()
assert (tmp_path / ".roorules").exists()
assert (tmp_path / ".roo" / "rules" / "artemis.md").exists()
assert (tmp_path / ".openclaw" / "OPENCLAW.md").exists()
assert (tmp_path / ".openclaw" / "rules" / "artemis.md").exists()
assert (tmp_path / ".codex" / "AGENTS.md").exists()
def test_cli_mcp_install_claude_migrates_legacy_claude_md(tmp_path, monkeypatch):
"""Verify the claude target removes a previously injected CLAUDE.md block while preserving user content."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
claude_dir = tmp_path / ".claude"
claude_dir.mkdir(parents=True)
claude_md = claude_dir / "CLAUDE.md"
claude_md.write_text(
"# My own instructions\n\n"
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
"<!-- END ARTEMIS MOBILE TESTING RULES -->\n",
encoding="utf-8",
)
result = runner.invoke(app, ["mcp", "--install", "claude"])
assert result.exit_code == 0
remaining = claude_md.read_text(encoding="utf-8")
assert "My own instructions" in remaining
assert "ARTEMIS MOBILE TESTING RULES" not in remaining
rule_file = claude_dir / "rules" / "artemis.md"
assert rule_file.exists()
assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8")
def test_cli_mcp_install_claude_deletes_block_only_claude_md(tmp_path, monkeypatch):
"""Verify a CLAUDE.md consisting solely of the injected block is deleted outright."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
claude_dir = tmp_path / ".claude"
claude_dir.mkdir(parents=True)
claude_md = claude_dir / "CLAUDE.md"
claude_md.write_text(
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
"<!-- END ARTEMIS MOBILE TESTING RULES -->\n",
encoding="utf-8",
)
result = runner.invoke(app, ["mcp", "--install", "claude"])
assert result.exit_code == 0
assert not claude_md.exists()
assert (claude_dir / "rules" / "artemis.md").exists()
def test_cli_mcp_install_claude_refuses_unparseable_claude_json(tmp_path, monkeypatch):
"""Verify an unparseable ~/.claude.json is left untouched instead of being rewritten."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
corrupt = "{ this is not json at all"
claude_json = tmp_path / ".claude.json"
claude_json.write_text(corrupt, encoding="utf-8")
result = runner.invoke(app, ["mcp", "--install", "claude"])
assert result.exit_code == 0
assert claude_json.read_text(encoding="utf-8") == corrupt
def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monkeypatch):
"""Verify Codex TOML merging preserves other servers and updates only Artemis."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
@@ -461,88 +289,6 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
assert agents_text.count("<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->") == 1
def test_cli_mcp_install_jsonc_and_backup(tmp_path, monkeypatch):
"""Verify merging configuration preserves valid JSONC and creates .bak for unparseable JSON."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir(parents=True, exist_ok=True)
mcp_json = cursor_dir / "mcp.json"
# 1. Valid JSONC with comments and trailing comma
mcp_json.write_text(
'{\n // My existing server\n "mcpServers": {\n "test": {"command": "echo"},\n }\n}',
encoding="utf-8",
)
result = runner.invoke(app, ["mcp", "--install", "cursor"])
assert result.exit_code == 0
import json
data = json.loads(mcp_json.read_text(encoding="utf-8"))
assert "test" in data["mcpServers"]
assert "artemis" in data["mcpServers"]
# 2. Corrupt / unparseable file triggers backup
mcp_json.write_text("INVALID JSON DATA {{{{", encoding="utf-8")
result2 = runner.invoke(app, ["mcp", "--install", "cursor"])
assert result2.exit_code == 0
backup_file = cursor_dir / "mcp.json.bak"
assert backup_file.exists()
assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8")
def test_cli_mcp_install_refuses_rewrite_when_backup_fails(tmp_path, monkeypatch):
"""Backup failure for an unparseable config must abort the rewrite instead of destroying it."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir(parents=True, exist_ok=True)
mcp_json = cursor_dir / "mcp.json"
original = "INVALID JSON DATA {{{{"
mcp_json.write_text(original, encoding="utf-8")
import pathlib
real_write_text = pathlib.Path.write_text
def failing_bak_write(self, *args, **kwargs):
if str(self).endswith(".bak"):
raise OSError("disk full")
return real_write_text(self, *args, **kwargs)
monkeypatch.setattr("pathlib.Path.write_text", failing_bak_write)
runner.invoke(app, ["mcp", "--install", "cursor"])
assert mcp_json.read_text(encoding="utf-8") == original
assert not (cursor_dir / "mcp.json.bak").exists()
def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeypatch):
"""Verify reinstalling OpenClaw replaces the obsolete plugin wrapper with mcp.servers."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
config_path = tmp_path / ".openclaw" / "openclaw.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
'{"plugins":{"artemis_mcp":{"enabled":true},"keep":{"enabled":true}}}',
encoding="utf-8",
)
result = runner.invoke(app, ["mcp", "--install", "openclaw"])
assert result.exit_code == 0
import json
data = json.loads(config_path.read_text(encoding="utf-8"))
assert "artemis_mcp" not in data["plugins"]
assert data["plugins"]["keep"]["enabled"] is True
assert data["mcp"]["servers"]["artemis"]["enabled"] is True
assert data["mcp"]["servers"]["artemis"]["command"]
def test_cli_restart_help():
"""Verify 'artemis restart --help' displays lifecycle options."""
result = runner.invoke(app, ["restart", "--help"])
+153 -7
View File
@@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif(
def powershell(tmp_path, script, **env):
path = tmp_path / "test.ps1"
path.write_text(
"$ErrorActionPreference = 'Stop'\n. $env:DOWNLOAD_SCRIPT\n" + script, encoding="ascii"
"$ErrorActionPreference = 'Stop'\n"
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
". $env:DOWNLOAD_SCRIPT\n" + script,
encoding="ascii",
)
clean_env = {
key: value
@@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env):
**env,
},
capture_output=True,
text=True,
encoding="utf-8",
timeout=45,
)
@@ -58,7 +61,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
if (([Uri]$ArtemisDownloads.scrcpy_url).Host -eq 'github.com') { throw 'China scrcpy still uses GitHub' }
if ($ArtemisDownloads.scrcpy_urls.Count -lt 2) { throw 'China scrcpy has no fallback' }
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
Remove-Item Env:npm_config_registry
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
@@ -71,11 +75,153 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr
def test_scrcpy_archive_is_checked_before_extraction():
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
"Remove-Item $extractDir -Recurse"
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
archive = tmp_path / "fixture.zip"
with zipfile.ZipFile(archive, "w") as package:
package.writestr("scrcpy-win64-v4.1/scrcpy.exe", b"verified executable")
package.writestr("scrcpy-win64-v4.1/scrcpy-server", b"verified server")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
install_dir = tmp_path / "user/Programs/scrcpy"
install_dir.mkdir(parents=True)
(install_dir / "keep.txt").write_text("user file")
temp_dir = tmp_path / "temporary"
temp_dir.mkdir()
result = powershell(
tmp_path,
"""
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
if ($errors) { throw 'Launcher parse error' }
$installer = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Install-PortableScrcpy' }, $true)
. ([scriptblock]::Create($installer.Extent.Text))
function Get-LaunchText { param($English, $Chinese); return $English }
$script:attempts = @()
function Invoke-DownloadFile {
param($Uri, $OutFile)
$script:attempts += $Uri
if ($Uri -eq 'https://first.example/scrcpy.zip' -or $env:FIRST_FAILURE -eq 'all') {
Set-Content -LiteralPath $OutFile -Value 'rejected content'
if ($env:FIRST_FAILURE -eq 'hash') { return $true }
$script:ArtemisDownloadError = if ($env:FIRST_FAILURE -eq 'timeout') { 'Download timed out' } else { 'HTTP 404' }
return $false
}
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile -Force
return $true
}
$script:ArtemisDownloads = [pscustomobject]@{
scrcpy_urls = @('https://first.example/scrcpy.zip', 'https://second.example/scrcpy.zip')
scrcpy_sha256 = $env:FIXTURE_HASH
}
$installed = Install-PortableScrcpy
Write-Host "INSTALLED=$installed"
Write-Host ('ATTEMPTS=' + ($script:attempts -join ','))
""",
LOCALAPPDATA=str(tmp_path / "user"),
TEMP=str(temp_dir),
TMP=str(temp_dir),
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
FIRST_FAILURE=first_failure,
FIXTURE_HASH=digest,
)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
assert "ATTEMPTS=https://first.example/scrcpy.zip,https://second.example/scrcpy.zip" in (
result.stdout
)
assert (install_dir / "keep.txt").read_text() == "user file"
assert not list(temp_dir.iterdir()), "Installer must clean its temporary download directory"
if first_failure == "all":
assert "INSTALLED=False" in result.stdout
assert "HTTP 404" in result.stdout
assert not (install_dir / "scrcpy.exe").exists()
else:
assert "INSTALLED=True" in result.stdout
assert (install_dir / "scrcpy.exe").read_bytes() == b"verified executable"
assert (install_dir / "scrcpy-server").read_bytes() == b"verified server"
expected_error = {
"http": "HTTP 404",
"timeout": "Download timed out",
"hash": "SHA256 verification failed",
}[first_failure]
assert expected_error in result.stdout
@pytest.mark.parametrize("curl_exit", [22, 28])
def test_download_reports_http_or_timeout_without_repeating_request(tmp_path, curl_exit):
result = powershell(
tmp_path,
"""
function curl.exe {
$target = $args[[Array]::IndexOf($args, '-o') + 1]
Set-Content -LiteralPath $target -Value 'partial download'
$global:LASTEXITCODE = [int]$env:CURL_EXIT
return '404'
}
function Invoke-WebRequest { throw 'HTTP rejection/timeout must not be retried with a second client' }
$destination = Join-Path $env:TEST_ROOT 'download.zip'
$downloaded = Invoke-DownloadFile -Uri 'https://missing.example/file.zip' -OutFile $destination -TimeoutSec 10
Write-Host "DOWNLOADED=$downloaded"
Write-Host "REASON=$script:ArtemisDownloadError"
if (Test-Path -LiteralPath $destination) { throw 'Partial download was left behind' }
""",
CURL_EXIT=str(curl_exit),
)
assert result.returncode == 0, result.stdout + result.stderr
assert "DOWNLOADED=False" in result.stdout
assert ("HTTP 404" if curl_exit == 22 else "timed out after 10 seconds") in result.stdout
@pytest.mark.parametrize(
"fallbacks,valid",
[
(["https://backup.example/scrcpy.zip"], True),
([], True),
(["http://insecure.example/scrcpy.zip"], False),
("https://not-an-array.example/scrcpy.zip", False),
([None], False),
],
)
def test_scrcpy_custom_sources_validate_urls_and_preserve_order(tmp_path, fallbacks, valid):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_url"] = "https://custom.example/scrcpy.zip"
config["overrides"]["scrcpy_fallback_urls"] = fallbacks
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
Write-Host ('SOURCES=' + ($ArtemisDownloads.scrcpy_urls -join ','))
""",
)
if valid:
assert result.returncode == 0, result.stdout + result.stderr
assert "SOURCES=" + ",".join(["https://custom.example/scrcpy.zip", *fallbacks]) in (
result.stdout
)
else:
assert result.returncode != 0
assert "HTTPS URLs" in result.stderr
def test_china_sources_never_fall_back_to_direct_github(tmp_path):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_fallback_urls"] = [
"https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
config["china"]["scrcpy_url"], # Duplicate should be downloaded only once.
]
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
if ($ArtemisDownloads.scrcpy_urls.Count -ne 1) { throw 'Invalid China sources' }
if (([Uri]$ArtemisDownloads.scrcpy_urls[0]).Host -eq 'github.com') { throw 'Direct GitHub fallback' }
""",
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("corrupt", [False, True])
+96 -65
View File
@@ -53,18 +53,18 @@ def repos(tmp_path):
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
installed, author = repos
assert update.update_source(installed, port=1) == 10
assert update.update_source(installed) == 10
assert (installed / "source.txt").read_text() == "version2"
assert (installed / ".env").read_text() == "secret stays local"
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 0
def test_dirty_checkout_is_preserved(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
(installed / "source.txt").write_text("user edits")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
assert (installed / "source.txt").read_text() == "user edits"
@@ -77,16 +77,32 @@ def test_diverged_checkout_is_not_merged(repos):
git(installed, "add", "local.txt")
git(installed, "commit", "-m", "local")
before = git(installed, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
def test_zip_install_continues_without_git_mutation(tmp_path, capsys):
assert update.update_source(tmp_path, port=1) == 0
def test_zip_install_fails_without_git_mutation(tmp_path, capsys):
assert update.update_source(tmp_path) == 1
assert not (tmp_path / ".git").exists()
assert "Git" in capsys.readouterr().out
def test_startup_check_reports_update_without_changing_source(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
assert update.update_source(installed, check_only=True) == 10
assert git(installed, "rev-parse", "HEAD") == before
assert (installed / "source.txt").read_text() == "version1"
def test_update_fetch_failure_is_not_reported_as_success(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
git(installed, "remote", "set-url", "origin", str(installed / "missing-upstream"))
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
root = tmp_path / "project"
path = tmp_path / "config.toml"
@@ -120,33 +136,7 @@ command = "leave-alone"
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
path = tmp_path / "mcp.json"
root = tmp_path / "project"
config = {
"cwd": str(root),
"command": "old",
"args": ["-m", "mcp_server"],
"disabled": True,
"env": {"CUSTOM": "preserved"},
"autoApprove": [],
}
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
moved_root = tmp_path / "new release"
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
assert result["artemis"]["cwd"] == str(moved_root)
assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"}
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists()
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
home = tmp_path / "home"
@@ -170,8 +160,11 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp
cursor_home = home / ".cursor"
cursor_home.mkdir()
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
vscode_home = home / "AppData/Code/User"
vscode_home.mkdir(parents=True)
vscode_config = vscode_home / "mcp.json"
vscode_config.write_bytes(b"{invalid VS Code JSONC}")
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("APPDATA", str(home / "AppData"))
root = tmp_path / "new release"
@@ -187,26 +180,31 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp
assert entry["env"]["PYTHONPATH"] == str(root)
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
assert "artemis" not in cursor["mcpServers"]
assert cursor["mcpServers"]["other"] == {"command": "keep"}
assert vscode_config.read_bytes() == b"{invalid VS Code JSONC}"
assert not vscode_config.with_name("mcp.json.artemis-update.bak").exists()
first = codex_config.read_text(encoding="utf-8")
assert update.refresh_mcp(root) == 0
assert codex_config.read_text(encoding="utf-8") == first
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
from artemis.interfaces.cli.commands import mcp as mcp_command
def test_mcp_sync_fails_on_invalid_codex_config_without_overwriting(tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
cursor_home = home / ".cursor"
cursor_home.mkdir(parents=True)
config = cursor_home / "mcp.json"
config.write_text("{invalid json", encoding="utf-8")
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
config = codex_home / "config.toml"
original = b'model = "keep-secret"\n[mcp_servers.artemis\n'
config.write_bytes(original)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.delenv("CODEX_HOME", raising=False)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
assert update.refresh_mcp(tmp_path / "project") == 1
assert config.read_text(encoding="utf-8") == "{invalid json"
assert config.read_bytes() == original
assert not config.with_name(config.name + ".artemis-update.bak").exists()
output = capsys.readouterr().out
assert str(config) in output
assert "Invalid TOML" in output
assert "keep-secret" not in output
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
@@ -216,7 +214,6 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa
codex_home = home / ".codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
@@ -225,6 +222,59 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
@pytest.mark.parametrize("codex_home_setting", [None, "", "custom"])
def test_codex_sync_reports_actual_path_and_ready_state(
tmp_path, monkeypatch, capsys, codex_home_setting
):
home = tmp_path / "home"
codex_home = home / ".codex" if codex_home_setting != "custom" else tmp_path / "custom Codex"
codex_home.mkdir(parents=True)
monkeypatch.setattr("pathlib.Path.home", lambda: home)
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
if codex_home_setting is None:
monkeypatch.delenv("CODEX_HOME", raising=False)
else:
monkeypatch.setenv("CODEX_HOME", str(codex_home) if codex_home_setting == "custom" else "")
root = tmp_path / "project"
assert update.refresh_mcp(root) == 0
first = capsys.readouterr().out
assert str(codex_home / "config.toml") in first
assert "已安装" in first
assert update.refresh_mcp(root) == 0
second = capsys.readouterr().out
assert str(codex_home / "config.toml") in second
assert "已是最新" in second
if codex_home_setting == "custom":
assert not (home / ".codex").exists()
def test_codex_sync_reports_missing_directory_without_touching_other_clients(
tmp_path, monkeypatch, capsys
):
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.delenv("CODEX_HOME", raising=False)
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
assert update.refresh_mcp(tmp_path / "project") == 0
output = capsys.readouterr().out
assert str(tmp_path / ".codex/config.toml") in output
assert "未发现 Codex 配置目录" in output
assert "--install codex" in output
assert not (tmp_path / ".codex").exists()
def test_custom_codex_command_is_preserved_and_reported(tmp_path, monkeypatch, capsys):
codex_home = tmp_path / ".codex"
codex_home.mkdir()
config = codex_home / "config.toml"
original = '[mcp_servers.artemis]\ncommand = "custom"\nargs = ["wrapper"]\n'
config.write_text(original, encoding="utf-8")
monkeypatch.setenv("CODEX_HOME", str(codex_home))
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
assert update.refresh_mcp(tmp_path / "project") == 0
assert config.read_text(encoding="utf-8") == original
assert "自定义" in capsys.readouterr().out
def test_codex_install_preserves_existing_user_tables(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
@@ -277,25 +327,6 @@ def test_codex_inline_environment_sync_is_idempotent(tmp_path):
)
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
path = tmp_path / "mcp.json"
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
path.write_text(original, encoding="utf-8")
assert (
update.refresh_json(
path,
("mcpServers",),
tmp_path,
"new-python",
("mobile_run_task",),
install_missing=True,
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
)
is None
)
assert path.read_text(encoding="utf-8") == original
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
ui = tmp_path / "apps/showcase_ui"
(ui / "src").mkdir(parents=True)
+21 -1
View File
@@ -112,7 +112,7 @@ def test_unpacked_archive_fast_forwards(repository, tmp_path):
git(root, "push", "test-upstream", "main")
from scripts.update_installation import update_source
assert update_source(extracted, port=1) == 10
assert update_source(extracted) == 10
assert (extracted / "start.bat").read_text() == "updated"
assert git(extracted, "status", "--porcelain") == ""
@@ -132,3 +132,23 @@ def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_p
)
with pytest.raises(ValueError, match="credential-free HTTPS"):
module.package_source(root, tmp_path / "source.zip")
def test_archive_checks_out_chinese_batch_with_crlf_and_clean_git(repository, tmp_path):
root, _, _, _ = repository
project_root = Path(__file__).resolve().parents[2]
(root / ".gitattributes").write_bytes((project_root / ".gitattributes").read_bytes())
# The Git blob uses LF; the archive must contain the CRLF working-tree form.
content = (project_root / "start-cn.bat").read_bytes().replace(b"\r\n", b"\n")
(root / "start-cn.bat").write_bytes(content)
git(root, "add", "--", ".gitattributes", "start-cn.bat")
git(root, "commit", "-m", "Windows launcher attributes")
archive_path = tmp_path / "source.zip"
module.package_source(root, archive_path)
with zipfile.ZipFile(archive_path) as archive:
packed = archive.read("artemis/start-cn.bat")
assert b"\r\n" in packed
assert b"\n" not in packed.replace(b"\r\n", b"")
assert packed.decode("utf-8").startswith("@echo off\r\n")
archive.extractall(tmp_path / "unpacked")
assert git(tmp_path / "unpacked/artemis", "status", "--porcelain") == ""
+6 -3
View File
@@ -6,7 +6,7 @@ from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver
@patch("artemis.clients.ui_automator_client.u2.connect")
@patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
@@ -25,7 +25,10 @@ def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
mock_connect.assert_called_once_with("device-123")
@patch("artemis.clients.ui_automator_client.u2.connect", side_effect=RuntimeError("offline"))
@patch(
"artemis.clients.ui_automator_client._ManagedUIAutomatorDevice",
side_effect=RuntimeError("offline"),
)
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
@@ -43,7 +46,7 @@ def test_failed_ui_connection_does_not_stop_process_awake_service(
assert client._awake_strategy is None
@patch("artemis.clients.ui_automator_client.u2.connect")
@patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more