Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6560dbd4 | ||
|
|
df66897ff4 | ||
|
|
a6a3ee2eb0 | ||
|
|
49873e7f05 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ cd Artemis-Eapil
|
||||
```powershell
|
||||
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
|
||||
.\start.bat -Port 8001 # 指定端口
|
||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||
.\start.bat -SkipUpdate # 跳过源码更新提示
|
||||
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||
```
|
||||
|
||||
@@ -39,7 +39,7 @@ cd Artemis-Eapil
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和启动时自动更新目前由 Windows 脚本提供。
|
||||
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和独立升级脚本目前由 Windows 提供。
|
||||
|
||||
## 配置与使用
|
||||
|
||||
@@ -72,9 +72,11 @@ uv run artemis mcp --install codex
|
||||
|
||||
## 更新与排错
|
||||
|
||||
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步 Codex MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。Codex 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 Codex 中重新加载 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)
|
||||
|
||||
|
||||
@@ -30,6 +30,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 +53,7 @@ def preferences():
|
||||
return {
|
||||
**store.preferences(),
|
||||
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +258,12 @@ 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"]:
|
||||
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}
|
||||
@@ -28,7 +28,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 +36,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"])
|
||||
@@ -83,7 +83,9 @@ def _list_sessions_sync():
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
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"]
|
||||
|
||||
@@ -274,7 +274,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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ 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
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
@@ -190,7 +190,9 @@ 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,
|
||||
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
+20
-5
@@ -47,26 +47,41 @@
|
||||
<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">
|
||||
@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>}
|
||||
@@ -87,6 +102,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"><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}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
</section>}
|
||||
}
|
||||
@@ -16,6 +16,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;}
|
||||
|
||||
+55
@@ -22,6 +22,7 @@ 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();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
providers: [
|
||||
@@ -32,6 +33,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');
|
||||
|
||||
@@ -25,6 +25,8 @@ export class AutomationPanelComponent {
|
||||
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,10 +39,11 @@ 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); });
|
||||
@@ -104,13 +107,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();
|
||||
|
||||
+11
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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("重启失败");
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
|
||||
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`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` 和构建,成功后才记录指纹。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。
|
||||
|
||||
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
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 +36,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 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
+65
-27
@@ -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,6 +33,35 @@ function Get-LaunchText {
|
||||
return $English
|
||||
}
|
||||
|
||||
# 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 ''
|
||||
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 '======================================================'
|
||||
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
|
||||
@@ -40,28 +71,18 @@ $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 'download_config.ps1')
|
||||
try {
|
||||
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
|
||||
@@ -126,12 +147,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +397,7 @@ if (-not $SkipMcpSync) {
|
||||
} 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
|
||||
}
|
||||
@@ -420,7 +452,7 @@ if ($missingCore.Count -gt 0) {
|
||||
}
|
||||
|
||||
# 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 +544,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.
|
||||
|
||||
@@ -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:
|
||||
@@ -406,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:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
[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 '升级会终止本项目的 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')) {
|
||||
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
|
||||
}
|
||||
exit $code
|
||||
}
|
||||
|
||||
try {
|
||||
Set-Location -LiteralPath $PSScriptRoot
|
||||
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
|
||||
if (-not (Test-Path -LiteralPath $python)) { throw '尚未安装项目 Python 环境;请先运行 start.bat 或 start-cn.bat。' }
|
||||
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
|
||||
& $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
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
||||
|
||||
当前仅提供 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 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
||||
|
||||
## 从需求到执行
|
||||
@@ -28,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
@@ -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 仅启动网页,跳过 Codex MCP 安装和更新
|
||||
echo -Help 显示本说明
|
||||
echo.
|
||||
echo 国内镜像设置:config\downloads.json
|
||||
echo 首次运行需联网;后续运行会检查源码、依赖,并同步 Codex MCP 配置。
|
||||
endlocal & exit /b 0
|
||||
call "%~dp0start.bat" -Mirror China -Help
|
||||
set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
|
||||
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
|
||||
@@ -15,15 +15,10 @@ 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 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"] == (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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") == ""
|
||||
|
||||
|
||||
@@ -153,12 +153,22 @@ def test_chinese_batch_prints_complete_lines_and_preserves_exit_code(tmp_path, e
|
||||
root = tmp_path / "source with spaces!"
|
||||
root.mkdir()
|
||||
shutil.copy2(ROOT / "start-cn.bat", root / "start-cn.bat")
|
||||
(root / "start.bat").write_bytes(
|
||||
f'@echo off\r\nif not "%~1"=="-Mirror" exit /b 19\r\n'
|
||||
f'if not "%~2"=="China" exit /b 20\r\nexit /b {exit_code}\r\n'.encode("ascii")
|
||||
shutil.copy2(ROOT / "start.bat", root / "start.bat")
|
||||
(root / "scripts").mkdir()
|
||||
# Exercise the real Chinese banner/help prologue, stopping before installs,
|
||||
# update checks or downloads. Chinese now belongs to PowerShell, not CMD.
|
||||
prologue = (
|
||||
(ROOT / "scripts/start.ps1")
|
||||
.read_text(encoding="utf-8-sig")
|
||||
.split("# Ensure modern TLS", 1)[0]
|
||||
)
|
||||
(root / "scripts/start.ps1").write_text(
|
||||
prologue + f"\nif ($Mirror -ne 'China') {{ exit 20 }}\nexit {exit_code}\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat")],
|
||||
env={**os.environ, "ARTEMIS_NO_PAUSE": "1"},
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Verify frozen upgrades and process ownership in disposable Windows installations."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
|
||||
reason="Windows PowerShell and uv required",
|
||||
)
|
||||
|
||||
|
||||
def powershell(script, *, env=None, cwd=None):
|
||||
return subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
||||
cwd=cwd,
|
||||
env={**os.environ, **(env or {})},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
return (
|
||||
subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True, timeout=15)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def make_venv(root):
|
||||
subprocess.run(
|
||||
["uv", "venv", "--offline", "--python", sys.executable, str(root / ".venv")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
return root / ".venv/Scripts/python.exe"
|
||||
|
||||
|
||||
def test_process_selection_is_scoped_and_includes_descendants(tmp_path):
|
||||
root = tmp_path / "project"
|
||||
|
||||
def process(pid, executable, command, name="python.exe", parent=0):
|
||||
return dict(
|
||||
ProcessId=pid,
|
||||
ParentProcessId=parent,
|
||||
ExecutablePath=executable,
|
||||
CommandLine=command,
|
||||
Name=name,
|
||||
)
|
||||
|
||||
processes = [
|
||||
process(10, str(root / ".venv/Scripts/python.exe"), "python -m artemis ui"),
|
||||
process(11, "C:\\Python\\python.exe", "python -m multiprocessing.spawn", parent=10),
|
||||
process(12, "C:\\tools\\ffmpeg.exe", "ffmpeg recording", "ffmpeg.exe", parent=11),
|
||||
process(13, "C:\\tools\\adb.exe", "adb server", "adb.exe", parent=10),
|
||||
process(14, "C:\\apps\\codex.exe", "codex", "codex.exe", parent=10),
|
||||
process(
|
||||
20,
|
||||
str(root.with_name("project-other") / ".venv/Scripts/python.exe"),
|
||||
"python -m artemis ui",
|
||||
),
|
||||
process(21, "C:\\Python\\python.exe", "python -m artemis ui"),
|
||||
process(22, "C:\\Python\\python.exe", f'python "{root}/mcp_server/__main__.py"'),
|
||||
process(23, "C:\\Python\\python.exe", f'python "{root}/unrelated.py"'),
|
||||
process(24, str(root / ".venv/Scripts/python.exe"), "protected updater"),
|
||||
process(25, "C:\\tools\\emulator.exe", "emulator", "emulator.exe", parent=10),
|
||||
]
|
||||
fixture = tmp_path / "processes.json"
|
||||
fixture.write_text(json.dumps(processes), encoding="utf-8")
|
||||
script = tmp_path / "select.ps1"
|
||||
script.write_text(
|
||||
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
|
||||
f". '{ROOT / 'scripts/upgrade_processes.ps1'}'\n"
|
||||
"$snapshot = Get-Content -Raw -LiteralPath $env:PROCESS_FIXTURE | ConvertFrom-Json\n"
|
||||
"$ids = @(Get-ArtemisUpgradeProcesses -ProjectRoot $env:PROJECT_ROOT -Processes $snapshot -ProtectedIds @(24) | ForEach-Object { [int]$_.ProcessId } | Sort-Object)\n"
|
||||
"ConvertTo-Json -InputObject $ids -Compress\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
result = powershell(script, env={"PROCESS_FIXTURE": str(fixture), "PROJECT_ROOT": str(root)})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert json.loads(result.stdout) == [10, 11, 12, 22]
|
||||
|
||||
|
||||
def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path):
|
||||
import psutil
|
||||
|
||||
root = tmp_path / "project with spaces!"
|
||||
root.mkdir()
|
||||
python = make_venv(root)
|
||||
pid_file = root / "child.pid"
|
||||
sleeper = root / "sleeper.py"
|
||||
sleeper.write_text(
|
||||
"import pathlib, subprocess, sys, time\n"
|
||||
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||
"pathlib.Path(sys.argv[1]).write_text(str(child.pid))\n"
|
||||
"time.sleep(60)\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
project = subprocess.Popen([str(python), str(sleeper), str(pid_file)])
|
||||
unrelated = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
|
||||
child_pid = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
if pid_file.exists():
|
||||
child_pid = int(pid_file.read_text())
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert child_pid is not None
|
||||
script = tmp_path / "stop.ps1"
|
||||
script.write_text(
|
||||
"$ErrorActionPreference = 'Stop'\n"
|
||||
f". '{ROOT / 'scripts/upgrade_processes.ps1'}'\n"
|
||||
"Stop-ArtemisUpgradeProcesses -ProjectRoot $env:PROJECT_ROOT\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
result = powershell(script, env={"PROJECT_ROOT": str(root)})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
project.wait(timeout=5)
|
||||
assert unrelated.poll() is None
|
||||
assert not psutil.pid_exists(child_pid)
|
||||
finally:
|
||||
for process in (project, unrelated):
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if child_pid and psutil.pid_exists(child_pid):
|
||||
psutil.Process(child_pid).kill()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare_exit", [0, 7])
|
||||
def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
|
||||
tmp_path, prepare_exit
|
||||
):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
author = tmp_path / "author"
|
||||
(author / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name)
|
||||
for name in ("update.bat", "update-cn.bat"):
|
||||
shutil.copyfile(ROOT / name, author / name)
|
||||
(author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n")
|
||||
(author / "scripts/start.ps1").write_text("exit 23\n", encoding="ascii")
|
||||
git(author, "init", "-b", "main")
|
||||
git(author, "config", "user.name", "Fixture")
|
||||
git(author, "config", "user.email", "fixture@example.test")
|
||||
git(author, "add", ".")
|
||||
git(author, "commit", "-m", "old version")
|
||||
installed = tmp_path / "installed with spaces!"
|
||||
git(tmp_path, "clone", "-b", "main", str(author), str(installed))
|
||||
make_venv(installed)
|
||||
(installed / ".env").write_text("secret stays local")
|
||||
# A running file AND both BAT wrappers are replaced during the merge.
|
||||
(author / "scripts/upgrade.ps1").write_text("throw 'new updater must not run in this turn'\n")
|
||||
(author / "scripts/upgrade_processes.ps1").write_text(
|
||||
"throw 'new process helper must not run'\n"
|
||||
)
|
||||
(author / "update.bat").write_text("@echo off\nexit /b 33\n")
|
||||
(author / "update-cn.bat").write_text("@echo off\nexit /b 34\n")
|
||||
(author / "scripts/start.ps1").write_text(
|
||||
"param([switch]$PrepareOnly, [switch]$SkipUpdate, [switch]$NoOpen, [string]$Mirror)\n"
|
||||
"if (-not ($PrepareOnly -and $SkipUpdate -and $NoOpen) -or $Mirror -ne 'China' -or $env:ARTEMIS_UPGRADE_CHILD -ne '1') { exit 35 }\n"
|
||||
"Set-Content -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) 'prepared.txt') -Value 'new version prepared'\n"
|
||||
f"exit {prepare_exit}\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
git(author, "add", ".")
|
||||
git(author, "commit", "-m", "new version")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "update-cn.bat"],
|
||||
cwd=installed,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert (installed / "prepared.txt").read_text().strip() == "new version prepared"
|
||||
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
|
||||
assert (installed / ".env").read_text() == "secret stays local"
|
||||
assert ("升级完成" in result.stdout) == (prepare_exit == 0)
|
||||
assert "new updater must not run" not in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"])
|
||||
@pytest.mark.parametrize("help_flag", ["-Help", "--help"])
|
||||
def test_upgrade_help_is_safe_and_wrappers_are_ascii(name, help_flag):
|
||||
(ROOT / name).read_bytes().decode("ascii")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", name, help_flag],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert "升级会终止本项目" in result.stdout
|
||||
|
||||
|
||||
def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(tmp_path):
|
||||
holder_script = tmp_path / "holder.ps1"
|
||||
holder_script.write_text(
|
||||
f". '{ROOT / 'scripts/installation_lock.ps1'}'\n"
|
||||
"$lock = Enter-ArtemisInstallationLock -ProjectRoot $env:PROJECT_ROOT\n"
|
||||
"Set-Content -LiteralPath $env:READY_FILE -Value 'ready'\n"
|
||||
"Start-Sleep -Seconds 60\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
ready = tmp_path / "ready.txt"
|
||||
root = tmp_path / "project"
|
||||
holder = subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-File", str(holder_script)],
|
||||
env={**os.environ, "PROJECT_ROOT": str(root), "READY_FILE": str(ready)},
|
||||
)
|
||||
contender = tmp_path / "contender.ps1"
|
||||
contender.write_text(
|
||||
f". '{ROOT / 'scripts/installation_lock.ps1'}'\n"
|
||||
"try {\n"
|
||||
" $lock = Enter-ArtemisInstallationLock -ProjectRoot $env:PROJECT_ROOT\n"
|
||||
" Exit-ArtemisInstallationLock -Mutex $lock\n"
|
||||
" exit 0\n"
|
||||
"} catch { Write-Host 'LOCK_BUSY'; exit 37 }\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if ready.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert ready.exists()
|
||||
blocked = powershell(contender, env={"PROJECT_ROOT": str(root)})
|
||||
assert blocked.returncode == 37, blocked.stdout + blocked.stderr
|
||||
assert (
|
||||
powershell(contender, env={"PROJECT_ROOT": str(root.with_name("other"))}).returncode
|
||||
== 0
|
||||
)
|
||||
holder.kill()
|
||||
holder.wait(timeout=5)
|
||||
assert powershell(contender, env={"PROJECT_ROOT": str(root)}).returncode == 0
|
||||
finally:
|
||||
if holder.poll() is None:
|
||||
holder.kill()
|
||||
holder.wait(timeout=5)
|
||||
|
||||
|
||||
def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path):
|
||||
root = tmp_path / "prepared project"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in ("start.ps1", "installation_lock.ps1"):
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / "scripts/download_config.ps1").write_text(
|
||||
"function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / "scripts/bootstrap_python.ps1").write_text(
|
||||
"function Initialize-ArtemisPython { return $env:TEST_PYTHON }\n", encoding="ascii"
|
||||
)
|
||||
(root / "scripts/update_installation.py").write_text(
|
||||
"import pathlib, sys\n"
|
||||
"root = pathlib.Path(__file__).parent.parent\n"
|
||||
"with (root / 'checks.txt').open('a') as f: f.write(sys.argv[1] + '\\n')\n"
|
||||
"sys.exit(0)\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / ".env").write_text("keep credentials")
|
||||
index = root / "apps/showcase_ui/dist/frontend/browser/index.html"
|
||||
index.parent.mkdir(parents=True)
|
||||
index.write_text("built frontend")
|
||||
tools = root / "stub-tools"
|
||||
tools.mkdir()
|
||||
for name in ("adb", "scrcpy", "ffmpeg"):
|
||||
(tools / f"{name}.cmd").write_text("@echo off\nexit /b 91\n")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(root / "scripts/start.ps1"),
|
||||
"-PrepareOnly",
|
||||
"-SkipUpdate",
|
||||
"-NoOpen",
|
||||
],
|
||||
env={
|
||||
**os.environ,
|
||||
"TEST_PYTHON": sys.executable,
|
||||
"PATH": str(tools) + os.pathsep + os.environ["PATH"],
|
||||
},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (root / "checks.txt").read_text().splitlines() == ["mcp", "frontend-check"]
|
||||
assert "Launching Artemis" not in result.stdout
|
||||
assert "ADB" not in result.stderr
|
||||
assert (root / ".env").read_text() == "keep credentials"
|
||||
|
||||
|
||||
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
root = tmp_path / "dirty installation"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / ".gitignore").write_text(".venv/\n")
|
||||
source = root / "user.txt"
|
||||
source.write_text("original")
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.name", "Fixture")
|
||||
git(root, "config", "user.email", "fixture@example.test")
|
||||
git(root, "add", ".")
|
||||
git(root, "commit", "-m", "initial")
|
||||
python = make_venv(root)
|
||||
source.write_text("user changes")
|
||||
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
|
||||
try:
|
||||
result = powershell(root / "scripts/upgrade.ps1")
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "发现本地修改" in result.stdout
|
||||
assert "[STOP]" not in result.stdout
|
||||
assert worker.poll() is None
|
||||
assert source.read_text() == "user changes"
|
||||
finally:
|
||||
# Windows venv redirectors can have a second interpreter process.
|
||||
import psutil
|
||||
|
||||
if worker.poll() is None:
|
||||
children = psutil.Process(worker.pid).children(recursive=True)
|
||||
worker.kill()
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
worker.wait(timeout=5)
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
chcp 65001 >nul 2>&1
|
||||
setlocal DisableDelayedExpansion
|
||||
set "ARTEMIS_LAUNCH_LANG=zh-CN"
|
||||
if /i "%~1"=="--help" goto help
|
||||
call "%~dp0update.bat" -Mirror China %* & call exit /b %%ERRORLEVEL%%
|
||||
:help
|
||||
call "%~dp0update.bat" -Mirror China -Help & call exit /b %%ERRORLEVEL%%
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
chcp 65001 >nul 2>&1
|
||||
setlocal DisableDelayedExpansion
|
||||
if /i "%~1"=="--help" goto help
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\upgrade.ps1" %* & call exit /b %%ERRORLEVEL%%
|
||||
:help
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\upgrade.ps1" -Help & call exit /b %%ERRORLEVEL%%
|
||||
Reference in new issue
Block a user