Compare commits

...
1 Commits
Author SHA1 Message Date
chenmingxuan 83bcfd54d5 feat: release 2.1.0 with optional Flash final AI review
Default Flash final review to off and persist the setting across the UI,
MCP, SDK and queued tasks. Keep execution separate from test verdicts.

Bundle UI fonts and licenses to remove Google Fonts from startup builds.
Align backend/frontend versions and document installation and upgrades.
2026-10-23 12:36:56 +08:00
52 changed files with 898 additions and 33 deletions

No files matched your search

+2
View File
@@ -29,6 +29,8 @@ ARTEMIS_RECORDING_ENABLED=false
# JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。 # JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。
ARTEMIS_RECORDING_CONFIG={"codec":"auto","max_long_edge":1280,"fps":15,"h265_kbps":1000,"h264_kbps":1500,"computer_scope":"screen","minimum_disk_mb":256,"hevc_playback":false} ARTEMIS_RECORDING_CONFIG={"codec":"auto","max_long_edge":1280,"fps":15,"h265_kbps":1000,"h264_kbps":1500,"computer_scope":"screen","minimum_disk_mb":256,"hevc_playback":false}
ARTEMIS_OUTPUTTER_ENABLED=true ARTEMIS_OUTPUTTER_ENABLED=true
# Flash 执行结束后的独立 AI 分析与判定,默认关闭;不影响 Pro 或执行过程。
ARTEMIS_FLASH_FINAL_CHECK_ENABLED=false
ARTEMIS_STEP_SUMMARIZER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。 # 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
+1 -1
View File
@@ -21,7 +21,7 @@
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) | | 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md | | 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 | | 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.0.1 发行说明](docs/releases/2.0.1.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 | | 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.1.0 发行说明](docs/releases/2.1.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 |
## Quick Commands ## Quick Commands
+5 -1
View File
@@ -52,7 +52,7 @@ components:
ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。 ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。
本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.0.1 说明](docs/releases/2.0.1.md)。 本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.1.0 说明](docs/releases/2.1.0.md)。
## System architecture ## System architecture
@@ -68,6 +68,8 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。 最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。 示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
录像使用用户选择的范围。人工示范的窗口模式跟随前台窗口,仅保留一路,无有效窗口时保留缺口;普通录屏在只读验收前停止。恢复操作再追加片段,时间映射以实际录像为准,不借用另一来源或时间的画面。 录像使用用户选择的范围。人工示范的窗口模式跟随前台窗口,仅保留一路,无有效窗口时保留缺口;普通录屏在只读验收前停止。恢复操作再追加片段,时间映射以实际录像为准,不借用另一来源或时间的画面。
@@ -94,6 +96,8 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
沿用全局字体栈:`Google Sans`、`Google Sans Text`、`Inter`、系统无衬线字体;中文使用系统可用字形。代码、路径及请求诊断使用现有等宽样式,保留换行与复制能力。 沿用全局字体栈:`Google Sans`、`Google Sans Text`、`Inter`、系统无衬线字体;中文使用系统可用字形。代码、路径及请求诊断使用现有等宽样式,保留换行与复制能力。
`Inter`、`Outfit`、`Roboto` 及 `Material Symbols Outlined` 使用随源码分发的[本地字体](apps/showcase_ui/public/fonts/README.md),构建和显示不依赖 Google Fonts。图标保留完整字形及 `FILL`、`GRAD`、`opsz`、`wght` 可变轴,不用文字代替缺失图标。
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。 模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。 图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。
+5 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.0.1 已发布**:修复 Python 3.12 全新安装时网页服务无法启动的问题,并统一不同 Python 版本的 MCP 工具描述。修复与升级步骤见 [2.0.1 发行说明](docs/releases/2.0.1.md);探索、插件和精选任务等功能见 [2.0.0 发行说明](docs/releases/2.0.0.md)。 **2.1.0 已发布**:新增 Flash 最终 AI 判定与分析开关,默认关闭;网页字体和图标随源码分发,修复 Google 字体访问超时导致的构建与启动失败。功能与升级步骤见 [2.1.0 发行说明](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。
## 主要功能 ## 主要功能
@@ -19,6 +19,8 @@
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。 准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。
### Windows ### Windows
在 PowerShell 中执行: 在 PowerShell 中执行:
@@ -59,6 +61,8 @@ cd Artemis-Eapil
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。 “功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。 报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
## 让 AI 使用 ## 让 AI 使用
+5 -1
View File
@@ -9,7 +9,11 @@ from artemis.runtime.case_result import read_case_result
def reviewed_result( def reviewed_result(
session_id: str, ai_result: dict | None, storage: AutomationStore session_id: str, ai_result: dict | None, storage: AutomationStore
) -> dict | None: ) -> dict | None:
original = {**ai_result, "source": "ai"} if ai_result else None original = (
{**ai_result, "source": "system" if ai_result.get("verification_skipped") else "ai"}
if ai_result
else None
)
record = storage.get("case_review", session_id) record = storage.get("case_review", session_id)
reviews = (record or {}).get("reviews") or [] reviews = (record or {}).get("reviews") or []
if not reviews: if not reviews:
@@ -35,6 +35,9 @@ def execution_config(item: dict) -> dict:
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})} options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None}) options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
return { return {
"flash_final_check_enabled": agent.flash.final_check_enabled
if profile == "flash"
else None,
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True), "runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
"expected_output": item.get("expected_output"), "expected_output": item.get("expected_output"),
"enable_outputter": item.get("enable_outputter") "enable_outputter": item.get("enable_outputter")
@@ -177,6 +177,9 @@ class CapabilitySettings(BaseModel):
recording_enabled: bool = False recording_enabled: bool = False
recording: dict = Field(default_factory=dict) recording: dict = Field(default_factory=dict)
outputter_enabled: bool = True outputter_enabled: bool = True
flash_final_check_enabled: bool = Field(
default_factory=lambda: load_agent_config().flash.final_check_enabled
)
step_summarizer_enabled: bool = True step_summarizer_enabled: bool = True
screenshot_limit_enabled: bool = True screenshot_limit_enabled: bool = True
screenshot_max_long_edge: int = 2048 screenshot_max_long_edge: int = 2048
@@ -207,6 +210,7 @@ def get_capability_settings() -> CapabilitySettings:
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED, recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
recording=load_agent_config().recording.model_dump(), recording=load_agent_config().recording.model_dump(),
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED, outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
flash_final_check_enabled=load_agent_config().flash.final_check_enabled,
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED, step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
screenshot_limit_enabled=screenshot.enabled, screenshot_limit_enabled=screenshot.enabled,
screenshot_max_long_edge=screenshot.max_long_edge, screenshot_max_long_edge=screenshot.max_long_edge,
@@ -227,6 +231,7 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
"ARTEMIS_VIDEO_ENABLED": update.video_enabled, "ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled, "ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled, "ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_FLASH_FINAL_CHECK_ENABLED": update.flash_final_check_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled, "ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled, "ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds, "ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
+7 -1
View File
@@ -73,6 +73,8 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str: def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result") result = (outcome or {}).get("test_result")
if isinstance(result, dict) and result.get("status") in RESULT_LABELS: if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
if result["status"] == "inconclusive" and result.get("verification_skipped"):
return "未做 AI 判定"
label = RESULT_LABELS[result["status"]] label = RESULT_LABELS[result["status"]]
if result.get("source"): if result.get("source"):
label += "(人工确认)" if result["source"] == "human" else "(AI确认)" label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
@@ -273,7 +275,11 @@ def metadata(report: dict) -> list[tuple]:
("验证结论", report["verdict"]), ("验证结论", report["verdict"]),
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))), ("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
( (
"原 AI 结论", "原始结果"
if ((report.get("test_result") or {}).get("ai_result") or {}).get(
"verification_skipped"
)
else "原 AI 结论",
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")}) verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
if (report.get("test_result") or {}).get("source") == "human" if (report.get("test_result") or {}).get("source") == "human"
else "—", else "—",
@@ -579,6 +579,13 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id) env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend")) env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1" env["ARTEMIS_TASK_WORKER"] = "1"
# Capture the global Flash choice at launch, including saved favorites.
# It is metadata in the snapshot, never a favorite's runtime override.
flash_final_check = (task_item.get("execution_config") or {}).get(
"flash_final_check_enabled"
)
if profile == "flash" and flash_final_check is not None:
env["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] = str(flash_final_check).lower()
from artemis.exploration.store import explorations from artemis.exploration.store import explorations
exploration_run = explorations.run(str(sess_id)) if sess_id else None exploration_run = explorations.run(str(sess_id)) if sess_id else None
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.0.1", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.0.1", "version": "2.1.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.0.1", "version": "2.1.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
+23
View File
@@ -0,0 +1,23 @@
# 本地网页字体
这些字体作为静态资源随源码和网页构建产物分发,由 `src/styles.scss` 的 `@font-face` 引用。构建和页面显示不需要连接 Google Fonts;不要恢复外部字体样式表或预连接。
正文提供 Latin 字形,中文继续使用系统字体。Material Symbols 保留上游完整图标集和 `FILL`、`GRAD`、`opsz`、`wght` 四个可变轴,兼容现有连字名称及组件样式。
## 来源与许可证
| 文件 | 固定来源 | 许可证 |
| --- | --- | --- |
| `inter-latin-wght-normal.woff2` | [@fontsource-variable/inter 5.3.0](https://www.npmjs.com/package/@fontsource-variable/inter/v/5.3.0),`files/inter-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/inter-OFL.txt) |
| `outfit-latin-wght-normal.woff2` | [@fontsource-variable/outfit 5.3.0](https://www.npmjs.com/package/@fontsource-variable/outfit/v/5.3.0),`files/outfit-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/outfit-OFL.txt) |
| `roboto-latin-wght-normal.woff2` | [@fontsource-variable/roboto 5.3.0](https://www.npmjs.com/package/@fontsource-variable/roboto/v/5.3.0),`files/roboto-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/roboto-OFL.txt) |
| `material-symbols-outlined.woff2` | Google [material-design-icons 的固定提交](https://github.com/google/material-design-icons/tree/10ecc14790612ca8fd164e3f4ffe9c16fc50d5b7/variablefont),`MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2` | [Apache 2.0](licenses/material-symbols-outlined-Apache-2.0.txt) |
字体文件未经修改。更新时从固定版本获取,保留上游版权与许可证,并同步以下 SHA256;不要在安装或构建时下载字体。
| 文件 | SHA256 |
| --- | --- |
| `inter-latin-wght-normal.woff2` | `3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62` |
| `outfit-latin-wght-normal.woff2` | `6c18d579fd87c3776be068b762cbc83fde3acb543d49eabd3ade842eb987e887` |
| `roboto-latin-wght-normal.woff2` | `1404ca348bd75ef836f4dd8b6f2cc719458642d1237c368296b2fc652dca47dc` |
| `material-symbols-outlined.woff2` | `3ae08513a06396b0258cf1d1a1091c2d336977518fb755ea5b4a13aacdbdc6ef` |
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) Roboto-Italic[wdth,wght].ttf: Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -12,6 +12,7 @@ interface Capabilities {
video_enabled: boolean; video_enabled: boolean;
recording_enabled: boolean; recording_enabled: boolean;
outputter_enabled: boolean; outputter_enabled: boolean;
flash_final_check_enabled: boolean;
step_summarizer_enabled: boolean; step_summarizer_enabled: boolean;
screenshot_limit_enabled: boolean; screenshot_limit_enabled: boolean;
screenshot_max_long_edge: number; screenshot_max_long_edge: number;
@@ -28,7 +29,7 @@ interface Capabilities {
template: ` template: `
<section aria-label="功能开关"> <section aria-label="功能开关">
<h3>功能开关</h3> <h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p> <p>按需启用,保存后对尚未开始的任务生效,不影响运行中的任务。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label> <span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -51,6 +52,8 @@ interface Capabilities {
} }
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label> <span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" aria-label="Flash 最终 AI 判定与分析" [(ngModel)]="config.flash_final_check_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>Flash 最终 AI 判定与分析<small>默认关闭。开启后,在 Flash 执行结束时根据用例需求和执行步骤复核结果,会增加耗时。关闭时显示“执行完成 · 未做 AI 判定”,仍保留步骤和报告。不影响 Pro。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label> <span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -96,7 +99,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit { export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>(); changed = output<Capabilities>();
private http = inject(HttpClient); private http = inject(HttpClient);
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 }; 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, flash_final_check_enabled: false, 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(''); busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void { ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({ this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
@@ -220,4 +220,37 @@ describe('持久化模型与功能设置', () => {
fixture.componentInstance.save(); fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings'); http.expectNone('/api/system/capability-settings');
}); });
it('Flash 最终 AI 判定默认关闭,保存失败保留选择,成功后重新打开仍保留', async () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const toggle = root.querySelector<HTMLInputElement>('[aria-label="Flash 最终 AI 判定与分析"]')!;
await Promise.resolve();
expect(toggle.disabled).toBeTrue();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges(); await fixture.whenStable();
expect(toggle.checked).toBeFalse();
expect(root.textContent).toContain('不影响 Pro');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
root.querySelector<HTMLButtonElement>('button')!.click();
let request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.flash_final_check_enabled).toBeTrue();
request.flush({}, {status:500,statusText:'Error'});
fixture.detectChanges();
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeTrue();
expect(fixture.componentInstance.error()).toContain('保存失败');
root.querySelector<HTMLButtonElement>('button')!.click();
request = http.expectOne('/api/system/capability-settings');
const saved = request.request.body;
request.flush(saved);
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
reopened.detectChanges();
http.expectOne('/api/system/capability-settings').flush(saved);
reopened.detectChanges(); await reopened.whenStable();
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
reopened.destroy();
});
}); });
@@ -5,10 +5,14 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞' passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞'
}; };
export function testResultSourceLabel(result: CaseTestResult | null | undefined): string {
return result?.source === 'human' ? '人工确认' : result?.verification_skipped ? 'AI 判定已关闭' : 'AI确认';
}
@Component({ @Component({
selector: 'app-test-result-badge', selector: 'app-test-result-badge',
standalone: true, standalone: true,
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`, template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="sourceLabel() + ':' + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [` styles: [`
:host{display:inline-flex;vertical-align:middle;max-width:100%} :host{display:inline-flex;vertical-align:middle;max-width:100%}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569} .test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
@@ -29,8 +33,10 @@ export class TestResultBadgeComponent {
compact = input(false); compact = input(false);
prominent = input(false); prominent = input(false);
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending')); status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
sourceLabel = computed(() => testResultSourceLabel(this.result()));
label = computed(() => this.result()?.source === 'human' label = computed(() => this.result()?.source === 'human'
? (this.status() === 'passed' ? '人工通过' : '人工不通过') ? (this.status() === 'passed' ? '人工通过' : '人工不通过')
: this.status() === 'inconclusive' && this.result()?.verification_skipped ? '未做 AI 判定'
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]); : this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]); icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
} }
@@ -62,4 +62,24 @@ describe('用例测试结果卡片', () => {
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse(); expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
fixture.destroy(); fixture.destroy();
}); });
it('跳过 Flash AI 判定时不显示通过或 AI 确认,仍允许人工复验', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
const skipped = {status:'inconclusive',reason:'执行完成 · 未做 AI 判定',evidence:[],verification_skipped:true,source:'system'};
fixture.componentRef.setInput('result', skipped);
fixture.componentRef.setInput('sessionId','flash-skipped');
fixture.componentRef.setInput('executionStatus','completed');
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('.prominent')?.textContent).toContain('未做 AI 判定');
expect(root.querySelector('.caption')?.textContent).toContain('AI 判定已关闭');
expect(root.querySelector('.prominent')?.getAttribute('title')).not.toContain('AI确认');
expect(root.querySelector('.review-toolbar')).not.toBeNull();
fixture.componentRef.setInput('result', {status:'passed',reason:'人工核对完成',evidence:[],source:'human',ai_result:skipped});
fixture.detectChanges();
expect(root.textContent).toContain('人工通过');
expect(root.textContent).toContain('原始结果 · 未做 AI 判定');
expect(root.textContent).not.toContain('原 AI 结论');
fixture.destroy();
});
}); });
@@ -4,17 +4,17 @@ import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model'; import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { TestResultBadgeComponent } from './test-result-badge.component'; import { TestResultBadgeComponent, testResultSourceLabel } from './test-result-badge.component';
@Component({ @Component({
selector: 'app-test-result-card', selector: 'app-test-result-card',
standalone: true, standalone: true,
imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule], imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule],
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status"> template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header> <header><span class="caption">用例测试结果 <small>{{sourceLabel()}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
<app-report-markdown class="reason" [content]="result().reason" /> <app-report-markdown class="reason" [content]="result().reason" />
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>} @if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>} @if(result().ai_result; as original){<details><summary>{{original.verification_skipped ? '原始结果 · 未做 AI 判定' : '原 AI 结论 · ' + (original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证')}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>} @if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>}
@if(canReview()){ @if(canReview()){
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div> <div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
@@ -53,6 +53,7 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
export class TestResultCardComponent { export class TestResultCardComponent {
private http = inject(HttpClient); private http = inject(HttpClient);
result = input.required<CaseTestResult>(); result = input.required<CaseTestResult>();
sourceLabel = computed(() => testResultSourceLabel(this.result()));
sessionId = input<string | null>(null); sessionId = input<string | null>(null);
executionStatus = input(''); executionStatus = input('');
reviewSaved = output<CaseReviewResponse>(); reviewSaved = output<CaseReviewResponse>();
@@ -25,7 +25,8 @@ export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked'; status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string; reason: string;
evidence: string[]; evidence: string[];
source?: 'ai' | 'human'; source?: 'ai' | 'human' | 'system';
verification_skipped?: boolean;
reviewed_at?: number; reviewed_at?: number;
ai_result?: CaseTestResult | null; ai_result?: CaseTestResult | null;
} }
@@ -1423,7 +1423,7 @@
<span class="mode-name">Flash</span> <span class="mode-name">Flash</span>
<span class="bullet">·</span> <span class="bullet">·</span>
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span> <span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
<span class="limitation-tag">(轻量执行,结束时判断测试结果)</span> <span class="limitation-tag">(最终 AI 判定默认关闭,可在功能配置中开启)</span>
</p> </p>
} @else if(selectedProfile() === 'exploration') { } @else if(selectedProfile() === 'exploration') {
<p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p> <p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p>
-4
View File
@@ -21,10 +21,6 @@
<title>artemis</title> <title>artemis</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap" />
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png?v=2"> <link rel="icon" type="image/png" sizes="192x192" href="/logo.png?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=2"> <link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=2">
<link rel="icon" type="image/svg+xml" href="/logo.svg?v=2"> <link rel="icon" type="image/svg+xml" href="/logo.svg?v=2">
+34 -2
View File
@@ -14,10 +14,42 @@
* limitations under the License. * limitations under the License.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
@import '@angular/cdk/overlay-prebuilt.css'; @import '@angular/cdk/overlay-prebuilt.css';
// Keep fonts local so production builds and icons do not depend on Google Fonts.
// Sources and licenses: public/fonts/README.md.
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/outfit-latin-wght-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/roboto-latin-wght-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
}
.cdk-overlay-container { .cdk-overlay-container {
z-index: 1200 !important; z-index: 1200 !important;
} }
+3 -1
View File
@@ -119,7 +119,9 @@ async def settle_flash_outcome(
) )
else: else:
result = CaseTestResult( result = CaseTestResult(
status="inconclusive", reason="已关闭最终检查,未进行测试结果验收。" status="inconclusive",
reason="执行完成 · 未做 AI 判定。已关闭 Flash 最终 AI 分析与判定,执行记录和报告仍保留。",
verification_skipped=True,
) )
outcome = RunOutcome( outcome = RunOutcome(
+8
View File
@@ -735,6 +735,10 @@ class MemoryConfig(BaseModel):
class FlashProfileConfig(BaseModel): class FlashProfileConfig(BaseModel):
"""Configuration options specific to the ⚡ Flash execution profile (FlashRunner/ReactiveRunner).""" """Configuration options specific to the ⚡ Flash execution profile (FlashRunner/ReactiveRunner)."""
final_check_enabled: bool = Field(
default=False,
description="Run the independent AI analysis and verdict after Flash execution ends.",
)
max_turns: int = Field( max_turns: int = Field(
default=0, default=0,
ge=0, ge=0,
@@ -1017,6 +1021,10 @@ class AgentGlobalConfig(BaseModel):
if out_env is not None: if out_env is not None:
data.setdefault("outputter", {})["enabled"] = out_env data.setdefault("outputter", {})["enabled"] = out_env
flash_check_env = _parse_bool("ARTEMIS_FLASH_FINAL_CHECK_ENABLED")
if flash_check_env is not None:
data.setdefault("flash", {})["final_check_enabled"] = flash_check_env
vid_rec_env = _parse_bool("ARTEMIS_VIDEO_RECORDING_ENABLED") or _parse_bool( vid_rec_env = _parse_bool("ARTEMIS_VIDEO_RECORDING_ENABLED") or _parse_bool(
"ARTEMIS_WITH_VIDEO_RECORDING_TOOLS" "ARTEMIS_WITH_VIDEO_RECORDING_TOOLS"
) )
+1
View File
@@ -131,6 +131,7 @@ class Settings(BaseSettings):
# Worker-only override, applied after the user's JSON recording defaults. # Worker-only override, applied after the user's JSON recording defaults.
ARTEMIS_TASK_RECORDING_CONFIG: dict = Field(default_factory=dict) ARTEMIS_TASK_RECORDING_CONFIG: dict = Field(default_factory=dict)
ARTEMIS_OUTPUTTER_ENABLED: bool = True ARTEMIS_OUTPUTTER_ENABLED: bool = True
ARTEMIS_FLASH_FINAL_CHECK_ENABLED: bool = False
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task. # Consecutive Pro responses without a valid tool call before failing the task.
+1
View File
@@ -48,6 +48,7 @@
"max_long_edge": 2048 "max_long_edge": 2048
}, },
"flash": { "flash": {
"final_check_enabled": "${env:ARTEMIS_FLASH_FINAL_CHECK_ENABLED:-false}",
"max_turns": 0, "max_turns": 0,
"explorer_mode": "flash", "explorer_mode": "flash",
"step_summarizer": { "step_summarizer": {
+5
View File
@@ -5,11 +5,16 @@ from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from pydantic.json_schema import SkipJsonSchema
from artemis.config import TRACES_PATH from artemis.config import TRACES_PATH
class CaseTestResult(BaseModel): class CaseTestResult(BaseModel):
# Host metadata, not part of the model's verdict schema. Old results omit it.
verification_skipped: SkipJsonSchema[bool] = Field(
default=False, exclude_if=lambda value: not value
)
status: Literal["passed", "failed", "inconclusive", "blocked"] = Field( status: Literal["passed", "failed", "inconclusive", "blocked"] = Field(
description="Judge the system under test against the user's expectations. A correctly executed inspection that reveals an expectation mismatch is failed, even if the executor completed and reported the mismatch correctly." description="Judge the system under test against the user's expectations. A correctly executed inspection that reveals an expectation mismatch is failed, even if the executor completed and reported the mismatch correctly."
) )
+5 -1
View File
@@ -1261,7 +1261,11 @@ class Agent:
record_video_enabled=self._config.record_video_enabled, record_video_enabled=self._config.record_video_enabled,
disable_checker=self._config.disable_checker, disable_checker=self._config.disable_checker,
disable_midway_checks=self._config.disable_midway_checks, disable_midway_checks=self._config.disable_midway_checks,
disable_final_check=self._config.disable_final_check, disable_final_check=self._config.disable_final_check
or (
(task.request.profile or "").lower() == "flash"
and not self._config.flash.final_check_enabled
),
checker_max_iterations=self._config.checker_max_iterations, checker_max_iterations=self._config.checker_max_iterations,
final_check_max_attempts=self._config.final_check_max_attempts, final_check_max_attempts=self._config.final_check_max_attempts,
checkpoint_max_repairs=self._config.checkpoint_max_repairs, checkpoint_max_repairs=self._config.checkpoint_max_repairs,
+1
View File
@@ -48,6 +48,7 @@
"max_long_edge": 2048 "max_long_edge": 2048
}, },
"flash": { "flash": {
"final_check_enabled": "${env:ARTEMIS_FLASH_FINAL_CHECK_ENABLED:-false}",
"max_turns": 0, "max_turns": 0,
"explorer_mode": "flash", "explorer_mode": "flash",
"step_summarizer": { "step_summarizer": {
+2
View File
@@ -76,6 +76,8 @@ Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。 `mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
Flash 最终 AI 判定与分析默认关闭。读取 `mobile_config(section="capabilities")` 的 `flash_final_check_enabled`;用户要求保存全局设置时,可用 `changes={"flash_final_check_enabled":true}` 和 `persist_defaults=true` 开启。它影响尚未开始的所有 Flash 任务,运行中的任务、Pro 和主动探索不变;不作为单任务或精选模板的覆盖项。关闭时 `test_result.status="inconclusive"`、`verification_skipped=true`、`source="system"`,表示未做 AI 判定,不是测试通过;执行异常仍按原失败状态记录。人工复验可随后给出独立结论。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。 任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
## 插件证据与留档参数 ## 插件证据与留档参数
+4 -2
View File
@@ -14,13 +14,15 @@
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。 报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
“执行完成”与测试结论分开记录。预期直接写在任务提示词中即可;Flash 和 Pro 默认用只读终检核对提示词、用户指导、执行结论及实际证据,不增加必填表单。任务列表显式标注“测试通过 / 测试不通过 / 未验证 / 测试阻塞”,结果区域和 Word/Excel 同时列出判定依据及证据。终检关闭、出错或缺少证据时显示“未验证”,设备或执行异常不当作产品缺陷;已确认的断言失败会保留。正在执行的任务也可以下载,但文件只代表下载时的快照。 “执行完成”与测试结论分开记录。预期直接写在任务提示词中即可;Pro 默认用只读终检核对提示词、用户指导、执行结论及实际证据,不增加必填表单。Flash 的“最终 AI 判定与分析”默认关闭,可在首页功能配置开启;关闭时正常完成的任务显示“执行完成 · 未做 AI 判定”,历史、批次与 Word/Excel 保持一致,不自动记为测试通过。终检出错或缺少证据时仍显示“未验证”,设备或执行异常不当作产品缺陷;已确认的断言失败会保留。正在执行的任务也可以下载,但文件只代表下载时的快照。
Flash 开关保存在全局配置中,对网页、批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑统一生效;在任务开始时固定,排队中的任务使用最新配置,运行中的任务不变。开关只省去末尾独立 AI 复核,执行模型的最终答复、步骤摘要收尾和资源清理仍保留,不影响 Pro 或主动探索。环境变量为 `ARTEMIS_FLASH_FINAL_CHECK_ENABLED=false`,JSON 默认在 `agent.flash.final_check_enabled`;旧配置缺少该字段时也默认关闭。
终检已输出完整且符合结构的结论时,直接校验并保存,不额外请求模型改写格式;若兼容接口把 `test_result` 编码为 JSON 字符串,会先解码再按同一规则校验。普通说明文字、被截断的输出、缺少检查项或证据的结果不会直接算通过。判定范围以提示词为准:明确要求“发现并如实报告问题、不要求无缺陷”的检查任务通过,不表示被测产品没有缺陷。 终检已输出完整且符合结构的结论时,直接校验并保存,不额外请求模型改写格式;若兼容接口把 `test_result` 编码为 JSON 字符串,会先解码再按同一规则校验。普通说明文字、被截断的输出、缺少检查项或证据的结果不会直接算通过。判定范围以提示词为准:明确要求“发现并如实报告问题、不要求无缺陷”的检查任务通过,不表示被测产品没有缺陷。
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。 报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。 任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原始结果和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;已做 AI 判定的结论标明“AI确认”,关闭判定的结果标明“未做 AI 判定”。复验后下载的报告包会重新生成。
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。 任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
+33
View File
@@ -0,0 +1,33 @@
# ARTEMIS-Eapil 2.1.0
本版增加 Flash 最终 AI 判定与分析开关,并修复全新安装时因 Google 字体访问超时而无法完成网页构建的问题。
## Flash 最终 AI 判定可选
- 在首页打开“系统设置与运行准备”,找到“功能开关”中的 **Flash 最终 AI 判定与分析**,保存选择;默认关闭。
- 关闭时只跳过 Flash 执行结束后,按用例需求和执行步骤进行的最终 AI 复核。执行过程中的 AI 操作及必要检查继续保留,Pro 模式沿用现有行为。
- 正常执行结束但未做最终判定时,页面显示 **执行完成 · 未做 AI 判定**,不自动记为测试通过;执行报错仍显示失败。历史、报告、人工复验及 MCP 结果沿用相同口径。
- 该设置统一用于网页单任务、批量、精选任务、MCP/SDK 及探索用例的 Flash 复跑。任务开始时读取并固定:修改后尚未开始的任务使用新值,运行中的任务保持原值。
## 修复字体网络依赖
- 将 Inter、Outfit、Roboto 和完整 Material Symbols 图标字体随源码分发,移除全局样式及页面入口中的 Google Fonts 外部引用和预连接。
- 生产构建及字体显示无需访问 Google Fonts,避免网络超时导致启动脚本报“网页构建失败”。图标字形及可变轴保留,字体来源、校验值和许可证见[本地字体说明](../../apps/showcase_ui/public/fonts/README.md)。
- 首次安装仍需下载 Python/Node 依赖,可按网络环境选择官方源或国内镜像启动脚本。
## 升级与安装
1. 先结束正在运行的任务,再运行 `update.bat`;国内网络使用 `update-cn.bat`。升级程序会停止当前项目的服务与任务进程,并同步依赖环境。
2. 完成后运行 `start.bat` / `start-cn.bat`,等待网页构建并打开页面。启动脚本只检查更新,不代替升级脚本。
3. 全新安装下载 `artemis-eapil-2.1.0-source.zip`,解压后运行上述启动脚本。用附件 `SHA256SUMS.txt` 校验下载完整性。
源码包保留浅层 Git 更新能力,包含本地字体及许可证;不包含依赖环境、用户密钥、任务历史或预编译网页。升级主程序不会自动升级已安装插件。
## 验证
- 本轮开发的默认后端测试 **2967 项通过、2 项跳过、8 项按标记排除**;Ruff 格式与静态检查、质量基线和核心类型检查通过。
- 前端 **292 项测试通过**;禁用 Angular 构建缓存并阻断构建进程网络后,生产构建成功,未发生网络请求。
- 浏览器在仅允许同源字体的环境中确认四套字体均可加载,描边及填充图标显示正常;字体及许可证已完整复制到构建产物,源码打包过滤规则保留这些资源。
- 升版后启动导入、安装资源和源码归档相关的 **12 项测试通过**,前后端及锁文件版本均为 `2.1.0`,再次完成无网络生产构建。
以上为确定性验证;真实设备及付费模型流程未纳入本轮测试。
+1 -1
View File
@@ -431,7 +431,7 @@
}, },
{ {
"name": "mobile_config", "name": "mobile_config",
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-sol), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n", "description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-sol), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
"inputSchema": { "inputSchema": {
"properties": { "properties": {
"section": { "section": {
+2
View File
@@ -34,6 +34,7 @@ CAPABILITY_FIELDS = {
"recording_enabled", "recording_enabled",
"recording", "recording",
"outputter_enabled", "outputter_enabled",
"flash_final_check_enabled",
"step_summarizer_enabled", "step_summarizer_enabled",
"screenshot_limit_enabled", "screenshot_limit_enabled",
"task_timeout_seconds", "task_timeout_seconds",
@@ -192,6 +193,7 @@ async def mobile_config(
tool_fallback_no_progress_attempts. 'default' omits reasoning; tool_fallback_no_progress_attempts. 'default' omits reasoning;
unsupported values fail visibly. unsupported values fail visibly.
capabilities: video_enabled and recording_enabled are independent; outputter_enabled, capabilities: video_enabled and recording_enabled are independent; outputter_enabled,
flash_final_check_enabled (false; Flash exit-only AI review),
step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800), step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),
observation_timeout_seconds (60), observation_max_attempts (3), observation_timeout_seconds (60), observation_max_attempts (3),
device_command_timeout_seconds (10), action_failure_attempts (3), device_command_timeout_seconds (10), action_failure_attempts (3),
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.0.1" version = "2.1.0"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+1 -1
View File
@@ -22,7 +22,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历
## 三种模式 ## 三种模式
- **Flash**:路径明确的快速执行,单个执行模型观察并操作;适合冒烟、稳定流程与探索后复跑。可以接受中途指导,也有结束验收。任务长不自动等于需要 Pro。 - **Flash**:路径明确的快速执行,单个执行模型观察并操作;适合冒烟、稳定流程与探索后复跑。可以接受中途指导;最终 AI 判定与分析默认关闭,可在全局功能配置开启。关闭时显示“未做 AI 判定”,不能当作验收通过。任务长不自动等于需要 Pro。
- **Pro**:需要持久计划、多个分支、检查点、ADB/日志诊断或详细报告;按计划执行并验证。`explorer_mode` 是 Pro 的视觉定位深度,不是探索任务模式。 - **Pro**:需要持久计划、多个分支、检查点、ADB/日志诊断或详细报告;按计划执行并验证。`explorer_mode` 是 Pro 的视觉定位深度,不是探索任务模式。
- **探索**:用户只有大概流程,需要与模型交流、纠正方向或人工示范;以 Pro 的执行能力推进,最终提炼一个可复用用例。超时保存并暂停,可继续。探索成功、生成用例和 Flash/Pro 复跑成功是三个不同结果。 - **探索**:用户只有大概流程,需要与模型交流、纠正方向或人工示范;以 Pro 的执行能力推进,最终提炼一个可复用用例。超时保存并暂停,可继续。探索成功、生成用例和 Flash/Pro 复跑成功是三个不同结果。
+1 -1
View File
@@ -6,7 +6,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 适合 | 目标清楚、路径明确、低分支流程;冒烟和已提炼用例复跑 | 复杂分支、持久计划、检查点、长程监控、ADB/日志、详细记录 | 流程尚未定型,用户参与纠正或示范的一个复杂用例 | | 适合 | 目标清楚、路径明确、低分支流程;冒烟和已提炼用例复跑 | 复杂分支、持久计划、检查点、长程监控、ADB/日志、详细记录 | 流程尚未定型,用户参与纠正或示范的一个复杂用例 |
| 执行方式 | 单执行模型观察→动作;需要时使用定位等辅助工具 | Planner 制订计划;Operator 执行;Checker 验收;按需生成报告 | 复用 Pro 工具和计划,加持续对话、提问等待、示范分析和用例提炼 | | 执行方式 | 单执行模型观察→动作;需要时使用定位等辅助工具 | Planner 制订计划;Operator 执行;Checker 验收;按需生成报告 | 复用 Pro 工具和计划,加持续对话、提问等待、示范分析和用例提炼 |
| 验收 | 默认结束时只读验收 | 默认结束验收,可配置检查点/strict | 先处理用户指导,再验收探索目标;不把示范当独立复跑通过 | | 验收 | 最终 AI 判定与分析默认关闭,可在全局功能配置开启 | 默认结束验收,可配置检查点/strict | 先处理用户指导,再验收探索目标;不把示范当独立复跑通过 |
| 中途输入 | `inject_instruction` 指导当前任务 | 同 Flash,还能记录计划及诊断信息 | `message` 持续交流;结束后消息仅讨论,再操作需 `continue` | | 中途输入 | `inject_instruction` 指导当前任务 | 同 Flash,还能记录计划及诊断信息 | `message` 持续交流;结束后消息仅讨论,再操作需 `continue` |
| 入口 | `mobile_run_task(model="Flash")` | `mobile_run_task(model="Pro")` | `mobile_exploration(create/start/...)` | | 入口 | `mobile_run_task(model="Flash")` | `mobile_run_task(model="Pro")` | `mobile_exploration(create/start/...)` |
| 产物 | 步骤、证据、测试结果,按需导出报告 | 以上加计划、笔记和可选详细报告 | 对话、各轮执行和示范证据、一个用例的不可变版本 | | 产物 | 步骤、证据、测试结果,按需导出报告 | 以上加计划、笔记和可选详细报告 | 对话、各轮执行和示范证据、一个用例的不可变版本 |
@@ -951,6 +951,51 @@ def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_
assert outcome_file.read_text(encoding="utf-8") == original assert outcome_file.read_text(encoding="utf-8") == original
def test_skipped_flash_review_is_consistent_in_history_reports_and_human_review(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "status": "completed"})
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
result = {
"status": "inconclusive",
"reason": "执行完成 · 未做 AI 判定",
"evidence": [],
"verification_skipped": True,
}
(folder / "run_outcome.json").write_text(
json.dumps({"task_status": "completed", "tests": {}, "test_result": result}),
encoding="utf-8",
)
for item in (
client.get("/api/automation/history").json()["items"][0],
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
client.get(f"/api/automation/reports/{job['session_id']}").json(),
):
assert item["status"] == "completed"
assert item["test_result"] == {**result, "source": "system"}
assert reports.session_report(job["session_id"])["verdict"] == "未做 AI 判定"
paths = reports.export_session(job["session_id"])
workbook = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
assert "未做 AI 判定" in [
cell.value for row in workbook["任务结果"].iter_rows() for cell in row
]
doc = Document(next(path for path in paths if path.suffix == ".docx"))
assert "未做 AI 判定" in "\n".join(paragraph.text for paragraph in doc.paragraphs)
reviewed = client.put(
f"/api/automation/reports/{job['session_id']}/review",
json={"status": "passed", "reason": "已人工核对"},
).json()
assert reviewed["test_result"]["source"] == "human"
assert reviewed["test_result"]["ai_result"] == {**result, "source": "system"}
assert reviewed["verdict"] == "测试通过(人工确认)"
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch): def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0] job = isolated.jobs(service.create_batch(payload())["id"])[0]
url = f"/api/automation/reports/{job['session_id']}/review" url = f"/api/automation/reports/{job['session_id']}/review"
@@ -31,6 +31,7 @@ def isolated_settings(monkeypatch, tmp_path):
"OPENAI_REASONING_EFFORT", "OPENAI_REASONING_EFFORT",
"ARTEMIS_VIDEO_ENABLED", "ARTEMIS_VIDEO_ENABLED",
"ARTEMIS_OUTPUTTER_ENABLED", "ARTEMIS_OUTPUTTER_ENABLED",
"ARTEMIS_FLASH_FINAL_CHECK_ENABLED",
"ARTEMIS_STEP_SUMMARIZER_ENABLED", "ARTEMIS_STEP_SUMMARIZER_ENABLED",
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
"ARTEMIS_TASK_TIMEOUT_SECONDS", "ARTEMIS_TASK_TIMEOUT_SECONDS",
@@ -149,6 +150,36 @@ def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
assert load_agent_config().video_analyzer.enabled is True assert load_agent_config().video_analyzer.enabled is True
def test_flash_review_defaults_off_and_persists_without_changing_pro(isolated_settings):
assert Settings(_env_file=None).ARTEMIS_FLASH_FINAL_CHECK_ENABLED is False
assert service.get_capability_settings().flash_final_check_enabled is False
assert load_agent_config().checker.final_check is True
for enabled in (True, False):
saved = service.save_capability_settings(
service.CapabilitySettings(flash_final_check_enabled=enabled)
)
assert saved.flash_final_check_enabled is enabled
assert Settings(_env_file=isolated_settings).ARTEMIS_FLASH_FINAL_CHECK_ENABLED is enabled
config = load_agent_config()
assert config.flash.final_check_enabled is enabled
assert config.checker.final_check is True
assert config.pro.checker.final_check is True
# Older clients omit the new switch; saving another setting keeps it.
assert (
service.save_capability_settings(
service.CapabilitySettings(recording_enabled=False)
).flash_final_check_enabled
is enabled
)
def test_old_checker_config_does_not_enable_flash_review(monkeypatch, tmp_path):
monkeypatch.delenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", raising=False)
path = tmp_path / "artemis.jsonc"
path.write_text('{"agent":{"checker":{"enabled":true,"final_check":true}}}', encoding="utf-8")
assert load_agent_config(path).flash.final_check_enabled is False
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path): def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
config_path = tmp_path / "artemis.jsonc" config_path = tmp_path / "artemis.jsonc"
config_path.write_text( config_path.write_text(
@@ -767,6 +767,34 @@ async def test_enqueue_tasks_unified_ingress():
assert task["goal"] == "Test unified goal" assert task["goal"] == "Test unified goal"
def test_flash_review_is_refreshed_at_launch_and_frozen_in_worker_environment(monkeypatch):
from apps.admin_console.services.execution_config import execution_config
monkeypatch.setattr("artemis.exploration.store.explorations.run", lambda _sid: None)
monkeypatch.setattr("apps.admin_console.services.automation_store.store.job", lambda _sid: None)
item = {"profile": "flash", "goal": "queued", "session_id": "flash-config-snapshot"}
monkeypatch.setenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", "false")
old_snapshot = execution_config(item)
item["execution_config"] = old_snapshot
assert old_snapshot["flash_final_check_enabled"] is False
assert "flash_final_check_enabled" not in old_snapshot["runtime_overrides"]
monkeypatch.setenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", "true")
item["execution_config"] = execution_config(item)
assert item["execution_config"]["flash_final_check_enabled"] is True
# Simulate a later save between this task's snapshot and worker launch.
monkeypatch.setenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", "false")
_cmd, env = TaskQueueService._build_worker_invocation(
item,
"flash-config-snapshot",
item["session_id"],
item["goal"],
"flash",
TaskQueueService._task_target(item),
)
assert env["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] == "true"
assert os.environ["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] == "false"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_queue_worker_notifies_conversation(): async def test_queue_worker_notifies_conversation():
"""Verify queue_worker calls notify() when conversation_id is attached to task.""" """Verify queue_worker calls notify() when conversation_id is attached to task."""
@@ -123,6 +123,7 @@ async def test_acceptance_timeout_finishes_with_unknown_verdict(tmp_path, monkey
) )
assert data["task_status"] == "completed" assert data["task_status"] == "completed"
assert data["test_result"]["status"] == "inconclusive" assert data["test_result"]["status"] == "inconclusive"
assert "总预算已用尽(0.01 秒)" in data["test_result"]["reason"] assert "总预算已用尽(0.01 秒)" in data["test_result"]["reason"]
@@ -155,6 +156,23 @@ async def test_disabled_acceptance_does_not_call_a_model_or_claim_pass(
) )
check.assert_not_called() check.assert_not_called()
assert data["test_result"]["status"] == "inconclusive" assert data["test_result"]["status"] == "inconclusive"
assert data["test_result"]["verification_skipped"] is True
assert "未做 AI 判定" in data["test_result"]["reason"]
@pytest.mark.asyncio
async def test_skipping_review_does_not_hide_execution_failure(tmp_path, monkeypatch):
check = AsyncMock()
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path, disable_final_check=True),
"任务",
{"status": "failed", "explanation": "设备断开"},
)
check.assert_not_called()
assert data["task_status"] == "blocked"
assert data["test_result"]["status"] == "blocked"
assert not data["test_result"].get("verification_skipped")
@pytest.mark.asyncio @pytest.mark.asyncio
+18
View File
@@ -74,6 +74,24 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
assert payload["tool_fallback_model"] == "gpt-6-luna" assert payload["tool_fallback_model"] == "gpt-6-luna"
@pytest.mark.asyncio
async def test_config_can_save_flash_review_without_exposing_task_overrides(monkeypatch):
request = AsyncMock(
return_value={"flash_final_check_enabled": False, "recording_enabled": False}
)
monkeypatch.setattr(tools, "_request", request)
await tools.mobile_config(
section="capabilities",
changes={"flash_final_check_enabled": True},
persist_defaults=True,
)
assert request.call_args.args == ("POST", "/api/system/capability-settings")
assert request.call_args.kwargs["json"] == {
"flash_final_check_enabled": True,
"recording_enabled": False,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path): async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
source = tmp_path / "cases.xlsx" source = tmp_path / "cases.xlsx"
@@ -114,11 +114,24 @@ async def test_locked_work_profile_does_not_block_unlocked_device_owner():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monkeypatch): @pytest.mark.parametrize(
"profile,enabled,expected",
[
("flash", False, False),
("flash", True, True),
("Flash", False, False),
("pro", False, True),
("pro", True, True),
],
)
async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(
monkeypatch, profile, enabled, expected
):
import uuid import uuid
canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid) monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid)
monkeypatch.setenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", str(enabled).lower())
agent = Agent() agent = Agent()
assert agent._session_id == canonical_sid assert agent._session_id == canonical_sid
@@ -127,7 +140,7 @@ async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monk
task.id = "fallback-id" task.id = "fallback-id"
task.get_name.return_value = "task-name" task.get_name.return_value = "task-name"
task.request.goal = "test goal" task.request.goal = "test goal"
task.request.profile = "flash" task.request.profile = profile
task.request.task_name = "task-name" task.request.task_name = "task-name"
task.request.trace_path = "traces" task.request.trace_path = "traces"
@@ -138,6 +151,7 @@ async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monk
mock_data_engine_cls.return_value = mock_data_engine mock_data_engine_cls.return_value = mock_data_engine
agent._prepare_tracing(task=task, context=context) agent._prepare_tracing(task=task, context=context)
assert context.execution_setup.final_check_enabled is expected
mock_data_engine.start_session.assert_called_once() mock_data_engine.start_session.assert_called_once()
call_kwargs = mock_data_engine.start_session.call_args.kwargs call_kwargs = mock_data_engine.start_session.call_args.kwargs
+19
View File
@@ -44,6 +44,25 @@ def test_completed_execution_and_failed_test_are_independent():
assert resolve_trace_suffix("completed", outcome("failed")) == "_TESTFAIL" assert resolve_trace_suffix("completed", outcome("failed")) == "_TESTFAIL"
def test_skipped_review_metadata_survives_reload_without_changing_model_schema(tmp_path):
result = CaseTestResult(
status="inconclusive", reason="执行完成 · 未做 AI 判定", verification_skipped=True
)
directory = tmp_path / "task-skipped"
directory.mkdir()
(directory / "run_outcome.json").write_text(
json.dumps({"task_status": "completed", "test_result": result.model_dump()}),
encoding="utf-8",
)
loaded = read_case_result("task-skipped", "completed", root=tmp_path)
assert loaded == result.model_dump()
assert loaded["verification_skipped"] is True
assert "verification_skipped" not in CaseTestResult.model_json_schema()["properties"]
assert (
"verification_skipped" not in CaseTestResult(status="blocked", reason="失败").model_dump()
)
@pytest.mark.parametrize("status", ["failed", "cancelled", "interrupted", "skipped"]) @pytest.mark.parametrize("status", ["failed", "cancelled", "interrupted", "skipped"])
def test_incomplete_execution_does_not_inherit_a_pass(status): def test_incomplete_execution_does_not_inherit_a_pass(status):
result = resolve_case_result(outcome(), status, "环境初始化失败") result = resolve_case_result(outcome(), status, "环境初始化失败")
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.0.1" version = "2.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },