Compare commits

..
4 Commits
59 changed files with 2071 additions and 186 deletions

No files matched your search

+5
View File
@@ -62,6 +62,10 @@ OCR_API_KEY=
ADB_HOST=127.0.0.1
ADB_PORT=5037
# ADB_DEVICE_SERIAL=
# 自动发现本机模拟器,并重连通过网页连接过的 TCP 设备;不扫描局域网。
ARTEMIS_ADB_AUTO_CONNECT=true
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS=10
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS=5
ARTEMIS_KEEP_DEVICE_AWAKE=true
ARTEMIS_HIERARCHY_BACKEND=auto
ARTEMIS_HELPER_AUTO_INSTALL=true
@@ -69,4 +73,5 @@ ARTEMIS_DEFAULT_PROFILE=flash
# 批量任务与 Excel 导入限制
ARTEMIS_IMPORT_MAX_ROWS=1000
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
ARTEMIS_IMPORT_MAX_BYTES=10485760
+2 -1
View File
@@ -27,7 +27,7 @@
| 核心类型检查 | `uv run pyright --project pyright-core.json` |
| 源码打包 | `uv run --no-sync python scripts/package_source.py` |
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开发中需要保留当前源码时传入 `-SkipUpdate`,避免启动脚本拉取远端更新。
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启动只提示源码更新;升级使用 `update.bat` / `update-cn.bat`,会终止本项目运行进程。开发中可传入 `-SkipUpdate` 跳过更新提示。
## Key Paths
@@ -58,6 +58,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
- `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。
## Definition of Done
+6 -4
View File
@@ -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)
+8 -1
View File
@@ -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}
+5 -3
View File
@@ -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"]
+6 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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",
@@ -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;}
@@ -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();
@@ -4,6 +4,9 @@ import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
interface Capabilities {
adb_auto_connect: boolean;
adb_discovery_interval_seconds: number;
adb_discovery_timeout_seconds: number;
video_enabled: boolean;
recording_enabled: boolean;
outputter_enabled: boolean;
@@ -24,6 +27,8 @@ interface Capabilities {
<section aria-label="功能开关">
<h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -44,6 +49,8 @@ interface Capabilities {
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
<label>设备自动发现间隔(秒)<input type="number" min="1" max="300" [(ngModel)]="config.adb_discovery_interval_seconds" [disabled]="busy() || !loaded()" /></label>
<label>每轮自动连接预算(秒)<input type="number" min="1" max="30" [(ngModel)]="config.adb_discovery_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
</div>
</details>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
@@ -67,7 +74,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -89,6 +96,8 @@ export class CapabilitySettingsComponent implements OnInit {
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300;
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300
&& c.adb_discovery_interval_seconds >= 1 && c.adb_discovery_interval_seconds <= 300
&& c.adb_discovery_timeout_seconds > 0 && c.adb_discovery_timeout_seconds <= 30;
}
}
@@ -138,7 +138,7 @@ describe('持久化模型与功能设置', () => {
});
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
@@ -162,7 +162,7 @@ describe('持久化模型与功能设置', () => {
});
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
const capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
@@ -190,4 +190,26 @@ describe('持久化模型与功能设置', () => {
fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings');
});
it('自动连接默认开启,可独立关闭并保存发现预算', () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
expect(root.textContent).toContain('自动连接设备');
expect(fixture.componentInstance.config.adb_auto_connect).toBeTrue();
fixture.componentInstance.config.adb_auto_connect = false;
fixture.componentInstance.config.adb_discovery_timeout_seconds = 8;
fixture.componentInstance.save();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.adb_auto_connect).toBeFalse();
expect(request.request.body.adb_discovery_timeout_seconds).toBe(8);
expect(request.request.body.recording_enabled).toBeFalse();
request.flush(request.request.body);
fixture.componentInstance.config.adb_discovery_timeout_seconds = 0;
fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings');
});
});
@@ -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'
})
+4
View File
@@ -124,6 +124,7 @@ class Settings(BaseSettings):
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
ARTEMIS_BATCH_MAX_EXECUTIONS: int = Field(default=10000, ge=1)
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
@@ -134,6 +135,9 @@ class Settings(BaseSettings):
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
ADB_DEVICE_SERIAL: str | None = None
ARTEMIS_ADB_AUTO_CONNECT: bool = True
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS: float = Field(default=10, ge=1, le=300)
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=5, gt=0, le=30)
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
+9 -1
View File
@@ -21,6 +21,7 @@ import time
from typing import Any
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
from artemis.core.diagnostics.probes.base import BaseProbe
from artemis.core.diagnostics.probes.credentials_probe import (
@@ -353,7 +354,12 @@ class ReadinessEngine:
}
logger.info("[ReadinessEngine] Restarting ADB server...")
return await asyncio.to_thread(_restart_sync)
result = await asyncio.to_thread(_restart_sync)
if result.get("success"):
result["automatic_connections"] = await asyncio.to_thread(
adb_device_discovery.refresh, adb_path, force=True
)
return result
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
@@ -417,8 +423,10 @@ class ReadinessEngine:
)
output = (res.stdout + "\n" + res.stderr).strip()
success = "connected to" in output.lower() and "failed" not in output.lower()
remembered = adb_device_discovery.remember(target) if success else False
return {
"success": success,
"remembered": remembered,
"target": target,
"output": output,
"message": f"Connected to {target}" if success else output,
@@ -33,6 +33,7 @@ from artemis.core.diagnostics.schema import (
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -477,6 +478,7 @@ class AdbDeviceProbe(BaseProbe):
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
await asyncio.to_thread(adb_device_discovery.refresh, adb_path)
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
@@ -668,6 +670,7 @@ class AdbDeviceProbe(BaseProbe):
"installed": True,
"adb_path": adb_path,
"adb_version": adb_ver,
"automatic_connections": adb_device_discovery.results.copy(),
"adb_keys": key_status.to_dict(),
"emulator_path": emulator_path,
"is_emulator_in_path": is_emu_in_path,
+2 -1
View File
@@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.platform import platform
from artemis.runtime.adb_endpoint import AdbEndpoint
from artemis.utils.app_launch_utils import launch_app_with_retries
@@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None):
port_str = os.environ.get("ADB_PORT", "5037")
port = int(port_str) if port_str.isdigit() else 5037
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket
adb = AdbClient(host=host, port=port)
devices = adb.device_list()
+285
View File
@@ -0,0 +1,285 @@
"""Bounded TCP device discovery shared by the console, CLI and MCP.
USB and Android SDK emulators are discovered by ADB itself. Third-party
emulators expose TCP adbd listeners; only listeners owned by known emulator
processes are probed. Explicitly connected TCP devices are remembered separately.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import socket
import struct
import subprocess
import tempfile
import threading
import time
import psutil
from artemis.config import settings
from artemis.config.paths import get_app_dir
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def _canonical_target(target: str) -> str:
host, separator, port = target.rpartition(":")
if separator and host.lower() in {"localhost", "127.0.0.1"}:
return f"127.0.0.1:{port}"
return target
def emulator_listeners() -> list[list[str]]:
"""Return listener groups, one per running emulator (never scan a network)."""
owners = set()
for process in psutil.process_iter(["pid", "name", "exe"]):
info = process.info
name = str(info.get("name") or "").lower().removesuffix(".exe")
path = str(info.get("exe") or "").lower()
known = name in {
"mumunxdevice",
"nemuheadless",
"mumuheadless",
"ldvboxheadless",
"noxvmhandle",
"memuheadless",
"hd-player",
}
vendor_vm = name == "vboxheadless" and any(
vendor in path for vendor in ("mumu", "nemu", "ldplayer", "leidian", "nox", "memu")
)
if known or vendor_vm:
owners.add(info["pid"])
if not owners:
return []
groups: dict[int, set[int]] = {}
for connection in psutil.net_connections(kind="tcp"):
address = connection.laddr
pid = connection.pid
if (
pid is not None
and pid in owners
and address
and connection.status == psutil.CONN_LISTEN
and address.ip in {"127.0.0.1", "0.0.0.0"}
):
groups.setdefault(pid, set()).add(address.port)
# Prefer current MuMu ports over its compatibility aliases (same adbd).
return [
[f"127.0.0.1:{port}" for port in sorted(ports, key=lambda p: (p in {5555, 7555}, p))]
for ports in groups.values()
]
def is_adbd(target: str, timeout: float) -> bool:
"""Confirm an ADB CNXN/AUTH header before giving a local port to adb connect."""
host, port = target.rsplit(":", 1)
payload = b"host::\x00"
command = int.from_bytes(b"CNXN", "little")
packet = struct.pack(
"<6I", command, 0x01000000, 4096, len(payload), sum(payload), command ^ 0xFFFFFFFF
)
try:
with socket.create_connection((host, int(port)), timeout=timeout) as stream:
stream.sendall(packet + payload)
header = b""
deadline = time.monotonic() + timeout
while len(header) < 24:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
stream.settimeout(remaining)
chunk = stream.recv(24 - len(header))
if not chunk:
return False
header += chunk
kind, _, _, length, _, magic = struct.unpack("<6I", header)
return (
kind.to_bytes(4, "little") in {b"CNXN", b"AUTH"}
and magic == kind ^ 0xFFFFFFFF
and length <= 1048576
)
except (OSError, ValueError):
return False
class AdbDeviceDiscovery:
"""Coalesce discovery and reconnect only missing transports, with a total budget."""
def __init__(self):
self._gate = threading.Lock()
self._last_scan = float("-inf")
self.results: list[dict[str, object]] = []
self._cursor = 0
@staticmethod
def _directory() -> Path:
return get_app_dir() / "adb-devices"
def remember(self, target: str) -> bool:
"""Save an explicitly connected device, without credentials or shared-list races."""
directory = self._directory()
temporary = None
try:
directory.mkdir(parents=True, exist_ok=True)
destination = directory / (hashlib.sha256(target.encode()).hexdigest() + ".json")
fd, temporary = tempfile.mkstemp(dir=directory, prefix=".device-")
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump({"target": target}, stream)
os.replace(temporary, destination)
return True
except OSError as exc:
logger.warning(f"Cannot remember ADB device {target}: {exc}")
return False
finally:
if temporary:
Path(temporary).unlink(missing_ok=True)
def _remembered(self) -> list[str]:
targets = []
for path in self._directory().glob("*.json"):
try:
target = json.loads(path.read_text(encoding="utf-8"))["target"]
host, port = target.rsplit(":", 1)
if host and 0 < int(port) < 65536:
targets.append(target)
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
logger.warning(f"Invalid remembered ADB device {path.name}: {exc}")
return targets
def refresh(self, adb_path: str, *, force: bool = False) -> list[dict[str, object]]:
if not settings.ARTEMIS_ADB_AUTO_CONNECT or not current_adb_endpoint().is_local_default:
self.results = []
return []
if not self._gate.acquire(blocking=False):
return self.results.copy()
try:
if (
not force
and time.monotonic() - self._last_scan
< settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS
):
return self.results.copy()
self._last_scan = time.monotonic()
self.results = []
self._refresh(adb_path)
except (OSError, psutil.Error, ValueError, subprocess.SubprocessError) as exc:
logger.warning(f"ADB automatic discovery unavailable: {exc}")
self.results.append({"success": False, "message": str(exc)})
finally:
self._gate.release()
return self.results.copy()
def _refresh(self, adb_path: str) -> None:
session = AdbSession(current_adb_endpoint(), adb_path=adb_path)
deadline = time.monotonic() + settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS
devices = session.run(
["devices"],
capture_output=True,
text=True,
timeout=max(deadline - time.monotonic(), 0.01),
)
if devices.returncode:
return
ready = {
_canonical_target(parts[0])
for line in devices.stdout.splitlines()
if len(parts := line.split()) >= 2 and parts[1] in {"device", "unauthorized"}
}
try:
groups = emulator_listeners()
except (OSError, psutil.Error) as exc:
# Missing process-inspection permissions must not stop reconnection
# of explicitly configured phones or make device enumeration fail.
groups = []
logger.warning(f"Cannot inspect local emulator listeners: {exc}")
self.results.append({"success": False, "message": str(exc)})
remembered = self._remembered()
targets = [(group, True) for group in groups] + [([target], False) for target in remembered]
# Resume at a different group if a slow/offline peer exhausts the budget.
# Large multi-instance installations must not starve later devices.
count = len(targets)
start = self._cursor % count if count else 0
for offset in range(count):
index = (start + offset) % count
candidates, probe = targets[index]
self._cursor = (index + 1) % count
if any(_canonical_target(target) in ready for target in candidates):
continue
for target in candidates:
remaining = deadline - time.monotonic()
if remaining <= 0:
return
if probe and not is_adbd(target, min(remaining, 0.3)):
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
return
try:
result = session.run(
["connect", target],
capture_output=True,
text=True,
timeout=min(remaining, 2),
)
except subprocess.TimeoutExpired:
self.results.append(
{"target": target, "success": False, "message": "ADB 连接超时"}
)
continue
output = (result.stdout + result.stderr).strip()
success = (
result.returncode == 0
and "connected to" in output.lower()
and "failed" not in output.lower()
)
state = self._wait_for_transport(session, target, deadline) if success else None
pending = success and state is None
if success and state is None:
success = False
output = f"{output};已发起连接,等待设备就绪,下次刷新会重试"
self.results.append(
{
"target": target,
"success": success,
"state": state,
"pending": pending,
"message": output,
}
)
if success:
logger.info(f"ADB 自动连接:{target} — {output}")
ready.add(_canonical_target(target))
break
if pending:
logger.info(f"ADB 设备连接中:{target} — {output}")
break # Do not create a second alias while the first transport is settling.
logger.warning(f"ADB 自动连接失败:{target} — {output}")
@staticmethod
def _wait_for_transport(session: AdbSession, target: str, deadline: float) -> str | None:
"""adb connect can return before its transport appears in adb devices."""
while (remaining := deadline - time.monotonic()) > 0:
try:
result = session.run(["devices"], capture_output=True, text=True, timeout=remaining)
except subprocess.TimeoutExpired:
return None
for line in result.stdout.splitlines():
parts = line.split()
if (
len(parts) >= 2
and _canonical_target(parts[0]) == _canonical_target(target)
and parts[1] in {"device", "unauthorized"}
):
return parts[1]
time.sleep(min(0.1, max(deadline - time.monotonic(), 0)))
return None
adb_device_discovery = AdbDeviceDiscovery()
+15 -2
View File
@@ -92,6 +92,19 @@ class AdbEndpoint:
def mode(self) -> str:
return "local" if self.is_local_default else "remote"
@property
def client_host(self) -> str:
"""ADB only permits daemon auto-start for the literal host 'localhost'.
Keep forwarded/custom-port endpoints explicit, even on loopback, so a
missing tunnel never starts a different local daemon at that address.
"""
return "localhost" if self.is_local_default else self.host
@property
def client_socket(self) -> str:
return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket
def to_dict(self) -> dict[str, Any]:
return {
**asdict(self),
@@ -108,7 +121,7 @@ class AdbEndpoint:
target = environment if environment is not None else os.environ
target["ADB_HOST"] = self.host
target["ADB_PORT"] = str(self.port)
target["ADB_SERVER_SOCKET"] = self.socket
target["ADB_SERVER_SOCKET"] = self.client_socket
target[ADB_ENDPOINT_ID_ENV] = self.identity
return target
@@ -145,7 +158,7 @@ class AdbSession:
return [
self.adb_path,
"-H",
self.endpoint.host,
self.endpoint.client_host,
"-P",
str(self.endpoint.port),
*arguments,
+17 -7
View File
@@ -25,6 +25,8 @@ import threading
import time
from typing import Any
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.runtime.adb_discovery import adb_device_discovery
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
@@ -107,6 +109,7 @@ class DevicePool:
adb = self._resolve_adb()
if not adb:
return None
adb_device_discovery.refresh(adb)
try:
res = subprocess.run(
[adb, "devices", "-l"],
@@ -131,6 +134,7 @@ class DevicePool:
adb = self._resolve_adb()
if not adb:
return None
await asyncio.to_thread(adb_device_discovery.refresh, adb)
proc = None
try:
proc = await asyncio.create_subprocess_exec(
@@ -242,23 +246,29 @@ class DevicePool:
async def _start_adb_server(self, timeout: float) -> None:
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
endpoint = current_adb_endpoint()
if not endpoint.is_local_default:
return
adb = self._resolve_adb()
if not adb:
return
proc = None
try:
clean_env = os.environ.copy()
clean_env.pop("ADB_SERVER_SOCKET", None)
session = AdbSession(endpoint, adb_path=adb)
proc = await asyncio.create_subprocess_exec(
adb,
"start-server",
*session.command(["start-server"]),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=clean_env,
env=session.environment(),
)
await asyncio.wait_for(proc.communicate(), timeout=timeout)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if proc.returncode != 0:
output = (stderr or stdout).decode(errors="replace").strip()
logger.warning(
f"Local ADB server startup failed (exit {proc.returncode}): {output}"
)
except Exception as exc:
logger.debug(f"adb start-server warm-up did not complete: {exc}")
logger.warning(f"Local ADB server startup did not complete: {exc}")
if proc is not None:
try:
proc.kill()
+2 -2
View File
@@ -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 进程。
+8 -2
View File
@@ -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 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
+13 -3
View File
@@ -27,6 +27,9 @@ MODEL_FIELDS = {
"reasoning_effort",
}
CAPABILITY_FIELDS = {
"adb_auto_connect",
"adb_discovery_interval_seconds",
"adb_discovery_timeout_seconds",
"video_enabled",
"recording_enabled",
"outputter_enabled",
@@ -147,7 +150,8 @@ async def mobile_config(
step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),
observation_timeout_seconds (60), observation_max_attempts (3),
device_command_timeout_seconds (10), action_failure_attempts (3),
worker_cleanup_timeout_seconds (30).
worker_cleanup_timeout_seconds (30), adb_auto_connect (true),
adb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).
reports: formats=['docx','xlsx'] (at least one).
API keys are never accepted or returned: use the Web UI or .env for credentials.
Saving settings does not change an already running task. Flash/Pro are execution
@@ -221,13 +225,19 @@ async def mobile_batch(
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}.
repeat_count is a positive integer: execute all selected cases in order for each
round, with independent session IDs, statuses and reports. The default total limit
(cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS.
Only include user-selected cases; get returns iteration/repeat_count per job.
Required device
must be user-selected. Creation validates and saves a DRAFT; start executes it.
Reuse the same request_id and exact payload after a connection failure; never invent
a new ID for the same submission. lookup(request_id) resolves an uncertain outcome.
get returns per-case status. pause stops future dispatch, not the active task;
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
retry_failed creates a new draft only after an explicit rerun request; it is not
retry_failed copies each failed execution once into a new draft (repeat_count=1)
only after an explicit rerun request; it is not
transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch and its task sessions, reports and files after
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
+2
View File
@@ -115,6 +115,8 @@ def _render_check(result: ProbeResult) -> dict[str, Any]:
"summary": result.summary,
}
if result.status is ProbeStatus.PASS:
if result.id == "android_adb" and result.metadata.get("automatic_connections"):
check["automatic_connections"] = _scrub(result.metadata["automatic_connections"])
return check
check["category"] = result.category.value
check["detail"] = result.description
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "1.3.2"
version = "1.4.0"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+17 -6
View File
@@ -1,4 +1,4 @@
# Copyright 2026 Google LLC
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -91,12 +91,23 @@ function Update-EnvironmentPath {
function Start-LocalAdbServer {
if (Test-CommandExists "adb") {
$adbErrorPreference = $ErrorActionPreference
try {
$origSocket = $env:ADB_SERVER_SOCKET
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
& adb start-server 2>$null | Out-Null
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
} catch {}
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
# ADB prints normal daemon-start messages there, so inspect its exit code.
$ErrorActionPreference = "Continue"
# Explicit options ignore inherited remote-server environment settings.
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
}
} catch {
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $adbErrorPreference
}
}
}
+20
View File
@@ -0,0 +1,20 @@
# Shared preparation/upgrade mutex. ASCII for Windows PowerShell 5.1.
function Enter-ArtemisInstallationLock {
param([string]$ProjectRoot)
$normalized = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\').ToLowerInvariant()
$hash = [Security.Cryptography.SHA256]::Create()
try { $digest = [BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($normalized))).Replace('-', '') }
finally { $hash.Dispose() }
$mutex = New-Object Threading.Mutex($false, "Local\ArtemisInstallation-$digest")
try {
try { $entered = $mutex.WaitOne(0) }
catch [Threading.AbandonedMutexException] { $entered = $true }
if (-not $entered) { throw 'Another startup preparation or upgrade is running. Please wait and retry.' }
return $mutex
} catch { $mutex.Dispose(); throw }
}
function Exit-ArtemisInstallationLock {
param($Mutex)
if ($null -ne $Mutex) { $Mutex.ReleaseMutex(); $Mutex.Dispose() }
}
+65 -27
View File
@@ -17,8 +17,10 @@ param(
[int]$Port = 8000,
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$PrepareOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[switch]$Help = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
@@ -31,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.
+40 -42
View File
@@ -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:
+84
View File
@@ -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
}
+65
View File
@@ -0,0 +1,65 @@
# Process ownership is determined by checkout paths, never by a port or name alone.
function Get-ArtemisUpgradeProcesses {
param([string]$ProjectRoot, [object[]]$Processes, [int[]]$ProtectedIds = @())
$root = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\') + '\'
$venv = $root + '.venv\'
$selected = @{}
foreach ($process in $Processes) {
if ($ProtectedIds -contains [int]$process.ProcessId) { continue }
$executable = [string]$process.ExecutablePath
$command = [string]$process.CommandLine
$python = $process.Name -match '^(python(w)?|artemis(-admin)?)\.exe$'
$inVenv = $executable.StartsWith($venv, [StringComparison]::OrdinalIgnoreCase)
$explicitRoot = $command.Replace('/', '\').IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0
$entrypoint = $command -match '(?i)(?:-m\s+(?:artemis(?:\s|\.|$)|apps\.admin_console(?:\.|\s)|mcp_server(?:\.|\s|$))|[\\/]artemis(?:\.exe|[\\/]__main__\.py)|[\\/]mcp_server[\\/])'
if (($python -and $inVenv) -or ($explicitRoot -and $entrypoint -and ($python -or $process.Name -eq 'uv.exe'))) {
$selected[[int]$process.ProcessId] = $process
}
}
do {
$added = $false
foreach ($process in $Processes) {
$id = [int]$process.ProcessId
if ($selected.ContainsKey($id) -or $ProtectedIds -contains $id) { continue }
# Shared device services and desktop applications can outlive a task.
if ($process.Name -match '^(adb|codex|chrome|msedge|firefox|emulator|MuMu.*|Nemu.*|Nox.*|MEmu.*|LdVBox.*|HD-Player)\.exe$') { continue }
if ($selected.ContainsKey([int]$process.ParentProcessId)) {
$selected[$id] = $process
$added = $true
}
}
} while ($added)
return @($selected.Values)
}
function Stop-ArtemisUpgradeProcesses {
param([string]$ProjectRoot)
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
$protected = @($PID)
$parentId = $PID
do {
$parent = $snapshot | Where-Object { [int]$_.ProcessId -eq $parentId } | Select-Object -First 1
$parentId = if ($parent) { [int]$parent.ParentProcessId } else { 0 }
if ($parentId -and $protected -notcontains $parentId) { $protected += $parentId } else { break }
} while ($true)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
$targets = @(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected)
if (-not $targets.Count) { return }
foreach ($target in $targets) {
$id = [int]$target.ProcessId
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop
if (-not $current -or $current.CreationDate -ne $target.CreationDate) { continue }
Write-Host "[STOP] ARTEMIS PID $id ($($target.Name))"
try { Stop-Process -Id $id -Force -ErrorAction Stop }
catch {
if (Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop) { throw }
}
}
Start-Sleep -Milliseconds 200
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
} while ([DateTime]::UtcNow -lt $deadline)
if (@(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected).Count) {
throw 'ARTEMIS processes are still running. Source/dependency updates were not started.'
}
}
+4
View File
@@ -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
View File
@@ -2,32 +2,16 @@
chcp 65001 >nul 2>&1
setlocal DisableDelayedExpansion
set "ARTEMIS_LAUNCH_LANG=zh-CN"
title ARTEMIS 国内镜像启动
title ARTEMIS
if /i "%~1"=="-Help" goto help
if /i "%~1"=="--help" goto help
echo ======================================================
echo ARTEMIS 中文启动 - 使用国内镜像
echo ======================================================
echo 正在检查已有环境、项目更新及依赖;首次启动可能需要下载组件。
echo 镜像地址和下载超时可在 config\downloads.json 中调整。
echo.
call "%~dp0start.bat" -Mirror China %*
set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_CN_EXIT_CODE%"=="0" echo 启动未完成;请查看上方错误信息,并检查网络或镜像设置。
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
:help
echo 用法:start-cn.bat [选项]
echo.
echo -SetupOnly 安装或检查环境,不启动网页
echo -Port 8001 使用指定端口,默认 8000
echo -NoOpen 启动服务后不自动打开浏览器
echo -SkipUpdate 跳过 Git 源码更新检查
echo -SkipMcpSync 仅启动网页,跳过 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%
+2 -7
View File
@@ -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
+14
View File
@@ -26,6 +26,20 @@ import pytest
from artemis.drivers.mock.mock_driver import MockDeviceDriver
@pytest.fixture(autouse=True)
def no_host_adb_discovery(monkeypatch, request):
"""Deterministic tests must never connect to host emulators or remembered phones."""
from artemis.runtime.adb_discovery import adb_device_discovery
if any(
request.node.get_closest_marker(name)
for name in ("android", "integration", "e2e", "manual")
):
return
monkeypatch.setattr(adb_device_discovery, "refresh", lambda *args, **kwargs: [])
monkeypatch.setattr(adb_device_discovery, "remember", lambda *args: True)
@pytest.fixture
def mock_driver():
"""Provide an isolated mock mobile driver."""
+101
View File
@@ -185,6 +185,107 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_repetitions_are_ordered_independent_and_idempotent(client, isolated):
request = payload(repeat_count=3, request_id="repeat-test")
created = client.post("/api/automation/batches", json=request)
assert created.status_code == 200
batch = created.json()
assert (batch["case_count"], batch["repeat_count"], batch["total_executions"]) == (2, 3, 6)
jobs = isolated.jobs(batch["id"])
assert len({j["session_id"] for j in jobs}) == 6
assert [j["position"] for j in jobs] == list(range(1, 7))
assert [(j["iteration"], j["case_position"]) for j in jobs] == [
(1, 1),
(1, 2),
(2, 1),
(2, 2),
(3, 1),
(3, 2),
]
assert [j["case"]["goal"] for j in jobs] == ["观察设置", "观察首页"] * 3
assert all(j["status"] == "pending" for j in jobs)
assert client.post("/api/automation/batches", json=request).json()["id"] == batch["id"]
assert len(isolated.jobs(batch["id"])) == 6
assert (
client.post("/api/automation/batches", json={**request, "repeat_count": 2}).status_code
== 409
)
# Each selected failure is copied once, including failures from different rounds.
for job in (jobs[0], jobs[4]):
isolated.save_job({**job, "status": "failed"})
retry = service.batch_action(batch["id"], "retry_failed")
assert retry["repeat_count"] == 1
assert len(isolated.jobs(retry["id"])) == 2
report = reports.session_report(jobs[4]["session_id"])
assert "第 3 / 3 轮" in report["name"]
archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z:
assert len([n for n in z.namelist() if n.endswith(".xlsx")]) == 7
assert len(set(z.namelist())) == len(z.namelist())
@pytest.mark.parametrize("count", [0, -1, 1.5, True, "3", None])
def test_batch_rejects_invalid_repetitions(client, isolated, count):
assert (
client.post("/api/automation/batches", json=payload(repeat_count=count)).status_code == 422
)
assert isolated.records("batch") == []
def test_batch_limit_is_checked_before_creating_records(client, isolated, monkeypatch):
monkeypatch.setattr(service.settings, "ARTEMIS_BATCH_MAX_EXECUTIONS", 5)
assert client.get("/api/automation/preferences").json()["batch_max_executions"] == 5
response = client.post("/api/automation/batches", json=payload(repeat_count=3))
assert response.status_code == 409
assert "5" in response.json()["detail"]
assert not isolated.records("batch")
assert not isolated.jobs()
@pytest.mark.asyncio
async def test_loop_dispatch_continues_after_failure_and_resume_without_repeating(
isolated, monkeypatch
):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(repeat_count=2))
service.batch_action(batch["id"], "resume")
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
for index in range(4):
await service.tick_batches()
await service.tick_batches() # Polling must not enqueue the same execution twice.
assert enqueue.await_count == index + 1
job = isolated.jobs(batch["id"])[index]
assert enqueue.call_args.kwargs["session_id"] == job["session_id"]
assert f"第 {job['iteration']} / 2 轮" in enqueue.call_args.kwargs["display_name"]
isolated.save_job({**job, "status": "failed" if index == 0 else "completed"})
if index == 1:
service.batch_action(batch["id"], "pause")
await service.tick_batches()
assert enqueue.await_count == 2
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "completed"
assert enqueue.await_count == 4
@pytest.mark.asyncio
async def test_repeat_fail_fast_skips_future_rounds(isolated):
batch = service.create_batch(payload(repeat_count=3, stop_on_failure=True))
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "failed"})
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed"] + ["skipped"] * 5
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
@@ -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"] == (
+90 -1
View File
@@ -70,6 +70,34 @@ def test_adb_session_builds_explicit_endpoint_command_and_environment():
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"])
def test_local_endpoint_allows_adb_auto_start_without_changing_task_identity(host):
endpoint = AdbServerEndpoint(host, 5037)
session = AdbSession(endpoint, adb_path="adb")
inherited = {
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
"ANDROID_ADB_SERVER_PORT": "5040",
}
assert session.command(["devices"]) == ["adb", "-H", "localhost", "-P", "5037", "devices"]
environment = session.environment(inherited)
assert environment["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
assert environment["ADB_HOST"] == host
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
assert inherited["ADB_SERVER_SOCKET"] == "tcp:remote.example:5040"
@pytest.mark.parametrize(
("host", "port"), [("127.0.0.1", 5038), ("::1", 5038), ("server.example", 5037)]
)
def test_forwarded_endpoint_never_uses_local_daemon_auto_start(host, port):
endpoint = AdbServerEndpoint(host, port)
session = AdbSession(endpoint, adb_path="adb")
assert session.command(["devices"])[2] == host
assert session.environment({})["ADB_SERVER_SOCKET"] == endpoint.socket
@pytest.mark.asyncio
async def test_connect_activates_forwarded_server_after_success(monkeypatch):
captured: dict[str, object] = {}
@@ -253,7 +281,7 @@ async def test_use_local_server_restores_standard_endpoint(monkeypatch):
assert result["success"] is True
assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict()
assert settings.ADB_PORT == 5037
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5037"
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
@pytest.mark.asyncio
@@ -282,6 +310,67 @@ async def test_remote_endpoint_blocks_local_wireless_connection(monkeypatch):
assert "Switch to local ADB" in result["message"]
@pytest.mark.asyncio
async def test_restart_reconnects_tcp_devices_after_server_started(monkeypatch):
from artemis.runtime.adb_discovery import adb_device_discovery
monkeypatch.setattr(
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
)
monkeypatch.setattr(
"artemis.core.diagnostics.adb_keys.inspect_adb_keys",
lambda: type("Keys", (), {"is_corrupted": False})(),
)
events = []
def run(args, **kwargs):
events.append(args[-1])
return subprocess.CompletedProcess(args, 0, "", "")
def reconnect(*args, **kwargs):
events.append("reconnect")
assert kwargs["force"] is True
return [{"target": "127.0.0.1:16384", "success": True}]
monkeypatch.setattr(subprocess, "run", run)
monkeypatch.setattr(adb_device_discovery, "refresh", reconnect)
result = await ReadinessEngine().restart_adb_server()
assert events == ["kill-server", "start-server", "reconnect"]
assert result["automatic_connections"][0]["success"] is True
@pytest.mark.asyncio
async def test_manual_tcp_connection_remembers_success_only(monkeypatch):
from unittest.mock import Mock
from artemis.runtime.adb_discovery import adb_device_discovery
monkeypatch.setattr(
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
)
remember = Mock(return_value=True)
monkeypatch.setattr(adb_device_discovery, "remember", remember)
monkeypatch.setattr(
subprocess,
"run",
lambda args, **kwargs: subprocess.CompletedProcess(
args, 0, "connected to 192.0.2.10:5555", ""
),
)
result = await ReadinessEngine().connect_wireless_adb("192.0.2.10", 5555)
assert result["remembered"] is True
remember.assert_called_once_with("192.0.2.10:5555")
remember.reset_mock()
monkeypatch.setattr(
subprocess,
"run",
lambda args, **kwargs: subprocess.CompletedProcess(args, 1, "", "connection refused"),
)
result = await ReadinessEngine().connect_wireless_adb("192.0.2.11", 5555)
assert result["success"] is False
assert result["remembered"] is False
remember.assert_not_called()
@pytest.mark.asyncio
async def test_remote_endpoint_blocks_local_emulator_launch(monkeypatch):
monkeypatch.setattr(
+6 -1
View File
@@ -53,9 +53,14 @@ async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
with pytest.raises(ValueError):
await tools.mobile_batch("create", batch={"name": "x"})
request.assert_not_awaited()
await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"})
selected = [{"name": "选中用例", "goal": "观察设置"}]
await tools.mobile_batch(
"create", request_id="stable-id", batch={"name": "x", "cases": selected, "repeat_count": 3}
)
assert request.await_count == 1
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
assert request.call_args.kwargs["json"]["cases"] == selected
assert request.call_args.kwargs["json"]["repeat_count"] == 3
assert request.call_args.args[1] == "/api/automation/batches"
+290
View File
@@ -0,0 +1,290 @@
"""TCP discovery tests never touch real processes, ports, devices or user state."""
import importlib
import json
import struct
import subprocess
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from artemis.config import settings
from artemis.runtime.adb_endpoint import AdbEndpoint
module = importlib.import_module("artemis.runtime.adb_discovery")
@pytest.fixture
def discovery(tmp_path, monkeypatch):
instance = module.AdbDeviceDiscovery()
monkeypatch.setattr(instance, "_directory", lambda: tmp_path)
monkeypatch.setattr(module, "emulator_listeners", lambda: [])
monkeypatch.setattr(
module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", 5037)
)
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True)
monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS", 5)
monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS", 10)
return instance
def fake_adb(monkeypatch, *, ready="", failures=()):
calls = []
connected = []
def run(self, arguments, **kwargs):
calls.append(arguments)
assert 0 < kwargs["timeout"] <= 5
if arguments == ["devices"]:
return subprocess.CompletedProcess(
arguments,
0,
"List of devices attached\n"
+ ready
+ "".join(f"{target} device\n" for target in connected),
"",
)
target = arguments[1]
if target in failures:
raise subprocess.TimeoutExpired(arguments, kwargs["timeout"])
connected.append(target)
return subprocess.CompletedProcess(arguments, 0, f"connected to {target}", "")
monkeypatch.setattr(module.AdbSession, "run", run)
return calls
@pytest.mark.parametrize(
"name",
[
"MuMuNxDevice.exe",
"NemuHeadless.exe",
"LdVBoxHeadless.exe",
"NoxVMHandle.exe",
"MEmuHeadless.exe",
"HD-Player.exe",
],
)
def test_supported_emulator_listener_ownership(monkeypatch, name):
monkeypatch.setattr(
module.psutil,
"process_iter",
lambda attrs: [
SimpleNamespace(info={"pid": 4, "name": name, "exe": "vendor.exe"}),
SimpleNamespace(info={"pid": 5, "name": "other.exe"}),
],
)
monkeypatch.setattr(
module.psutil,
"net_connections",
lambda kind: [
SimpleNamespace(pid=pid, status="LISTEN", laddr=SimpleNamespace(ip=ip, port=port))
for pid, ip, port in [
(4, "0.0.0.0", 16384),
(4, "127.0.0.1", 7555),
(4, "192.0.2.1", 5555),
(5, "0.0.0.0", 16385),
]
],
)
assert module.emulator_listeners() == [["127.0.0.1:16384", "127.0.0.1:7555"]]
def test_generic_virtualbox_is_not_an_android_emulator(monkeypatch):
monkeypatch.setattr(
module.psutil,
"process_iter",
lambda attrs: [
SimpleNamespace(
info={"pid": 4, "name": "VBoxHeadless.exe", "exe": "VirtualBox/VBoxHeadless.exe"}
)
],
)
monkeypatch.setattr(
module.psutil,
"net_connections",
lambda kind: pytest.fail("No known emulator; do not scan listeners"),
)
assert module.emulator_listeners() == []
@pytest.mark.parametrize("kind,valid", [(b"CNXN", True), (b"AUTH", True), (b"HTTP", False)])
def test_adbd_protocol_handles_fragmented_headers(monkeypatch, kind, valid):
command = int.from_bytes(kind, "little")
header = struct.pack("<6I", command, 1, 4096, 0, 0, command ^ 0xFFFFFFFF)
stream = Mock()
stream.__enter__ = Mock(return_value=stream)
stream.__exit__ = Mock(return_value=False)
stream.recv.side_effect = [header[:8], header[8:]]
monkeypatch.setattr(module.socket, "create_connection", lambda *args, **kwargs: stream)
assert module.is_adbd("127.0.0.1:16384", 0.3) is valid
assert stream.sendall.call_args.args[0][:4] == b"CNXN"
def test_discovery_connects_new_instance_without_duplicate_aliases(discovery, monkeypatch):
calls = fake_adb(monkeypatch)
monkeypatch.setattr(
module,
"emulator_listeners",
lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]],
)
monkeypatch.setattr(module, "is_adbd", lambda *args: True)
results = discovery.refresh("adb")
assert [r["target"] for r in results] == ["127.0.0.1:16384", "127.0.0.1:16416"]
discovery.refresh("adb")
assert len(calls) == 5 # cached: no subprocesses on repeated polling
discovery.refresh("adb", force=True)
assert len(calls) == 6 # force enumerates; connected instances are not reconnected
def test_ready_and_unauthorized_emulators_are_not_probed(discovery, monkeypatch):
calls = fake_adb(monkeypatch, ready="127.0.0.1:7555 device\n127.0.0.1:16416 unauthorized\n")
monkeypatch.setattr(
module,
"emulator_listeners",
lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]],
)
monkeypatch.setattr(
module, "is_adbd", lambda *args: pytest.fail("Do not touch an existing transport")
)
assert discovery.refresh("adb") == []
assert calls == [["devices"]]
def test_non_adb_port_is_not_connected(discovery, monkeypatch):
calls = fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", lambda: [["127.0.0.1:23579", "127.0.0.1:16384"]]
)
monkeypatch.setattr(module, "is_adbd", lambda target, timeout: target.endswith(":16384"))
assert discovery.refresh("adb")[0]["success"] is True
assert calls == [["devices"], ["connect", "127.0.0.1:16384"], ["devices"]]
@pytest.mark.parametrize("port", [5038, 5040])
def test_forwarded_adb_never_discovers_local_devices(discovery, monkeypatch, port):
monkeypatch.setattr(
module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", port)
)
monkeypatch.setattr(
module.AdbSession,
"run",
lambda *args, **kwargs: pytest.fail("Remote endpoint must stay untouched"),
)
assert discovery.refresh("adb", force=True) == []
def test_disabled_discovery_and_concurrent_call_do_not_connect(discovery, monkeypatch):
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", False)
monkeypatch.setattr(
module.AdbSession, "run", lambda *args, **kwargs: pytest.fail("Must not spawn")
)
assert discovery.refresh("adb") == []
monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True)
with discovery._gate:
assert discovery.refresh("adb") == []
def test_remembered_phone_survives_process_restart_and_offline_peer(
discovery, monkeypatch, tmp_path
):
assert discovery.remember("192.0.2.10:5555")
assert discovery.remember("192.0.2.11:5555")
second = module.AdbDeviceDiscovery()
monkeypatch.setattr(second, "_directory", lambda: tmp_path)
calls = fake_adb(monkeypatch, failures={"192.0.2.10:5555"})
results = second.refresh("adb")
assert len(results) == 2
assert any(r["success"] and r["target"] == "192.0.2.11:5555" for r in results)
assert len(calls) == 4
def test_total_budget_stops_unlimited_connection_attempts(discovery, monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module, "emulator_listeners", lambda: [[f"127.0.0.1:{16384 + i}"] for i in range(100)]
)
def probe(*args):
clock[0] += 1
return True
monkeypatch.setattr(module, "is_adbd", probe)
calls = fake_adb(monkeypatch)
discovery.refresh("adb")
assert len(calls) == 9 # listing plus four connects/readiness checks within 5s
def test_corrupt_persistence_and_missing_process_permissions_are_nonfatal(
discovery, monkeypatch, tmp_path
):
(tmp_path / "broken.json").write_text(json.dumps({"target": "bad"}))
assert discovery._remembered() == []
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied())
)
assert discovery.refresh("adb")[0]["success"] is False
def test_listener_permission_failure_still_reconnects_saved_phone(discovery, monkeypatch):
assert discovery.remember("192.0.2.10:5555")
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied())
)
results = discovery.refresh("adb")
assert results[-1]["target"] == "192.0.2.10:5555"
assert results[-1]["success"] is True
def test_localhost_alias_does_not_create_duplicate_transport(discovery, monkeypatch):
assert discovery.remember("localhost:16384")
calls = fake_adb(monkeypatch, ready="127.0.0.1:16384 device\n")
assert discovery.refresh("adb") == []
assert calls == [["devices"]]
def test_connect_waits_until_transport_is_visible(monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)
)
responses = iter(
["List of devices attached\n", "127.0.0.1:16384 offline\n", "127.0.0.1:16384 device\n"]
)
session = Mock()
session.run.side_effect = lambda *args, **kwargs: subprocess.CompletedProcess(
[], 0, next(responses), ""
)
assert module.AdbDeviceDiscovery._wait_for_transport(session, "localhost:16384", 1) == "device"
assert session.run.call_count == 3
def test_transport_settling_is_bounded(monkeypatch):
clock = [0.0]
monkeypatch.setattr(module.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(
module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)
)
session = Mock()
session.run.return_value = subprocess.CompletedProcess([], 0, "List of devices attached\n", "")
assert module.AdbDeviceDiscovery._wait_for_transport(session, "127.0.0.1:16384", 0.2) is None
assert clock[0] == 0.2
def test_pending_transport_is_not_reported_as_failed_or_connected(discovery, monkeypatch):
fake_adb(monkeypatch)
monkeypatch.setattr(
module, "emulator_listeners", lambda: [["127.0.0.1:16384", "127.0.0.1:7555"]]
)
monkeypatch.setattr(module, "is_adbd", lambda *args: True)
monkeypatch.setattr(discovery, "_wait_for_transport", lambda *args: None)
results = discovery.refresh("adb")
assert len(results) == 1
assert results[0]["pending"] is True
assert results[0]["success"] is False
assert "等待设备就绪" in results[0]["message"]
+40
View File
@@ -20,6 +20,7 @@ import pytest
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.device_pool import DevicePool, DeviceStatus
from artemis.config import settings
device_pool_module = importlib.import_module("artemis.runtime.device_pool")
@@ -245,6 +246,45 @@ def test_warm_up_without_adb_returns_false(monkeypatch):
assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False
def test_adb_warmup_does_not_start_local_server_for_remote_endpoint(monkeypatch):
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5038)
async def unexpected_spawn(*args, **kwargs):
pytest.fail("A forwarded endpoint must not start a local ADB daemon")
monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_spawn)
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
def test_adb_warmup_reports_failed_start_with_local_command(monkeypatch):
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
monkeypatch.setattr(settings, "ADB_PORT", 5037)
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:remote.example:5040")
captured = {}
warnings = []
class FailedProcess:
returncode = 1
async def communicate(self):
return b"", b"cannot bind listener"
async def spawn(*args, **kwargs):
captured["command"] = args
captured["env"] = kwargs["env"]
return FailedProcess()
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
monkeypatch.setattr(device_pool_module.logger, "warning", warnings.append)
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
assert captured["command"] == ("adb", "-H", "localhost", "-P", "5037", "start-server")
assert captured["env"]["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
assert len(warnings) == 1
assert "exit 1" in warnings[0]
assert "cannot bind listener" in warnings[0]
def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
+75
View File
@@ -0,0 +1,75 @@
"""Run just the ADB startup functions with fake commands; never touch host ADB."""
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.skipif(not shutil.which("powershell.exe"), reason="Windows PowerShell required")
@pytest.mark.parametrize("script_name", ["start.ps1", "install_deps.ps1"])
@pytest.mark.parametrize("outcome", ["success", "failure", "exception"])
def test_adb_startup_reports_failure_and_preserves_server_environment(
tmp_path, script_name, outcome
):
harness = tmp_path / "adb-startup.ps1"
harness.write_text(
"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$env:ADB_TEST_SCRIPT, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
$function = $ast.Find({ param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'Start-LocalAdbServer'
}, $true)
Invoke-Expression $function.Extent.Text
function Test-CommandExists { return $true }
function adb {
Write-Host ('ADB_ARGS=' + ($args -join '|'))
if ($env:ADB_TEST_OUTCOME -eq 'exception') { throw 'fixture launch error' }
Write-Error 'fixture stderr' -ErrorAction Continue
$global:LASTEXITCODE = $(if ($env:ADB_TEST_OUTCOME -eq 'failure') { 7 } else { 0 })
}
Start-LocalAdbServer
if ($env:ADB_SERVER_SOCKET -ne 'tcp:remote.example:5040') { throw 'Socket was changed' }
if ($env:ANDROID_ADB_SERVER_ADDRESS -ne 'remote.example') { throw 'Host was changed' }
if ($env:ANDROID_ADB_SERVER_PORT -ne '5040') { throw 'Port was changed' }
if ($ErrorActionPreference -ne 'Stop') { throw 'Error preference was changed' }
Write-Host 'HARNESS_OK'
""",
encoding="utf-8-sig",
)
result = subprocess.run(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(harness)],
env={
**os.environ,
"ADB_TEST_SCRIPT": str(ROOT / "scripts" / script_name),
"ADB_TEST_OUTCOME": outcome,
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
"ANDROID_ADB_SERVER_PORT": "5040",
},
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "ADB_ARGS=-H|localhost|-P|5037|start-server" in result.stdout
assert "HARNESS_OK" in result.stdout
if outcome == "success":
assert "[警告]" not in result.stdout
elif outcome == "failure":
assert "退出码 7" in result.stdout
assert "fixture stderr" in result.stdout
else:
assert "启动异常" in result.stdout
assert "fixture launch error" in result.stdout
+106
View File
@@ -0,0 +1,106 @@
"""Exercise CMD parsing and Windows PowerShell 5.1 without installing anything."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.skipif(
not shutil.which("cmd.exe") or not shutil.which("powershell.exe"),
reason="Windows CMD and Windows PowerShell required",
)
def copy_launchers(root: Path, newline: bytes) -> None:
root.mkdir()
(root / "scripts").mkdir()
for name in ("start.bat", "start-cn.bat"):
content = (ROOT / name).read_bytes().replace(b"\r\n", b"\n")
# ASCII batch wrappers remain safe even if a zip/editor changes EOLs.
content.decode("ascii")
(root / name).write_bytes(content.replace(b"\n", newline))
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
@pytest.mark.parametrize("exit_code", [0, 7])
def test_launch_forwards_options_and_exit_code_without_cmd_garbling(tmp_path, newline, exit_code):
root = tmp_path / "source with spaces"
copy_launchers(root, newline)
(root / "scripts" / "start.ps1").write_text(
"""param([string]$Mirror, [int]$Port, [switch]$NoOpen, [switch]$SkipUpdate, [switch]$SkipMcpSync)
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
Write-Host '镜像地址和下载超时可在 config\\downloads.json 中调整。'
Write-Host ('ARGS=' + (@{mirror=$Mirror;port=$Port;no_open=[bool]$NoOpen;skip_update=[bool]$SkipUpdate;skip_mcp=[bool]$SkipMcpSync;language=$env:ARTEMIS_LAUNCH_LANG} | ConvertTo-Json -Compress))
exit ([int]$env:LAUNCH_TEST_EXIT)
""",
encoding="utf-8-sig",
)
result = subprocess.run(
[
"cmd.exe",
"/d",
"/c",
"start-cn.bat",
"-Port",
"8123",
"-NoOpen",
"-SkipUpdate",
"-SkipMcpSync",
],
cwd=root,
env={**os.environ, "ARTEMIS_NO_PAUSE": "1", "LAUNCH_TEST_EXIT": str(exit_code)},
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == exit_code, result.stdout + result.stderr
assert result.stderr == ""
assert "镜像地址和下载超时可在" in result.stdout
args = json.loads(
next(
line.removeprefix("ARGS=")
for line in result.stdout.splitlines()
if line.startswith("ARGS=")
)
)
assert args == {
"mirror": "China",
"port": 8123,
"no_open": True,
"skip_update": True,
"skip_mcp": True,
"language": "zh-CN",
}
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
@pytest.mark.parametrize("help_flag", ["-Help", "--help"])
def test_actual_chinese_help_has_no_encoding_error_or_bootstrap(tmp_path, newline, help_flag):
root = tmp_path / "source with spaces"
copy_launchers(root, newline)
shutil.copyfile(ROOT / "scripts" / "start.ps1", root / "scripts" / "start.ps1")
assert (root / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
result = subprocess.run(
["cmd.exe", "/d", "/c", "start-cn.bat", help_flag],
cwd=root,
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
assert "用法:start-cn.bat [选项]" in result.stdout
assert "仅启动网页,跳过 Codex MCP" in result.stdout
assert "国内镜像设置:config\\downloads.json" in result.stdout
assert "�" not in result.stdout
assert not (root / ".venv").exists()
def test_batch_files_are_ascii_and_powershell_text_has_bom():
for name in ("start.bat", "start-cn.bat"):
(ROOT / name).read_bytes().decode("ascii")
assert (ROOT / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
+22 -6
View File
@@ -53,18 +53,18 @@ def repos(tmp_path):
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
installed, author = repos
assert update.update_source(installed, port=1) == 10
assert update.update_source(installed) == 10
assert (installed / "source.txt").read_text() == "version2"
assert (installed / ".env").read_text() == "secret stays local"
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 0
def test_dirty_checkout_is_preserved(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
(installed / "source.txt").write_text("user edits")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
assert (installed / "source.txt").read_text() == "user edits"
@@ -77,16 +77,32 @@ def test_diverged_checkout_is_not_merged(repos):
git(installed, "add", "local.txt")
git(installed, "commit", "-m", "local")
before = git(installed, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
def test_zip_install_continues_without_git_mutation(tmp_path, capsys):
assert update.update_source(tmp_path, port=1) == 0
def test_zip_install_fails_without_git_mutation(tmp_path, capsys):
assert update.update_source(tmp_path) == 1
assert not (tmp_path / ".git").exists()
assert "Git" in capsys.readouterr().out
def test_startup_check_reports_update_without_changing_source(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
assert update.update_source(installed, check_only=True) == 10
assert git(installed, "rev-parse", "HEAD") == before
assert (installed / "source.txt").read_text() == "version1"
def test_update_fetch_failure_is_not_reported_as_success(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
git(installed, "remote", "set-url", "origin", str(installed / "missing-upstream"))
assert update.update_source(installed) == 1
assert git(installed, "rev-parse", "HEAD") == before
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
root = tmp_path / "project"
path = tmp_path / "config.toml"
+1 -1
View File
@@ -112,7 +112,7 @@ def test_unpacked_archive_fast_forwards(repository, tmp_path):
git(root, "push", "test-upstream", "main")
from scripts.update_installation import update_source
assert update_source(extracted, port=1) == 10
assert update_source(extracted) == 10
assert (extracted / "start.bat").read_text() == "updated"
assert git(extracted, "status", "--porcelain") == ""
+13 -3
View File
@@ -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,
)
+357
View File
@@ -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)
+8
View File
@@ -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%%
+7
View File
@@ -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%%
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "artemis"
version = "1.3.2"
version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },