Compare commits

..
6 Commits
64 changed files with 2511 additions and 243 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ cd Artemis-Eapil
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页 .\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
``` ```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 国内下载源失败时自动尝试备用源,每个安装包均校验官方 SHA256;也可预先安装,或通过 `overrides.scrcpy_url` 和 `scrcpy_fallback_urls` 自定义 HTTPS 地址。
### macOS / Linux ### macOS / Linux
+66 -3
View File
@@ -13,6 +13,7 @@ from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import from apps.admin_console.services import report_export, task_import
from artemis.config.settings import settings from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result
router = APIRouter(prefix="/api/automation", tags=["automation"]) router = APIRouter(prefix="/api/automation", tags=["automation"])
@@ -23,6 +24,12 @@ class ReportPreferences(BaseModel):
) )
class CaseReviewRequest(BaseModel):
model_config = {"extra": "forbid"}
status: Literal["passed", "failed"]
reason: str = Field(default="", max_length=2000)
class BatchRequest(ReportPreferences): class BatchRequest(ReportPreferences):
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$") request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
@@ -122,7 +129,12 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}") @router.get("/batches/{batch_id}")
def batch_detail(batch_id: str): def batch_detail(batch_id: str):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)} jobs = store.jobs(batch_id)
for job in jobs:
job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store
)
return {**require("batch", batch_id), "jobs": jobs}
@router.delete("/batches/{batch_id}") @router.delete("/batches/{batch_id}")
@@ -201,6 +213,42 @@ async def download_report(
raise HTTPException(404, str(exc)) from exc raise HTTPException(404, str(exc)) from exc
@router.put("/reports/{session_id}/review")
def review_case(session_id: str, value: CaseReviewRequest):
report(session_id)
job = store.job(session_id) or {}
batch_id = job.get("batch_id")
if (
batch_id
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
):
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
with report_export.EXPORT_LOCK:
current = report(session_id)
if current["status"] not in {
"completed",
"success",
"failed",
"cancelled",
"interrupted",
"skipped",
}:
raise HTTPException(409, "请在任务结束后进行人工复验")
try:
store.save_case_review(session_id, value.status, value.reason)
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
if batch_id:
archive_service.forget(batch_id)
# A finishing worker can publish report_ready just after export;
# removing only the generated ZIP also prevents that stale cache hit.
try:
archive_service.archive_path(batch_id).unlink(missing_ok=True)
except OSError:
pass # An open download may hold the file; report_ready is already invalidated.
return report(session_id)
@router.get("/history") @router.get("/history")
def history( def history(
q: str = Query(default="", max_length=200), q: str = Query(default="", max_length=200),
@@ -209,6 +257,7 @@ def history(
end: float | None = None, end: float | None = None,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
size: int = Query(default=30, ge=1, le=100), size: int = Query(default=30, ge=1, le=100),
batch_id: str = Query(default="", max_length=100),
): ):
# SQL filtering/pagination searches the entire persisted history, not a UI cache. # SQL filtering/pagination searches the entire persisted history, not a UI cache.
query = """WITH history AS ( query = """WITH history AS (
@@ -220,7 +269,7 @@ def history(
COALESCE(json_extract(j.payload,'$.case.category'),'') category, COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory, COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions, COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
COALESCE(json_extract(j.payload,'$.error'),'') error COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'), SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
@@ -231,11 +280,22 @@ def history(
COALESCE(json_extract(j.payload,'$.case.category'),''), COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''), COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),''), COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
COALESCE(json_extract(j.payload,'$.error'),'') COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id) FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
) """ ) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')" where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
args = [] args = []
if batch_id == "__batch__":
where += " AND batch_id IS NOT NULL"
elif batch_id == "__single__":
where += " AND batch_id IS NULL"
elif batch_id:
try:
identifier(batch_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
where += " AND batch_id = ?"
args.append(batch_id)
if q: if q:
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')" where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
@@ -260,6 +320,9 @@ def history(
items = [dict(row) for row in rows] items = [dict(row) for row in rows]
jobs = store.jobs_for_sessions([item["session_id"] for item in items]) jobs = store.jobs_for_sessions([item["session_id"] for item in items])
for item in items: for item in items:
item["test_result"] = read_reviewed_result(
item["session_id"], item["status"], item.get("error") or "", storage=store
)
if job := jobs.get(item["session_id"]): if job := jobs.get(item["session_id"]):
item["name"] = task_import.execution_title(job) item["name"] = task_import.execution_title(job)
item["iteration"] = job.get("iteration", 1) item["iteration"] = job.get("iteration", 1)
+16 -1
View File
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException
from artemis.config import DB_PATH, TRACES_PATH from artemis.config import DB_PATH, TRACES_PATH
from artemis.runtime import trace_store from artemis.runtime import trace_store
from apps.admin_console.services.case_review import read_reviewed_result
try: try:
from admin_console.core.state import state from admin_console.core.state import state
@@ -82,6 +83,7 @@ def _list_sessions_sync():
job = jobs_by_id.get(s_id) job = jobs_by_id.get(s_id)
if job: if job:
case = job.get("case") or {} case = job.get("case") or {}
row_dict["batch_id"] = job.get("batch_id")
row_dict["case_id"] = case.get("case_id") or "" row_dict["case_id"] = case.get("case_id") or ""
row_dict["display_name"] = execution_title(job) 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["iteration"] = job.get("iteration", 1)
@@ -130,6 +132,13 @@ def _list_sessions_sync():
else: else:
row_dict["video_url"] = None row_dict["video_url"] = None
row_dict["recording_status"] = recording_status row_dict["recording_status"] = recording_status
row_dict["test_result"] = read_reviewed_result(
s_id,
row_dict.get("status") or "",
row_dict.get("error") or "",
root=TRACES_PATH,
storage=store,
)
agent_names = agent_names_by_session.get(s_id, []) agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile( sess_profile = model_service.resolve_session_profile(
@@ -189,7 +198,13 @@ async def get_session_details(session_id: str):
row = session_repo.get_session_by_id(session_id) row = session_repo.get_session_by_id(session_id)
if not row: if not row:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found") raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return dict(row) result = dict(row)
job = store.job(session_id) or {}
result["batch_id"] = job.get("batch_id")
result["test_result"] = read_reviewed_result(
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
)
return result
@router.get("/api/sessions/{session_id}/usage") @router.get("/api/sessions/{session_id}/usage")
+11
View File
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine from artemis.core.diagnostics import readiness_engine
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.task_import import execution_title
from artemis.runtime import DeviceExecutionLock, device_pool from artemis.runtime import DeviceExecutionLock, device_pool
from artemis.runtime.task_control import ( from artemis.runtime.task_control import (
add_instruction, add_instruction,
@@ -423,6 +425,15 @@ async def get_status():
seen_ids.add(sid) seen_ids.add(sid)
queue_data.append(g_item) queue_data.append(g_item)
batch_jobs = store.jobs_for_sessions(
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
)
queue_data = [{**item} for item in queue_data]
for item in [*queue_data, *active_tasks]:
if job := batch_jobs.get(str(item.get("session_id"))):
item["batch_id"] = job.get("batch_id")
item["display_name"] = execution_title(job)
if is_running: if is_running:
is_paused = state.is_paused is_paused = state.is_paused
return { return {
@@ -149,6 +149,52 @@ class AutomationStore:
def preferences(self) -> dict: def preferences(self) -> dict:
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]} return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
"""Append reviews atomically; invalidate the batch's generated report."""
identifier(session_id)
if status not in {"passed", "failed"}:
raise ValueError("人工复验结果只能为通过或不通过")
review = {
"status": status,
"reason": reason.strip()
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
"reviewed_at": time.time(),
}
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
(session_id,),
).fetchone()
record = json.loads(row[0]) if row else {"reviews": []}
record["reviews"].append(review)
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(
"case_review",
session_id,
json.dumps(record, ensure_ascii=False),
review["reviewed_at"],
),
)
job = conn.execute(
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
if job and job[0]:
batch = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
).fetchone()
if not batch or json.loads(batch[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能保存复验")
payload = json.loads(batch[0])
payload["report_ready"] = False
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(payload, ensure_ascii=False), review["reviewed_at"], job[0]),
)
conn.commit()
return review
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict: def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work.""" """Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
request_id = payload.get("request_id") request_id = payload.get("request_id")
@@ -247,6 +293,11 @@ class AutomationStore:
batch = json.loads(row[0]) batch = json.loads(row[0])
if batch.get("status") != "deleting": if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除") raise ValueError("批次尚未准备删除")
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,),
)
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,)) conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
conn.execute( conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?", "DELETE FROM automation_records WHERE kind='batch' AND id=?",
@@ -260,9 +311,14 @@ class AutomationStore:
with self.connection() as conn: with self.connection() as conn:
if session_id: if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,)) conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
(session_id,),
)
else: else:
conn.execute("DELETE FROM automation_jobs") conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'") conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
conn.commit() conn.commit()
@@ -0,0 +1,40 @@
"""Human review overlays the AI verdict without changing execution evidence."""
from pathlib import Path
from apps.admin_console.services.automation_store import AutomationStore
from artemis.runtime.case_result import read_case_result
def reviewed_result(
session_id: str, ai_result: dict | None, storage: AutomationStore
) -> dict | None:
original = {**ai_result, "source": "ai"} if ai_result else None
record = storage.get("case_review", session_id)
reviews = (record or {}).get("reviews") or []
if not reviews:
return original
latest = reviews[-1]
return {
"status": latest["status"],
"reason": latest["reason"],
"evidence": [],
"source": "human",
"reviewed_at": latest["reviewed_at"],
"ai_result": original,
}
def read_reviewed_result(
session_id: str,
execution_status: str,
error: str = "",
*,
storage: AutomationStore,
root: Path | None = None,
) -> dict | None:
return reviewed_result(
session_id,
read_case_result(session_id, execution_status, error, root=root),
storage,
)
+34 -3
View File
@@ -23,6 +23,8 @@ from apps.admin_console.database.repositories.session_repository import SessionR
from apps.admin_console.database.repositories.step_repository import StepRepository 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.automation_store import store, identifier
from apps.admin_console.services.task_import import execution_title from apps.admin_console.services.task_import import execution_title
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock() EXPORT_LOCK = threading.RLock()
@@ -62,6 +64,12 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str: def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result")
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
label = RESULT_LABELS[result["status"]]
if result.get("source"):
label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
return label
tests = (outcome or {}).get("tests") or {} tests = (outcome or {}).get("tests") or {}
if tests.get("failed", 0): if tests.get("failed", 0):
return "验证失败" return "验证失败"
@@ -179,6 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
case = meta.get("case") or {} case = meta.get("case") or {}
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown") status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
error = failure_reason(directory, status, meta.get("error")) error = failure_reason(directory, status, meta.get("error"))
ai_result = resolve_case_result(outcome, status, error)
test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store)
if test_result:
outcome = {**outcome, "test_result": test_result}
verification = verdict(outcome) verification = verdict(outcome)
if status in {"failed", "interrupted"} and verification.startswith("未验证"): if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)" verification = "未验证(任务异常终止)"
@@ -197,6 +209,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"expected": case.get("expected", ""), "expected": case.get("expected", ""),
"status": status, "status": status,
"verdict": verification, "verdict": verification,
"test_result": test_result,
"outcome": outcome, "outcome": outcome,
"content": content, "content": content,
"steps": steps, "steps": steps,
@@ -233,6 +246,18 @@ def metadata(report: dict) -> list[tuple]:
("预期结果", report["expected"] or "未指定"), ("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])), ("执行状态", STATUS.get(report["status"], report["status"])),
("验证结论", report["verdict"]), ("验证结论", report["verdict"]),
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
(
"原 AI 结论",
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
if (report.get("test_result") or {}).get("source") == "human"
else "—",
),
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
(
"验收证据",
"\n".join((report.get("test_result") or {}).get("evidence") or []) or "未记录",
),
("开始时间", time_text(report["start"])), ("开始时间", time_text(report["start"])),
("结束时间", time_text(report["end"])), ("结束时间", time_text(report["end"])),
("设备", report["device"]), ("设备", report["device"]),
@@ -434,6 +459,11 @@ def word_report(report: dict, path: Path):
if report["error"]: if report["error"]:
doc.add_heading("异常原因", 1) doc.add_heading("异常原因", 1)
doc.add_paragraph(text(report["error"])) doc.add_paragraph(text(report["error"]))
if result := report.get("test_result"):
doc.add_heading("测试结论与依据", 1)
doc.add_paragraph(text(result.get("reason") or "未记录"))
for evidence in result.get("evidence") or []:
doc.add_paragraph(text(evidence), style="List Bullet")
doc.add_heading("任务信息", 1) doc.add_heading("任务信息", 1)
details = [ details = [
(label, value) (label, value)
@@ -441,10 +471,11 @@ def word_report(report: dict, path: Path):
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"} if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
] ]
word_table(doc, details, (3.2, 13.3)) word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标", 2) doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
doc.add_paragraph(report["goal"] or "未记录") doc.add_paragraph(report["goal"] or "未记录")
doc.add_heading("预期结果", 2) if report["expected"]:
doc.add_paragraph(report["expected"] or "未指定") doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"])
if report.get("preconditions"): if report.get("preconditions"):
doc.add_paragraph("前置约束:" + text(report["preconditions"])) doc.add_paragraph("前置约束:" + text(report["preconditions"]))
if report.get("task_config"): if report.get("task_config"):
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.4.0", "version": "1.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.4.0", "version": "1.5.2",
"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": "1.4.0", "version": "1.5.2",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -44,7 +44,7 @@ limitations under the License.
</button> </button>
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button> <button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
</div> </div>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template> <ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue --> <!-- Dropdown Popover Panel for Task Queue -->
<ng-template <ng-template
@@ -61,6 +61,7 @@ limitations under the License.
<span class="material-symbols-outlined">list_alt</span> <span class="material-symbols-outlined">list_alt</span>
<span>任务队列</span> <span>任务队列</span>
</div> </div>
<app-task-batch-filter />
<div class="dropdown-section-label"> <div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span> <span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span> <span>当前队列 ({{ activeQueue().length }})</span>
@@ -86,6 +87,7 @@ limitations under the License.
} }
{{ getTaskStatus(session) | uiLabel }} {{ getTaskStatus(session) | uiLabel }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
<div class="item-header-actions"> <div class="item-header-actions">
@if (getDeviceSerial(session)) { @if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)"> <span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -549,7 +551,7 @@ limitations under the License.
} }
@for (phase of phases(); track trackPhase($index, phase); let first = $first) { @for (phase of phases(); track trackPhase($index, phase); let first = $first) {
<div class="phase-container"> <div class="phase-container">
@if (hasVisibleBlocks(phase)) { @if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
<div <div
class="phase-header" class="phase-header"
[class.first-phase]="first && startupWorkItems().length === 0" [class.first-phase]="first && startupWorkItems().length === 0"
@@ -584,6 +586,9 @@ limitations under the License.
and how each is judged; the chronological stream follows, and the and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). --> result rows close the block (see check-result below). -->
@if (isCheckerBlock(block)) { @if (isCheckerBlock(block)) {
@if (block.data.phase === 'outcome' && block.data.test_result) {
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
} @else {
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)"> <div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
<div class="check-header"> <div class="check-header">
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span> <span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
@@ -605,6 +610,7 @@ limitations under the License.
</ol> </ol>
} }
</div> </div>
}
} }
<!-- Text, tool calls, and actions share one chronological timeline. --> <!-- Text, tool calls, and actions share one chronological timeline. -->
@if (getSortedStepEvents(block.data).length > 0) { @if (getSortedStepEvents(block.data).length > 0) {
@@ -1268,7 +1274,7 @@ limitations under the License.
</div> </div>
} }
<!-- Check block result: one row per check item, keyed by its number above --> <!-- Check block result: one row per check item, keyed by its number above -->
@if (hasCheckerResult(block)) { @if (hasCheckerResult(block) && !(block.data.phase === 'outcome' && block.data.test_result)) {
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)"> <div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
@for (verdict of getCheckerVerdicts(block); track $index) { @for (verdict of getCheckerVerdicts(block); track $index) {
<div class="check-verdict" [ngClass]="verdict.status"> <div class="check-verdict" [ngClass]="verdict.status">
@@ -3702,9 +3702,12 @@ $stream-row-height: 25px;
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-size: 10.5px; box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700; font-weight: 700;
padding: 2px 5px; padding: 0 5px;
border-radius: 4px; border-radius: 4px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -25,6 +25,9 @@ import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service'; import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model'; import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
@@ -237,7 +240,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent], imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html', templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss', styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -388,7 +391,7 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => { public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => { const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s); const status = this.getTaskStatus(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status); return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
}); });
return list.sort((a, b) => { return list.sort((a, b) => {
const statusA = this.getTaskStatus(a); const statusA = this.getTaskStatus(a);
@@ -852,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
} }
// Top Nav Action Methods // Top Nav Action Methods
public openBatchQueue(): void {
this.isAutomationOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isTaskDropdownOpen.set(true);
}
public toggleTaskDropdown(event: Event): void { public toggleTaskDropdown(event: Event): void {
event.stopPropagation(); event.stopPropagation();
this.isTaskDropdownOpen.update(v => !v); this.isTaskDropdownOpen.update(v => !v);
@@ -1754,7 +1763,9 @@ export class AgentStreamComponent implements AfterViewInit {
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */ /** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
public getCheckerNotes(block: any): string[] { public getCheckerNotes(block: any): string[] {
const d = block?.data || {}; const d = block?.data || {};
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : []; if (d.phase === 'outcome') {
return [d.test_result?.reason, ...(d.test_result?.evidence || []), ...(Array.isArray(d.last_findings) ? d.last_findings : [])].filter(Boolean);
}
const notes: string[] = []; const notes: string[] = [];
if (Array.isArray(d.unmet_subgoals)) { if (Array.isArray(d.unmet_subgoals)) {
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`); for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
@@ -1796,7 +1807,9 @@ export class AgentStreamComponent implements AfterViewInit {
public getCheckerTone(block: any): string { public getCheckerTone(block: any): string {
const d = block?.data || {}; const d = block?.data || {};
if (d.phase === 'outcome') { if (d.phase === 'outcome') {
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive'); if (d.test_result) return d.test_result.status === 'blocked' ? 'inconclusive' : d.test_result.status;
if (d.tests?.failed) return 'failed';
return d.tests?.passed && !d.tests?.unchecked && !d.tests?.inconclusive ? 'passed' : 'inconclusive';
} }
if (d.isCompleted === false) return 'running'; if (d.isCompleted === false) return 'running';
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted'; if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
@@ -1811,12 +1824,12 @@ export class AgentStreamComponent implements AfterViewInit {
const d = block?.data || {}; const d = block?.data || {};
if (d.phase === 'outcome') { if (d.phase === 'outcome') {
const t = d.tests || {}; const t = d.tests || {};
const label = d.task_status === 'completed' ? "目标已完成" const label = d.test_result ? TEST_RESULT_LABELS[d.test_result.status as keyof typeof TEST_RESULT_LABELS]
: (d.task_status === 'blocked' ? "受阻" : "部分完成"); : (d.task_status === 'completed' ? "执行完成" : (d.task_status === 'blocked' ? "执行受阻" : "部分完成"));
const counts = [ const counts = [
[t.passed, 'passed'], [t.passed, '项通过'],
[t.failed, 'failed'], [t.failed, '项不通过'],
[t.inconclusive, 'inconclusive'], [t.inconclusive, '项无法确认'],
[t.unchecked, "未检查"] [t.unchecked, "未检查"]
] ]
.filter(([n]) => Number(n) > 0) .filter(([n]) => Number(n) > 0)
@@ -5,6 +5,7 @@
@if (error()) { <p class="error" role="alert">{{error()}}</p> } @if (error()) { <p class="error" role="alert">{{error()}}</p> }
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> } @if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
@if (tab() === 'reports') { @if (tab() === 'reports') {
@if(batch(); as b){<button type="button" class="back-to-batch" (click)="tab.set('batches')">返回批次:{{b.name}}</button>}
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p> <section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label> <div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div> <label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
@@ -14,10 +15,11 @@
@if (report(); as r) { @if (report(); as r) {
<div class="report-overview"> <div class="report-overview">
<strong class="report-title">{{r.name}}</strong> <strong class="report-title">{{r.name}}</strong>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div> </div>
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div> <div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a> <div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
@@ -83,6 +85,8 @@
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p> @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> <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"> <div class="actions">
<button type="button" (click)="showBatchQueue(b.id)">在任务队列查看此批次</button>
@if(agent.currentSessionId()){<button type="button" (click)="tab.set('reports')">查看当前任务报告</button>}
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>} @if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>} @if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>} @if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
@@ -102,6 +106,6 @@
</div> </div>
} }
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>} @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}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
</section>} </section>}
} }
@@ -1,4 +1,6 @@
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; } :host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px; .tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap; button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} } &.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
@@ -12,7 +12,9 @@ describe('AutomationPanelComponent batch deletion', () => {
currentSessionId: signal<string | null>(null), currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null), runningSessionId: signal<string | null>(null),
agentStatus: signal('idle'), agentStatus: signal('idle'),
sessions: signal<any[]>([]) sessions: signal<any[]>([]),
resultRevision: signal(0), batchFilter: signal(''), activeTab: signal('automation'),
selectSession: jasmine.createSpy('selectSession'), applyCaseReview: jasmine.createSpy('applyCaseReview')
}; };
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']); const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
@@ -23,6 +25,7 @@ describe('AutomationPanelComponent batch deletion', () => {
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null })); 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.delete.calls.reset();
http.post.calls.reset(); http.post.calls.reset();
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AutomationPanelComponent], imports: [AutomationPanelComponent],
providers: [ providers: [
@@ -117,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue(); expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
}); });
it('查看同批次用例时保留批次列表,可直接筛选该批次队列', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
component.tab.set('batches');
component.batch.set({id:'batch-1',name:'冒烟批次',status:'completed',jobs:[
{session_id:'task-a',status:'completed',case:{name:'用例 A',goal:'观察设置'}},
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]});
fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches');
expect(fixture.nativeElement.querySelectorAll('.job').length).toBe(2);
const queueRequested = jasmine.createSpy('queueRequested');
component.queueRequested.subscribe(queueRequested);
component.showBatchQueue('batch-1');
expect(agent.batchFilter()).toBe('batch-1'); expect(agent.activeTab()).toBe('tasks');
expect(queueRequested).toHaveBeenCalled();
fixture.destroy();
});
it('shows the failure reason and renders Chinese report structure with line breaks', () => { it('shows the failure reason and renders Chinese report structure with line breaks', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -1,4 +1,4 @@
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
@@ -6,6 +6,9 @@ import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service'; import { SystemService } from '../../services/system.service';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util'; import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model';
interface ArchiveProgress { interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed'; status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -18,10 +21,11 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule], selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
queueRequested = output<void>();
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient); agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
batches = signal<any[]>([]); preview = signal<any>(null); batches = signal<any[]>([]); preview = signal<any>(null);
@@ -48,6 +52,7 @@ export class AutomationPanelComponent {
const timer = setInterval(() => void this.refresh(), 5000); const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); }); destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
effect(() => { effect(() => {
this.agent.resultRevision();
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = ''; const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} }); if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
}); });
@@ -55,6 +60,9 @@ export class AutomationPanelComponent {
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; } formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; } label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); } renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
openBatchTask(id: string) { this.agent.selectSession(id, true); }
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; } duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); } downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
private async prepareBatchArchive(id: string) { private async prepareBatchArchive(id: string) {
@@ -58,6 +58,7 @@ limitations under the License.
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> } @if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
<!-- Tasks Tab Content --> <!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') { @if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter />
<!-- Active Queue Section --> <!-- Active Queue Section -->
<div class="queue-section-header"> <div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }}) <span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -82,6 +83,7 @@ limitations under the License.
} }
{{ getTaskStatus(session) | uiLabel }} {{ getTaskStatus(session) | uiLabel }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
<div class="task-card-header-actions"> <div class="task-card-header-actions">
@if (getDeviceSerial(session)) { @if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)"> <span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -119,7 +121,7 @@ limitations under the License.
</div> </div>
</div> </div>
<div class="task-goal" title="{{ session.initial_goal }}"> <div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
<div class="task-card-footer"> <div class="task-card-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-size: 10.5px; box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700; font-weight: 700;
padding: 2px 5px; padding: 0 5px;
border-radius: 4px; border-radius: 4px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -23,6 +23,8 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model'; import { Session } from '../../core/models/session.model';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util'; import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
@@ -31,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({ @Component({
selector: 'app-chat-interface', selector: 'app-chat-interface',
standalone: true, standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent], imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
templateUrl: './chat-interface.component.html', templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss', styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -54,7 +56,7 @@ export class ChatInterfaceComponent {
public activeQueue = computed(() => { public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => { const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s); const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending'; return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
}); });
return list.sort((a, b) => { return list.sort((a, b) => {
const statusA = this.getTaskStatus(a); const statusA = this.getTaskStatus(a);
@@ -43,6 +43,17 @@
border-color: rgba(203, 213, 225, 0.9); border-color: rgba(203, 213, 225, 0.9);
} }
.app-version {
padding: 0 10px 0 8px;
border-left: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 18px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.nav-tab-btn { .nav-tab-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -17,6 +17,7 @@
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../../package.json';
@Component({ @Component({
selector: 'app-nav-switcher', selector: 'app-nav-switcher',
@@ -43,9 +44,12 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
<span class="material-symbols-outlined tab-icon">space_dashboard</span> <span class="material-symbols-outlined tab-icon">space_dashboard</span>
<span class="tab-label">工作区</span> <span class="tab-label">工作区</span>
</a> </a>
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
</nav> </nav>
`, `,
changeDetection: ChangeDetectionStrategy.Eager, changeDetection: ChangeDetectionStrategy.Eager,
styleUrls: ['./nav-switcher.component.scss'] styleUrls: ['./nav-switcher.component.scss']
}) })
export class NavSwitcherComponent {} export class NavSwitcherComponent {
readonly version = version;
}
@@ -0,0 +1,26 @@
import { signal } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AgentService } from '../../services/agent.service';
import { TaskBatchFilterComponent } from './task-batch-filter.component';
describe('队列批次筛选', () => {
it('支持全部批量、单独任务及具体批次,可清除筛选', fakeAsync(() => {
const agent = {batchFilter:signal('batch-1')};
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AgentService,useValue:agent}]});
const fixture = TestBed.createComponent(TaskBatchFilterComponent);
const http = TestBed.inject(HttpTestingController);
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
fixture.detectChanges(); tick(); fixture.detectChanges();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
for(const value of ['__batch__','__single__','batch-2']){
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
expect(agent.batchFilter()).toBe(value);
}
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
expect(agent.batchFilter()).toBe('');
fixture.destroy(); http.verify();
}));
});
@@ -0,0 +1,33 @@
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
})
export class TaskBatchFilterComponent {
agent = inject(AgentService); private http = inject(HttpClient);
batches = signal<{id: string; name: string; created_at: number}[]>([]); error = signal('');
private destroyed = false;
constructor() {
this.load();
const timer = setInterval(() => this.load(), 15000);
inject(DestroyRef).onDestroy(() => { this.destroyed = true; clearInterval(timer); });
}
hasSelectedBatch() { return this.batches().some(batch => batch.id === this.agent.batchFilter()); }
load() {
this.http.get<{id: string; name: string; created_at: number}[]>('/api/automation/batches').subscribe({
next: batches => { if (!this.destroyed) { this.batches.set(batches); this.error.set(''); } },
error: () => { if (!this.destroyed) this.error.set('批次列表加载失败'); }
});
}
}
@@ -10,11 +10,44 @@ describe('历史任务检索', () => {
let http: HttpTestingController; let http: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(), TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]}); {provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
const agent = TestBed.inject(AgentService);
agent.batchFilter.set('batch-2'); fixture.detectChanges();
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
expect(filtered.request.params.get('page')).toBe('1');
expect(filtered.request.params.get('q')).toBe('设置');
filtered.flush({total:0,items:[]});
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
fixture.destroy();
});
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
{session_id:'failed-test', name:'预期校验', status:'completed', start_time:100, test_result:{status:'failed', reason:'预期出现成功,实际出现错误', evidence:['步骤 3 显示错误']}},
{session_id:'passed-test', name:'已验收', status:'completed', start_time:100, test_result:{status:'passed', reason:'实际与预期一致', evidence:['步骤 3 显示成功']}},
{session_id:'old-task', name:'旧任务', status:'completed', start_time:100}
]});
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('button.row');
expect(rows[0].textContent).toContain('执行完成');
expect(rows[0].querySelector('app-test-result-badge').textContent).toContain('测试不通过');
expect(rows[0].querySelector('.test-result-badge').title).toContain('实际出现错误');
expect(rows[1].querySelector('app-test-result-badge').textContent).toContain('测试通过');
expect(rows[2].querySelector('app-test-result-badge').textContent).toContain('未验证');
fixture.destroy();
});
it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => { it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked')); spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
@@ -1,30 +1,42 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { Component, DestroyRef, inject, signal } from '@angular/core'; import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent], selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>} @if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`, </section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`] styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
}) })
export class TaskHistoryComponent { export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1; agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>(); total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined; private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});} constructor(){
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
this.load();
effect(()=>{
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
if(nextFilter!==filter || nextRevision!==resultRevision){
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
}
});
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});} remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));} pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;} label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
@@ -34,6 +46,7 @@ export class TaskHistoryComponent {
load(){ load(){
const revision=++this.revision;this.loading.set(true);this.error.set(''); const revision=++this.revision;this.loading.set(true);this.error.set('');
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status); let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000); if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);} if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{ this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
@@ -0,0 +1,36 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞'
};
@Component({
selector: 'app-test-result-badge',
standalone: true,
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [`
:host{display:inline-flex;vertical-align:middle;max-width:100%}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
.material-symbols-outlined{font-size:16px;line-height:1}
.passed{background:#15803d;color:#fff;border-color:#15803d}
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
.compact .material-symbols-outlined{font-size:13px}
.prominent{padding:4px 9px;font-size:14px;gap:5px}
.prominent .material-symbols-outlined{font-size:18px}
`]
})
export class TestResultBadgeComponent {
result = input<CaseTestResult | null | undefined>();
executionStatus = input('');
compact = input(false);
prominent = input(false);
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
label = computed(() => this.result()?.source === 'human'
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
}
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestResultCardComponent } from './test-result-card.component';
describe('用例测试结果卡片', () => {
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
afterEach(() => TestBed.inject(HttpTestingController).verify());
it('显式展示不通过及原因,允许展开证据', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
fixture.detectChanges();
const view: HTMLElement = fixture.nativeElement;
expect(view.querySelector('[aria-label="用例测试结果"]')).not.toBeNull();
expect(view.querySelector('.prominent')?.textContent).toContain('测试不通过');
expect(view.querySelector('.reason')?.textContent).toContain('预期英文,实际为中文。');
const details = view.querySelector('details')!;
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.textContent).toContain('截图显示搜索设置');
});
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
fixture.componentRef.setInput('result', ai);
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '实际符合预期,已人工核对';
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
expect(saved).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('人工通过');
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
fixture.destroy();
});
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '人工发现异常';
fixture.nativeElement.querySelector('.confirm-fail').click();
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
fixture.detectChanges();
expect(fixture.componentInstance.note).toBe('人工发现异常');
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
fixture.destroy();
});
});
@@ -0,0 +1,76 @@
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
import { TestResultBadgeComponent } from './test-result-badge.component';
@Component({
selector: 'app-test-result-card',
standalone: true,
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
<p class="reason">{{result().reason}}</p>
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
@if(canReview()){
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
@if(reviewing()){
<div class="review-form" aria-label="人工复验">
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
</div>
}
}
</section>`,
styles: [`
:host{display:block;margin:8px 0}
.test-result-card{border:1px solid #cbd5e1;border-left:3px solid #64748b;border-radius:10px;padding:12px 16px;background:#f8fafc;color:#334155}
.passed{border-color:#bbf7d0;border-left-color:#15803d;background:#f0fdf4}
.failed{border-color:#fecaca;border-left-color:#b91c1c;background:#fef2f2}
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
.caption{font-size:12px;font-weight:600;color:#475569}
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
@media(max-width:480px){.test-result-card{padding:10px 12px}}
`]
})
export class TestResultCardComponent {
private http = inject(HttpClient);
result = input.required<CaseTestResult>();
sessionId = input<string | null>(null);
executionStatus = input('');
reviewSaved = output<CaseReviewResponse>();
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
constructor() {
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
}
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
saveReview(status: 'passed' | 'failed') {
const id = this.sessionId();
if (!id || !this.canReview() || this.saving()) return;
this.saving.set(true); this.error.set('');
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
next: response => {
this.saving.set(false); this.reviewSaved.emit(response);
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
},
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
});
}
}
@@ -21,8 +21,24 @@ export interface ModelInfo {
architecture?: string; architecture?: string;
} }
export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string;
evidence: string[];
source?: 'ai' | 'human';
reviewed_at?: number;
ai_result?: CaseTestResult | null;
}
export interface CaseReviewResponse {
id: string;
test_result: CaseTestResult;
verdict: string;
}
export interface TaskQueueItem { export interface TaskQueueItem {
session_id: string; session_id: string;
batch_id?: string | null;
goal: string; goal: string;
profile?: string; profile?: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
@@ -34,6 +50,7 @@ export interface TaskQueueItem {
export interface Session { export interface Session {
session_id: string; session_id: string;
batch_id?: string | null;
initial_goal: string; initial_goal: string;
start_time: number; start_time: number;
end_time?: number; end_time?: number;
@@ -41,6 +58,7 @@ export interface Session {
display_name?: string; display_name?: string;
case_id?: string; case_id?: string;
error?: string; error?: string;
test_result?: CaseTestResult | null;
video_url?: string; video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable'; recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo; model_info?: ModelInfo;
@@ -203,6 +203,7 @@ export interface CheckerBlockData {
route?: string; route?: string;
error?: string; error?: string;
task_status?: 'completed' | 'partial' | 'blocked' | string; task_status?: 'completed' | 'partial' | 'blocked' | string;
test_result?: import('./session.model').CaseTestResult | null;
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number }; tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
last_findings?: string[]; last_findings?: string[];
started_at?: number; started_at?: number;
@@ -18,7 +18,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model'; import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model'; import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model'; import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util'; import { extractStepReplayFrames } from '../utils/action-formatter.util';
@@ -85,6 +85,23 @@ export class AgentService {
private rawSessions = signal<Session[]>([]); private rawSessions = signal<Session[]>([]);
private pendingQueue = signal<Session[]>([]); private pendingQueue = signal<Session[]>([]);
public activeTasks = signal<any[]>([]); public activeTasks = signal<any[]>([]);
public batchFilter = signal('');
public resultRevision = signal(0);
public matchesBatchFilter(session: Session): boolean {
const filter = this.batchFilter();
if (!filter) return true;
if (filter === '__batch__') return !!session.batch_id;
if (filter === '__single__') return !session.batch_id;
return session.batch_id === filter;
}
public applyCaseReview(review: CaseReviewResponse): void {
this.rawSessions.update(items => items.map(session => session.session_id === review.id
? {...session, test_result: review.test_result} : session));
this.resultRevision.update(value => value + 1);
this.fetchSessions();
}
// Persistent tracking of active/pending sessions across polling boundaries // Persistent tracking of active/pending sessions across polling boundaries
private activeSessionTracking = new Map<string, Session>(); private activeSessionTracking = new Map<string, Session>();
@@ -129,6 +146,7 @@ export class AgentService {
} }
const finalSession: Session = { const finalSession: Session = {
...s, ...s,
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
status: sStatus, status: sStatus,
device_serial: serial, device_serial: serial,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
@@ -149,6 +167,7 @@ export class AgentService {
|| !!activeMatch; || !!activeMatch;
const finalPending: Session = { const finalPending: Session = {
...p, ...p,
batch_id: p.batch_id || activeMatch?.batch_id || null,
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'), status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
}; };
@@ -165,6 +184,8 @@ export class AgentService {
if (!existing) { if (!existing) {
const newSession: Session = { const newSession: Session = {
session_id: sid, session_id: sid,
batch_id: at.batch_id || null,
display_name: at.display_name,
initial_goal: at.goal || goal || '', initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000), start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: at.status || 'running', status: at.status || 'running',
@@ -1578,6 +1599,8 @@ export class AgentService {
if (typeof item === 'object' && item !== null) { if (typeof item === 'object' && item !== null) {
return { return {
session_id: item.session_id || `pending-task-${index}`, session_id: item.session_id || `pending-task-${index}`,
batch_id: item.batch_id || null,
display_name: item.display_name,
initial_goal: item.goal || '', initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index), start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending', status: item.status || 'pending',
+1
View File
@@ -14,6 +14,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "ES2022" "module": "ES2022"
+2 -1
View File
@@ -5,5 +5,6 @@
"assert_semantics": "# `assert` items (test assertions)\n- An `assert` item is a test expectation, not an instruction. A failed assertion is a legitimate, valuable test result: record it truthfully with evidence. Nothing will (or should) try to repair it.\n- Never mark an assert `failed` merely because evidence is absent — absence of captured evidence is `inconclusive` with an explicit note that the evidence is missing.\n- Precondition first: before judging an assert, confirm from the execution history (step replay, notes, anchor evidence) that the action(s) meant to trigger the asserted behavior were actually and correctly performed by the executor (the toggle was really tapped, the intended screen was really reached).\n- If that precondition is not established — the executor never performed the trigger, performed the wrong action, or the evidence cannot show it — output `inconclusive` and state explicitly that the precondition failed / the triggering action is not confirmed as executed. Never output `failed` here: a failed assert is attributed to the device under test, and an execution problem must not be reported as a device defect.\n- Output `failed` only when the precondition is established AND the device state, probe output, or recorded evidence still contradicts the expectation. The evidence must cite the concrete step(s) that performed the trigger plus the probe output / observation that contradicts the expectation, so the verdict reads as a device-defect diagnosis.\n- `suggestion` is not needed for assert verdicts.", "assert_semantics": "# `assert` items (test assertions)\n- An `assert` item is a test expectation, not an instruction. A failed assertion is a legitimate, valuable test result: record it truthfully with evidence. Nothing will (or should) try to repair it.\n- Never mark an assert `failed` merely because evidence is absent — absence of captured evidence is `inconclusive` with an explicit note that the evidence is missing.\n- Precondition first: before judging an assert, confirm from the execution history (step replay, notes, anchor evidence) that the action(s) meant to trigger the asserted behavior were actually and correctly performed by the executor (the toggle was really tapped, the intended screen was really reached).\n- If that precondition is not established — the executor never performed the trigger, performed the wrong action, or the evidence cannot show it — output `inconclusive` and state explicitly that the precondition failed / the triggering action is not confirmed as executed. Never output `failed` here: a failed assert is attributed to the device under test, and an execution problem must not be reported as a device defect.\n- Output `failed` only when the precondition is established AND the device state, probe output, or recorded evidence still contradicts the expectation. The evidence must cite the concrete step(s) that performed the trigger plus the probe output / observation that contradicts the expectation, so the verdict reads as a device-defect diagnosis.\n- `suggestion` is not needed for assert verdicts.",
"anchor_guide": "# Evidence anchoring (checkpoint audit)\n- You are auditing a subgoal at its completion moment. The authoritative evidence is the execution history around the anchor step (the anchor step's pre-action screenshot and the preceding step's post-action state) plus persistently probeable device state.\n- For transient expectations (toasts, dialogs, momentary states) and ordering expectations, you MUST inspect the recorded history near the anchor step — the live device has already moved on and is NOT valid evidence for them.\n- Prefer persistent state probes and recorded notes over inference from summaries.", "anchor_guide": "# Evidence anchoring (checkpoint audit)\n- You are auditing a subgoal at its completion moment. The authoritative evidence is the execution history around the anchor step (the anchor step's pre-action screenshot and the preceding step's post-action state) plus persistently probeable device state.\n- For transient expectations (toasts, dialogs, momentary states) and ordering expectations, you MUST inspect the recorded history near the anchor step — the live device has already moved on and is NOT valid evidence for them.\n- Prefer persistent state probes and recorded notes over inference from summaries.",
"final_guide": "# Final review (task exit audit)\n- Audit against the user's original goal as amended by the user guidance received during the run (listed in the prompt), not merely the plan's checkboxes. Guidance outranks the plan and its check lines: judge a check line the guidance retired or changed by the guidance, not by its original wording.\n- `@end` items: judge them using the FINAL device state provided to you.\n- `on_complete` items: cite the verdict ledger — an already-recorded PASS/FAIL from the item's checkpoint attempt is authoritative (an assert that ever failed stays failed). For items never checked, judge from the historical evidence near their subgoal's completion; if that evidence was never captured, output `inconclusive` stating the evidence is missing. NEVER re-judge an `on_complete` item from the current screen: the current screen only proves the present, not the past moment the item was declared for.\n- `unmet_subgoals`: list, verbatim, any plan subgoal marked complete whose verify criteria are demonstrably not met. Leave it empty otherwise; failed asserts alone do NOT make a subgoal unmet.", "final_guide": "# Final review (task exit audit)\n- Audit against the user's original goal as amended by the user guidance received during the run (listed in the prompt), not merely the plan's checkboxes. Guidance outranks the plan and its check lines: judge a check line the guidance retired or changed by the guidance, not by its original wording.\n- `@end` items: judge them using the FINAL device state provided to you.\n- `on_complete` items: cite the verdict ledger — an already-recorded PASS/FAIL from the item's checkpoint attempt is authoritative (an assert that ever failed stays failed). For items never checked, judge from the historical evidence near their subgoal's completion; if that evidence was never captured, output `inconclusive` stating the evidence is missing. NEVER re-judge an `on_complete` item from the current screen: the current screen only proves the present, not the past moment the item was declared for.\n- `unmet_subgoals`: list, verbatim, any plan subgoal marked complete whose verify criteria are demonstrably not met. Leave it empty otherwise; failed asserts alone do NOT make a subgoal unmet.",
"probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands." "probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands.",
"case_result_guide": "# Overall test result (final review, for Flash and Pro)\nAlways produce `test_result`, even when no plan/check items exist. Infer acceptance criteria directly from the user's original task prompt and subsequent guidance; do not ask for an extra expected-results form. Evaluate explicit expectations and the observable outcome of requested actions. Do not invent requirements, turn examples into expectations, or relax an expectation because the executor says it completed the task.\nCompare the executor's final explanation with actual screenshots, UI elements, step history, notes, probes and the verdict ledger. The explanation is a claim to verify, never proof by itself. A final screen only proves the final state: use recorded history for intermediate/transient results and required sequences. Use read-only tools only. Do not repair a failed test or repeat device actions.\n- passed: all requirements in the prompt/guidance are supported by concrete evidence, and no active assertion failed. Cite the specific step/screenshot/probe observations.\n- failed: the triggering actions and prerequisites are confirmed as correctly executed, but concrete evidence contradicts at least one stated expectation. Cite both the trigger and the contradictory result. A confirmed failure stays failed even when other requirements are unverified.\n- inconclusive: evidence is missing/ambiguous, the task has no discernible acceptance criterion, or the conclusion cannot be checked. Explain which requirement/evidence is missing; never claim passed or failed merely from the report or an absent exception.\n- blocked: an execution or environment obstacle prevented the required actions/validation. This is not evidence of a product defect.\nWrite `reason` in concise Simplified Chinese, explaining the expected and actual result. `evidence` contains concrete observations with step numbers when available. Preserve user-supplied values, UI text and logs exactly.\nThe test result evaluates the system under test, NOT whether the testing agent obeyed the instruction to inspect/report. Explicit expectations (预期, 应当, 必须, should, expected) are test assertions even without plan checkboxes. Reporting a mismatch correctly does not satisfy that expectation. Example: 'Check the search box; expected Search settings; if mismatched report the actual text and finish.' If the box visibly says 搜索设置, output test_result.status=failed, not passed: the inspection completed but the expected product result was not met. 'Report any failures and stop' never converts a detected failure into a pass. Likewise, confirming a missing required entry is a failed test when the correct page was reached. First list the expected product behavior in reason, then compare the actual evidence; judge execution/reporting separately. If no product expectation is given, judge the observable result of the requested actions."
} }
+23 -2
View File
@@ -46,6 +46,7 @@ from pydantic import BaseModel, Field, ValidationError
from artemis.core.tool_failure import ToolFailure, is_tool_failure from artemis.core.tool_failure import ToolFailure, is_tool_failure
from artemis.constants import CHECKER_MAX_ITERATIONS from artemis.constants import CHECKER_MAX_ITERATIONS
from artemis.runtime.case_result import CaseTestResult, normalize_case_result
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.data_engine.context_vars import CURRENT_TRACE_ID from artemis.data_engine.context_vars import CURRENT_TRACE_ID
from artemis.data_engine.trace import trace, trace_langchain_tool from artemis.data_engine.trace import trace, trace_langchain_tool
@@ -98,6 +99,10 @@ class CheckVerdict(BaseModel):
class CheckReport(BaseModel): class CheckReport(BaseModel):
verdicts: list[CheckVerdict] = Field(default_factory=list) verdicts: list[CheckVerdict] = Field(default_factory=list)
test_result: CaseTestResult | None = Field(
default=None,
description="Final review only: overall test verdict against the user's prompt and guidance, independent of execution completion.",
)
unmet_subgoals: list[str] = Field( unmet_subgoals: list[str] = Field(
default_factory=list, default_factory=list,
description=( description=(
@@ -287,6 +292,7 @@ def assemble_checker_prompt_segments(
segments.append(prompts.get("anchor_guide", "")) segments.append(prompts.get("anchor_guide", ""))
else: else:
segments.append(prompts.get("final_guide", "")) segments.append(prompts.get("final_guide", ""))
segments.append(prompts.get("case_result_guide", ""))
if probe_tool_registered: if probe_tool_registered:
segments.append(prompts.get("probe_guide", "")) segments.append(prompts.get("probe_guide", ""))
return "\n\n".join(s for s in segments if s) return "\n\n".join(s for s in segments if s)
@@ -458,7 +464,11 @@ def _normalize_report(report: CheckReport, check_items: list) -> CheckReport:
evidence="no verdict produced for this item", evidence="no verdict produced for this item",
) )
) )
return CheckReport(verdicts=verdicts, unmet_subgoals=list(report.unmet_subgoals)) return CheckReport(
verdicts=verdicts,
unmet_subgoals=list(report.unmet_subgoals),
test_result=normalize_case_result(report.test_result) if report.test_result else None,
)
async def _run_check_loop( async def _run_check_loop(
@@ -796,6 +806,7 @@ async def run_final_check(
ledger: list[dict], ledger: list[dict],
check_items, check_items,
attempt_id: str | None = None, attempt_id: str | None = None,
execution_report: dict | str | None = None,
) -> CheckReport: ) -> CheckReport:
"""Final review at task exit: audits the user's original goal and all """Final review at task exit: audits the user's original goal and all
declared check items, with the final screen state available.""" declared check items, with the final screen state available."""
@@ -828,6 +839,8 @@ async def run_final_check(
human_text = ( human_text = (
f"# User's Original Goal\n{goal}\n\n" f"# User's Original Goal\n{goal}\n\n"
f"# User guidance received during the run\n{_format_user_guidance(ctx, steps)}\n\n" f"# User guidance received during the run\n{_format_user_guidance(ctx, steps)}\n\n"
f"# Executor's Final Report (claims to verify, not independent evidence)\n"
f"{json.dumps(execution_report, ensure_ascii=False, default=str) if execution_report is not None else '(not provided)'}\n\n"
f"# Final Task Plan\n{plan_text or '(no plan)'}\n\n" f"# Final Task Plan\n{plan_text or '(no plan)'}\n\n"
f"# Declared Check Items\n{_format_check_items(items)}\n\n" f"# Declared Check Items\n{_format_check_items(items)}\n\n"
f"# Verdict Ledger (checkpoint results, authoritative for on_complete" f"# Verdict Ledger (checkpoint results, authoritative for on_complete"
@@ -852,7 +865,15 @@ async def run_final_check(
] ]
with CheckerStreamCapture(ctx) as capture: with CheckerStreamCapture(ctx) as capture:
try: try:
return await _run_check_loop(ctx, messages, tools, items) report = await _run_check_loop(ctx, messages, tools, items)
report.test_result = (
normalize_case_result(report.test_result)
if report.test_result
else CaseTestResult(
status="inconclusive", reason="验收未生成测试结论,不能确认用例是否通过。"
)
)
return report
finally: finally:
_persist_stream( _persist_stream(
ctx, capture, attempt_id=attempt_id, phase="final", checkpoint_id="final" ctx, capture, attempt_id=attempt_id, phase="final", checkpoint_id="final"
+113
View File
@@ -0,0 +1,113 @@
"""Flash's exit-only acceptance check uses the same read-only Checker as Pro."""
import asyncio
from artemis.agents.checker.checker import run_final_check
from artemis.context import ArtemisContext
from artemis.graph.checkpoints import (
RunOutcome,
TestSummary,
final_check_enabled,
publish_checker_event,
verdict_payloads,
write_run_outcome,
)
from artemis.runtime.case_result import CaseTestResult, resolve_case_result
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
async def settle_flash_outcome(
ctx: ArtemisContext,
goal: str,
execution_report: dict,
*,
final_report_received: bool = False,
) -> dict:
"""Check once at exit, without operating the device or rerunning the task."""
status = str(execution_report.get("status") or "failed")
result = None
if status == "completed" or final_report_received:
if final_check_enabled(ctx):
timeout = float(getattr(ctx.execution_setup, "checkpoint_timeout", 180.0))
publish_checker_event(
ctx,
{
"event": "attempt_started",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"check_items": [],
"status": "running",
},
)
try:
report = await asyncio.wait_for(
run_final_check(
ctx,
goal=goal,
plan_text="",
ledger=[],
check_items=[],
attempt_id="final#1",
execution_report=execution_report,
),
timeout=timeout,
)
result = report.test_result
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"status": "done",
"verdicts": verdict_payloads(report.verdicts),
"test_result": result.model_dump() if result else None,
"route": "end",
},
)
except Exception as exc:
detail = (
f"超过 {timeout:g} 秒"
if isinstance(exc, TimeoutError)
else f"{type(exc).__name__}: {exc}"
)
logger.warning(f"Flash 测试验收未完成:{detail}")
result = CaseTestResult(
status="inconclusive", reason=f"操作已结束,但测试验收未完成:{detail}。"
)
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "final",
"attempt_id": "final#1",
"checkpoint_id": "final",
"status": "error",
"error": result.reason,
"route": "end",
},
)
else:
result = CaseTestResult(
status="inconclusive", reason="已关闭最终检查,未进行测试结果验收。"
)
outcome = RunOutcome(
task_status="completed" if status == "completed" else "blocked", tests=TestSummary()
)
payload = outcome.model_dump()
if result:
payload["test_result"] = result.model_dump()
resolved = resolve_case_result(payload, status, str(execution_report.get("explanation") or ""))
payload["test_result"] = resolved.model_dump() if resolved else None
if ctx.data_engine:
write_run_outcome(
ctx.data_engine.base_dir, outcome, {"test_result": payload["test_result"]}
)
publish_checker_event(ctx, {"event": "run_outcome", "phase": "outcome", **payload})
ctx.run_outcome = payload
return payload
+2
View File
@@ -145,6 +145,7 @@ class FlashRunner:
def __init__(self, ctx: ArtemisContext, goal: str, max_turns: int | None = None): def __init__(self, ctx: ArtemisContext, goal: str, max_turns: int | None = None):
self.ctx = ctx self.ctx = ctx
self.goal = goal self.goal = goal
self.final_report_received = False
try: try:
cfg = load_agent_config() cfg = load_agent_config()
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
@@ -570,6 +571,7 @@ class FlashRunner:
) -> dict: ) -> dict:
"""Records the final step, acknowledges the tool call, and flushes.""" """Records the final step, acknowledges the tool call, and flushes."""
final_report = args final_report = args
self.final_report_received = True
if self.ctx.data_engine: if self.ctx.data_engine:
try: try:
if self.ctx.data_engine.current_step_id is None: if self.ctx.data_engine.current_step_id is None:
+46 -2
View File
@@ -247,6 +247,48 @@ def _ensure_maestro_not_installed(device_id: str) -> None:
_uninstall_package(device_id, MAESTRO_PACKAGE) _uninstall_package(device_id, MAESTRO_PACKAGE)
class UIAutomatorConnectionError(RuntimeError):
"""Terminal connection failure with device context and the original cause."""
def __init__(self, device_id: str, attempts: int, cause: Exception) -> None:
# The library appends its whole output buffer on every readiness poll.
# Keep the original exception as __cause__, but do not repeat those
# identical startup lines dozens of times in the user-facing failure.
lines = [line for arg in cause.args for line in str(arg).splitlines()]
detail = "\n".join(dict.fromkeys(lines)) or str(cause)
super().__init__(
f"UIAutomator2 连接失败(设备:{device_id};已尝试 {attempts} 次)。"
f"\n底层原因:{type(cause).__name__}: {detail}"
)
class _ManagedUIAutomatorDevice(u2.Device):
"""Release a failed launch before the constructor can lose its handle.
UIAutomator2 registers exit cleanup only after startup succeeds. Its
partially constructed Device is not returned when readiness fails, so
the caller cannot stop that launch. Intercept the startup boundary here
instead of modifying the installed library or killing device processes.
"""
def start_uiautomator(self) -> None:
try:
super().start_uiautomator()
except BaseException:
# _process is set only for a server launched by this instance;
# attaching to a server already on the device leaves it None.
if self._process is not None:
try:
super().stop_uiautomator(wait=False)
except Exception as cleanup_error:
# Cleanup must never hide the startup error or interrupt.
logger.warning(
f"Failed to stop this UIAutomator2 launch on {self._dev.serial}: "
f"{cleanup_error}"
)
raise
class UIAutomatorClient: class UIAutomatorClient:
"""UIAutomator2 client for Android screen data retrieval. """UIAutomator2 client for Android screen data retrieval.
@@ -297,7 +339,7 @@ class UIAutomatorClient:
if attempt: if attempt:
time.sleep(1.0 * attempt) time.sleep(1.0 * attempt)
try: try:
self._device = u2.connect(self._device_id) self._device = _ManagedUIAutomatorDevice(self._device_id)
break break
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
@@ -307,7 +349,9 @@ class UIAutomatorClient:
) )
else: else:
self._awake_strategy = None self._awake_strategy = None
raise last_error raise UIAutomatorConnectionError(
self._device_id, attempt + 1, last_error
) from last_error
logger.info("UIAutomator2 connected successfully") logger.info("UIAutomator2 connected successfully")
return self._device return self._device
+25 -3
View File
@@ -284,6 +284,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3 max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3
verify_blocked = False verify_blocked = False
case_result = None
blocked_findings: list[str] = [] blocked_findings: list[str] = []
if final_check_enabled(ctx): if final_check_enabled(ctx):
attempt_no = ctx.final_check_attempts + 1 attempt_no = ctx.final_check_attempts + 1
@@ -320,9 +321,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
), ),
timeout=checkpoint_timeout, timeout=checkpoint_timeout,
) )
case_result = report.test_result
except Exception as e: except Exception as e:
# Fail-open: release, but the verdict value stays inconclusive. # Fail-open: release, but the verdict value stays inconclusive.
logger.warning(f"Final check errored ({e}); releasing fail-open.") logger.warning(f"Final check errored ({e}); releasing fail-open.")
from artemis.runtime.case_result import CaseTestResult
detail = (
f"超过 {checkpoint_timeout:g} 秒"
if isinstance(e, TimeoutError)
else f"{type(e).__name__}: {e}"
)
case_result = CaseTestResult(
status="inconclusive", reason=f"最终验收未完成:{detail}。"
)
error_verdicts: list[dict] = [] error_verdicts: list[dict] = []
for ci in snapshot.all_check_items: for ci in snapshot.all_check_items:
append_ledger_record( append_ledger_record(
@@ -444,10 +456,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
verify_blocked=verify_blocked, verify_blocked=verify_blocked,
retired=retired if isinstance(retired, set) else None, retired=retired if isinstance(retired, set) else None,
) )
if snapshot.all_check_items or records or verify_blocked: if snapshot.all_check_items or records or verify_blocked or case_result is not None:
update["run_outcome"] = outcome.model_dump() from artemis.runtime.case_result import resolve_case_result
payload = outcome.model_dump()
if case_result:
payload["test_result"] = case_result.model_dump()
resolved = resolve_case_result(
payload, "completed" if outcome.task_status == "completed" else "failed"
)
payload["test_result"] = resolved.model_dump() if resolved else None
update["run_outcome"] = payload
# BLOCKED/partial wrap-ups carry the last findings in the metadata file. # BLOCKED/partial wrap-ups carry the last findings in the metadata file.
extra: dict = {"last_findings": blocked_findings} if blocked_findings else {} extra: dict = {"last_findings": blocked_findings} if blocked_findings else {}
extra["test_result"] = payload["test_result"]
# Which UI-hierarchy source served the run, so a report reader can tell a # Which UI-hierarchy source served the run, so a report reader can tell a
# helper run from a UIAutomator2 fallback without reading logs. # helper run from a UIAutomator2 fallback without reading logs.
from artemis.clients.screen_client_factory import hierarchy_backend_summary from artemis.clients.screen_client_factory import hierarchy_backend_summary
@@ -461,7 +483,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
{ {
"event": "run_outcome", "event": "run_outcome",
"phase": "outcome", "phase": "outcome",
**outcome.model_dump(), **payload,
"last_findings": list(blocked_findings), "last_findings": list(blocked_findings),
}, },
) )
+132
View File
@@ -0,0 +1,132 @@
"""A test verdict is independent of the task's execution/lifecycle status."""
import json
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from artemis.config import TRACES_PATH
class CaseTestResult(BaseModel):
status: Literal["passed", "failed", "inconclusive", "blocked"] = Field(
description="Judge the system under test against the user's expectations. A correctly executed inspection that reveals an expectation mismatch is failed, even if the executor completed and reported the mismatch correctly."
)
reason: str = Field(description="用简体中文说明提示词中的要求是否满足,以及不能判定的原因。")
evidence: list[str] = Field(
default_factory=list,
description="支撑结论的具体步骤、截图内容或只读探测结果;执行者声称完成不属于独立证据。",
)
RESULT_LABELS = {
"passed": "测试通过",
"failed": "测试不通过",
"inconclusive": "未验证",
"blocked": "测试阻塞",
}
_TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
def normalize_case_result(result: CaseTestResult) -> CaseTestResult:
evidence = [item.strip() for item in result.evidence if item.strip()]
if result.status in {"passed", "failed"} and not evidence:
return CaseTestResult(
status="inconclusive",
reason="验收结论缺少具体证据,不能确认测试是否通过。",
)
return result.model_copy(update={"evidence": evidence})
def resolve_case_result(
outcome: dict | None, execution_status: str, error: str = ""
) -> CaseTestResult | None:
"""Preserve confirmed defects; unfinished execution never becomes a pass."""
outcome = outcome if isinstance(outcome, dict) else {}
tests = outcome.get("tests") or {}
tests = tests if isinstance(tests, dict) else {}
candidate = None
if isinstance(outcome.get("test_result"), dict):
try:
candidate = normalize_case_result(CaseTestResult.model_validate(outcome["test_result"]))
except ValidationError:
candidate = None
failed_items = tests.get("failed_items") or []
if not isinstance(failed_items, list):
failed_items = []
failures = [
item
for item in failed_items
if isinstance(item, dict)
and item.get("kind", "assert") == "assert"
and item.get("evidence")
]
if failures:
return CaseTestResult(
status="failed",
reason=";".join(str(item.get("item_text") or "测试断言不满足") for item in failures),
evidence=[str(item["evidence"]) for item in failures],
)
if candidate and candidate.status == "failed":
return candidate
if execution_status not in _TERMINAL:
return None
if execution_status not in {"completed", "success"} or outcome.get("task_status") in {
"blocked",
"partial",
"failed",
"cancelled",
"interrupted",
}:
return CaseTestResult(
status="blocked",
reason=error
or str(
outcome.get("error")
or outcome.get("explanation")
or "任务未完整执行,无法完成验收。"
),
)
if candidate:
if candidate.status == "passed" and (tests.get("inconclusive") or tests.get("unchecked")):
return CaseTestResult(
status="inconclusive",
reason="部分检查项缺少证据或尚未验证。",
evidence=candidate.evidence,
)
return candidate
# Existing Pro runs already have a verdict ledger. A bare 'completed' from
# Flash (or a process exit code) is never used as a positive test result.
if tests.get("failed"):
return CaseTestResult(
status="blocked", reason="执行检查未满足;没有证据确认被测功能不符合预期。"
)
if tests.get("inconclusive") or tests.get("unchecked"):
return CaseTestResult(status="inconclusive", reason="部分检查项缺少证据或尚未验证。")
if tests.get("passed"):
return CaseTestResult(
status="passed",
reason="已记录的验证项全部通过。",
evidence=[f"已有检查结果:{tests['passed']} 项通过。"],
)
return CaseTestResult(
status="inconclusive", reason="没有保存测试验收结论;执行完成不等于测试通过。"
)
def read_case_result(
session_id: str, execution_status: str, error: str = "", *, root: Path | None = None
) -> dict | None:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
outcome = {}
try:
outcome = json.loads(
((root or TRACES_PATH) / session_id / "run_outcome.json").read_text(encoding="utf-8")
)
except (OSError, ValueError):
pass # Old tasks and active tasks may not have an outcome yet.
result = resolve_case_result(outcome, execution_status, error)
return result.model_dump() if result else None
+46 -7
View File
@@ -1,7 +1,7 @@
"""Cooperative, session-scoped task guidance and device lease parking.""" """Cooperative, session-scoped task guidance and device lease parking."""
import asyncio import asyncio
from collections.abc import Awaitable from collections.abc import Awaitable, Callable
import json import json
import logging import logging
import os import os
@@ -15,6 +15,26 @@ from artemis.runtime.device_lock import DeviceExecutionLock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Windows readers may briefly deny rename/delete even when directory permissions
# allow writes. Bound the total wait to 1.55s; other IO failures remain immediate.
_WINDOWS_ACCESS_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
def _retry_windows_access[T](operation: Callable[[], T]) -> T:
delays = iter(_WINDOWS_ACCESS_RETRY_DELAYS)
while True:
try:
return operation()
except OSError as exc:
delay = next(delays, None)
if delay is None or getattr(exc, "winerror", None) not in {5, 32, 33}:
raise
time.sleep(delay)
def _unlink_control_file(path: Path) -> None:
_retry_windows_access(lambda: path.unlink(missing_ok=True))
def control_dir(session_id: str) -> Path: def control_dir(session_id: str) -> Path:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id): if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
@@ -25,8 +45,21 @@ def control_dir(session_id: str) -> Path:
def _write_atomic(path: Path, data: dict) -> None: def _write_atomic(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") try:
os.replace(temporary, path) temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
_retry_windows_access(lambda: os.replace(temporary, path))
except OSError:
logger.exception("Cannot write task control file %s", path)
raise
finally:
try:
_unlink_control_file(temporary)
except OSError:
# Cleanup must never replace the original write error or turn a
# successful atomic write into a failed task/control submission.
logger.warning(
"Could not remove task control temporary file %s", temporary, exc_info=True
)
def read_state(session_id: str) -> dict: def read_state(session_id: str) -> dict:
@@ -39,7 +72,7 @@ def read_state(session_id: str) -> dict:
def request_pause(session_id: str) -> None: def request_pause(session_id: str) -> None:
(control_dir(session_id) / "resume.request").unlink(missing_ok=True) _unlink_control_file(control_dir(session_id) / "resume.request")
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()}) _write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
@@ -138,7 +171,7 @@ class TaskControl:
pause = self.directory / "pause.request" pause = self.directory / "pause.request"
if not pause.exists(): if not pause.exists():
return False return False
pause.unlink(missing_ok=True) _unlink_control_file(pause)
if on_pause is not None: if on_pause is not None:
await on_pause() await on_pause()
self.device_lock.release() self.device_lock.release()
@@ -146,7 +179,7 @@ class TaskControl:
resume = self.directory / "resume.request" resume = self.directory / "resume.request"
while not resume.exists(): while not resume.exists():
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
resume.unlink(missing_ok=True) _unlink_control_file(resume)
self._state("resuming") self._state("resuming")
previous = self.device_lock previous = self.device_lock
self.device_lock = DeviceExecutionLock( self.device_lock = DeviceExecutionLock(
@@ -208,4 +241,10 @@ class TaskControl:
def release(self) -> None: def release(self) -> None:
self.device_lock.release() self.device_lock.release()
(self.directory / "task_control_state.json").unlink(missing_ok=True) state_path = self.directory / "task_control_state.json"
try:
_unlink_control_file(state_path)
except OSError:
# The lease is already released. An ephemeral status file left
# behind must not override the task's result or block the queue.
logger.warning("Could not remove task control state file %s", state_path, exc_info=True)
+16 -6
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import uuid import uuid
from artemis.config import TRACES_PATH from artemis.config import TRACES_PATH
from artemis.runtime.case_result import resolve_case_result
def read_worker_outcome(session_id: str) -> dict: def read_worker_outcome(session_id: str) -> dict:
@@ -37,12 +38,19 @@ def save_failure_result(session_id: str, error: str, *, code: str = "execution_f
directory = TRACES_PATH / session_id directory = TRACES_PATH / session_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
payload: dict = {} payload: dict = {}
try: for name in ("result.json", "run_outcome.json"):
previous = json.loads((directory / "result.json").read_text(encoding="utf-8")) try:
if isinstance(previous, dict): previous = json.loads((directory / name).read_text(encoding="utf-8"))
payload.update(previous) if isinstance(previous, dict):
except (OSError, ValueError): prior_result = resolve_case_result(payload, "failed")
pass # Missing/truncated results are expected after a killed worker. prior_tests = payload.get("tests")
payload.update(previous)
if isinstance(prior_tests, dict) and isinstance(previous.get("tests"), dict):
payload["tests"] = {**prior_tests, **previous["tests"]}
if prior_result and prior_result.status == "failed":
payload["test_result"] = prior_result.model_dump()
except (OSError, ValueError):
pass # Missing/truncated results are expected after a killed worker.
payload.update( payload.update(
{ {
"status": "failed", "status": "failed",
@@ -54,6 +62,8 @@ def save_failure_result(session_id: str, error: str, *, code: str = "execution_f
"tests": payload.get("tests") or {}, "tests": payload.get("tests") or {},
} }
) )
test_result = resolve_case_result(payload, "failed", error)
payload["test_result"] = test_result.model_dump() if test_result else None
for name in ("result.json", "run_outcome.json"): for name in ("result.json", "run_outcome.json"):
target = directory / name target = directory / name
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp") temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
+22 -6
View File
@@ -701,7 +701,16 @@ class Agent:
runner = FlashRunner(context, goal=request.goal, max_turns=max_turns) runner = FlashRunner(context, goal=request.goal, max_turns=max_turns)
flash_result = await runner.run(state) flash_result = await runner.run(state)
output = flash_result from artemis.agents.checker.flash import settle_flash_outcome
run_outcome = await settle_flash_outcome(
context,
request.goal,
flash_result,
final_report_received=runner.final_report_received,
)
output = attach_test_summary(flash_result, run_outcome)
state.run_outcome = run_outcome
last_state_snapshot = state.model_dump() last_state_snapshot = state.model_dump()
status = flash_result.get("status") status = flash_result.get("status")
@@ -1545,17 +1554,21 @@ def attach_test_summary(output, run_outcome: dict | None):
+ int(tests.get("inconclusive", 0)) + int(tests.get("inconclusive", 0))
+ int(tests.get("unchecked", 0)) + int(tests.get("unchecked", 0))
) )
if total <= 0: test_result = run_outcome.get("test_result")
if total <= 0 and not test_result:
return output return output
summary = {"task_status": run_outcome.get("task_status"), **tests} summary = {"task_status": run_outcome.get("task_status"), **tests}
additions = {"test_summary": summary} if total > 0 else {}
if test_result:
additions["test_result"] = test_result
if isinstance(output, dict): if isinstance(output, dict):
merged = dict(output) merged = dict(output)
merged.setdefault("test_summary", summary) merged.update(additions)
return merged return merged
if output is None: if output is None:
return {"test_summary": summary} return additions
if isinstance(output, str): if isinstance(output, str):
return {"result": output, "test_summary": summary} return {"result": output, **additions}
# Typed/structured outputs keep their shape; the summary stays available in # Typed/structured outputs keep their shape; the summary stays available in
# run_outcome.json and the session state. # run_outcome.json and the session state.
return output return output
@@ -1566,6 +1579,9 @@ def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
if task_status != "completed": if task_status != "completed":
return "_FAIL" return "_FAIL"
tests = (run_outcome or {}).get("tests") or {} tests = (run_outcome or {}).get("tests") or {}
if int(tests.get("failed", 0)) > 0: if (
int(tests.get("failed", 0)) > 0
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed"
):
return "_TESTFAIL" return "_TESTFAIL"
return "_PASS" return "_PASS"
+6 -1
View File
@@ -7,7 +7,11 @@
"npm_registry": "https://registry.npmmirror.com", "npm_registry": "https://registry.npmmirror.com",
"node_mirror": "https://registry.npmmirror.com/-/binary/node", "node_mirror": "https://registry.npmmirror.com/-/binary/node",
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip", "adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip" "scrcpy_url": "https://ghfast.top/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"scrcpy_fallback_urls": [
"https://gh-proxy.com/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
"https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip"
]
}, },
"overrides": { "overrides": {
"python_index": "", "python_index": "",
@@ -16,6 +20,7 @@
"node_mirror": "", "node_mirror": "",
"adb_url": "", "adb_url": "",
"scrcpy_url": "", "scrcpy_url": "",
"scrcpy_fallback_urls": null,
"uv_installer_url": "" "uv_installer_url": ""
} }
} }
+7 -1
View File
@@ -34,7 +34,9 @@
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、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。 Windows 启动入口 `start.bat` / `start-cn.bat` 只检查并提示源码更新,`-SkipUpdate` 跳过提示。独立升级入口为 `update.bat` / `update-cn.bat`:先把升级程序复制到项目外的临时目录,检查本地修改,再终止本项目的 ARTEMIS 服务、MCP 和任务进程;之后仅做 Git 快进更新,并用新版启动程序准备环境,完成后由用户重新启动。其他项目、Codex、模拟器和共享 ADB 不受影响;运行中的任务会被中断,不会作为成功任务处理。1.1.1 及更新版本的发布 ZIP 含 Git 工作树,旧版无 `.git` 的 ZIP 需改用新版包或 Git 克隆。没有 Git、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。
Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。 Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。未运行过启动脚本的 Git 源码目录也可直接升级:先自动准备最小 Python 环境,快进源码后再安装新版依赖。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。双击升级失败时会保留原因并等待回车;自动化调用可设置 `ARTEMIS_NO_PAUSE=1`,输入重定向时也不会等待。
scrcpy 固定版本的国内下载采用多个来源,失败或 SHA256 不符时自动尝试下一来源,全部失败会显示 HTTP 状态、超时或校验原因。`config/downloads.json` 的 `overrides.scrcpy_url` 指定首选 HTTPS 地址,`overrides.scrcpy_fallback_urls` 指定备用地址数组;`null` 使用默认备用源,`[]` 禁用备用源。国内模式不自动回退到 GitHub 直连。
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。 MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
@@ -52,6 +54,10 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。 执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
UIAutomator2 启动失败时,先释放本次连接启动的服务,再按现有策略重试;最终错误保留设备编号、尝试次数和底层原因。初始化失败且没有操作步骤时,用例目标属于未执行、未验证,应先检查设备诊断和启动日志,不能据此认定被测功能失败。
Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中的要求核对执行结论、步骤截图及探测证据,无需另填预期结果。`test_result` 包含 `status`(`passed`、`failed`、`inconclusive`、`blocked`)、中文 `reason` 和 `evidence`;任务列表、报告与 MCP 使用同一结论。`completed` 仍只代表执行结束,可同时存在测试不通过。Flash 只在退出时验收,不添加计划或中途检查;沿用 Checker 的超时和开关,关闭、超时或缺少证据时显示未验证,不重跑用例。
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。 `mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。 任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
+5 -1
View File
@@ -8,10 +8,14 @@
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。 报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
“执行完成”与“验证通过”分开记录。没有实际检查记录时显示“未验证”,不会把模型结束执行当作业务测试通过。正在执行的任务也可以下载,但文件只代表下载时的快照。 “执行完成”与测试结论分开记录。预期直接写在任务提示词中即可;Flash 和 Pro 默认用只读终检核对提示词、用户指导、执行结论及实际证据,不增加必填表单。任务列表显式标注“测试通过 / 测试不通过 / 未验证 / 测试阻塞”,结果区域和 Word/Excel 同时列出判定依据及证据。终检关闭、出错或缺少证据时显示“未验证”,设备或执行异常不当作产品缺陷;已确认的断言失败会保留。正在执行的任务也可以下载,但文件只代表下载时的快照。
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。 报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
## Excel 批量导入 ## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。 1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
+15 -1
View File
@@ -271,7 +271,9 @@ def mobile_manage_task(
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable the run declared verification check items: machine-readable
passed/failed/inconclusive/unchecked counts plus failed-item details — passed/failed/inconclusive/unchecked counts plus failed-item details —
no need to parse report prose), and `progress` (Flash: no need to parse report prose), `test_result` (Flash and Pro: status
passed/failed/inconclusive/blocked, reason and evidence; completed only
means execution ended), and `progress` (Flash:
current turn, latest thought/action — intervene if turns climb without current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal). — check it still aligns with your goal).
@@ -419,11 +421,23 @@ def mobile_manage_task(
"task_status": run_outcome.get("task_status"), "task_status": run_outcome.get("task_status"),
**tests, **tests,
} }
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(
run_outcome, current_status, response.get("error") or ""
)
response["test_result"] = test_result.model_dump() if test_result else None
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
# Unreadable or malformed run_outcome.json: omit the summary. # Unreadable or malformed run_outcome.json: omit the summary.
pass pass
break break
if "test_result" not in response:
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(None, current_status, response.get("error") or "")
response["test_result"] = test_result.model_dump() if test_result else None
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive: if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
progress: dict[str, Any] = {} progress: dict[str, Any] = {}
+3 -1
View File
@@ -279,7 +279,9 @@ def mobile_run_task(
UI path. No step cap by default (history is compressed, and the agent UI path. No step cap by default (history is compressed, and the agent
can recall earlier steps and query the session recording), but no ADB can recall earlier steps and query the session recording), but no ADB
shell, no pre-execution safety net, no persistent plan or notes, and shell, no pre-execution safety net, no persistent plan or notes, and
no verification or written report — unsuitable for exploration, no midway checkpoints or persistent written notes. An exit-only read-only
Checker judges the prompt's expectations and returns test_result;
execution completion is separate from test pass. Unsuitable for exploration,
multi-branch recovery, structured monitoring/polling, or tasks that multi-branch recovery, structured monitoring/polling, or tasks that
must report back large amounts of detail. must report back large amounts of detail.
- **Pro**: complex, exploratory, or multi-branch tasks; continuous - **Pro**: complex, exploratory, or multi-branch tasks; continuous
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.4.0" version = "1.5.2"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+5 -1
View File
@@ -8,7 +8,7 @@ function Get-BootstrapText {
} }
function Initialize-ArtemisPython { function Initialize-ArtemisPython {
param([Parameter(Mandatory=$true)][string]$ProjectRoot) param([Parameter(Mandatory=$true)][string]$ProjectRoot, [switch]$RuntimeOnly)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) { if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
@@ -65,6 +65,10 @@ function Initialize-ArtemisPython {
throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。') throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
} }
# The frozen source updater uses only Python's standard library. Install
# project dependencies once, from the new lock file AFTER the fast-forward.
if ($RuntimeOnly) { return $pythonExe }
# A cheap check skips installation when the lock and installed packages agree. # A cheap check skips installation when the lock and installed packages agree.
# --inexact preserves any optional development tools already installed. # --inexact preserves any optional development tools already installed.
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' } $lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
+104 -5
View File
@@ -1,8 +1,95 @@
# Download settings are process-local; never edit global pip/npm/uv settings. # Download settings are process-local; never edit global pip/npm/uv settings.
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Get-DownloadText {
param([string]$English, [string]$Chinese)
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
return $English
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
$script:ArtemisDownloadError = ''
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
$previousErrors = $ErrorActionPreference
try {
# Native stderr is an ErrorRecord in PowerShell 5.1. Inspect the code,
# and log only HTTP status/exit code, never a URL containing credentials.
$ErrorActionPreference = 'Continue'
$status = & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec --proto '=https' --proto-redir '=https' --write-out '%{http_code}' -o $OutFile $Uri 2>$null
$curlCode = $LASTEXITCODE
if ($curlCode -eq 0 -and (Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { return $true }
$script:ArtemisDownloadError = if ($curlCode -eq 22) { "HTTP $status" }
elseif ($curlCode -eq 28) { Get-DownloadText "Download timed out after $TimeoutSec seconds" "下载超时($TimeoutSec 秒)" }
else { "curl exit $curlCode" }
# Do not repeat an HTTP rejection or a full timeout with a second client.
if ($curlCode -in @(22, 28)) { return $false }
} catch {
$script:ArtemisDownloadError = 'curl failed'
} finally {
$ErrorActionPreference = $previousErrors
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
}
$previousProgress = $ProgressPreference
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
if ((Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) {
$script:ArtemisDownloadError = ''
return $true
}
$script:ArtemisDownloadError = Get-DownloadText 'Empty download' '下载内容为空'
} catch {
$response = $_.Exception.Response
$script:ArtemisDownloadError = if ($response) { "HTTP $([int]$response.StatusCode)" }
else { Get-DownloadText 'Network/TLS error or download timeout' '网络、TLS 连接失败或下载超时' }
} finally {
$ProgressPreference = $previousProgress
if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue }
}
return $false
}
function Get-ArtemisFileSha256 {
param([Parameter(Mandatory=$true)][string]$Path)
# Do not depend on Get-FileHash module autoloading: PowerShell 7 can pass its
# own module search path to the Windows PowerShell 5.1 launcher.
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
finally { $stream.Dispose(); $hasher.Dispose() }
}
function Invoke-ArtemisVerifiedDownload {
param(
[Parameter(Mandatory=$true)][string[]]$Urls,
[Parameter(Mandatory=$true)][string]$OutFile,
[Parameter(Mandatory=$true)][string]$Sha256
)
$sources = @($Urls | Select-Object -Unique)
for ($index = 0; $index -lt $sources.Count; $index++) {
$sourceHost = ([Uri]$sources[$index]).Host
Write-Host (Get-DownloadText " [DOWNLOAD $($index + 1)/$($sources.Count)] scrcpy: $sourceHost" " [下载 $($index + 1)/$($sources.Count)] scrcpy:$sourceHost") -ForegroundColor Cyan
if (Invoke-DownloadFile -Uri $sources[$index] -OutFile $OutFile) {
$actualHash = Get-ArtemisFileSha256 -Path $OutFile
if ($actualHash -eq $Sha256) { return $true }
$script:ArtemisDownloadError = Get-DownloadText 'Official SHA256 verification failed; archive rejected' '官方 SHA256 校验失败,已拒绝此安装包'
Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
}
Write-Host (Get-DownloadText " [WARN] $sourceHost`: $script:ArtemisDownloadError" " [提示] $sourceHost 下载失败:$script:ArtemisDownloadError") -ForegroundColor Yellow
}
return $false
}
function Initialize-ArtemisDownloads { function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode) param([string]$ProjectRoot, [string]$Mode)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$path = Join-Path $ProjectRoot 'config\downloads.json' $path = Join-Path $ProjectRoot 'config\downloads.json'
$config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if (-not $Mode) { $Mode = $config.mode } if (-not $Mode) { $Mode = $config.mode }
@@ -28,6 +115,21 @@ function Initialize-ArtemisDownloads {
} }
} }
} }
$fallbacks = @('https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip')
if ($Mode -eq 'China' -and $null -ne $config.china.scrcpy_fallback_urls) { $fallbacks = $config.china.scrcpy_fallback_urls }
if ($null -ne $config.overrides.scrcpy_fallback_urls) { $fallbacks = $config.overrides.scrcpy_fallback_urls }
if ($fallbacks -isnot [Array]) { throw 'config/downloads.json: scrcpy_fallback_urls must be an array of HTTPS URLs.' }
foreach ($url in $fallbacks) {
$uri = $null
if ($url -isnot [string] -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') {
throw 'config/downloads.json: scrcpy_fallback_urls must contain only HTTPS URLs.'
}
}
$values.scrcpy_urls = @(@($values.scrcpy_url) + @($fallbacks) | Select-Object -Unique)
if ($Mode -eq 'China') {
# China mode must never silently fall back to a direct GitHub download.
$values.scrcpy_urls = @($values.scrcpy_urls | Where-Object { ([Uri]$_).Host -notin @('github.com', 'www.github.com') })
}
# Explicit caller environment settings take priority over the selected preset. # Explicit caller environment settings take priority over the selected preset.
if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX } if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX }
elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL } elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL }
@@ -42,7 +144,7 @@ function Initialize-ArtemisDownloads {
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally. # Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db' $values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values $script:ArtemisDownloads = [pscustomobject]$values
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mode -eq 'China') {
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
} else { } else {
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
@@ -67,10 +169,7 @@ function Install-ArtemisUvFromIndex {
if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' } if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' }
$zip = Join-Path $work 'uv.zip' $zip = Join-Path $work 'uv.zip'
if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' } if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' }
$hasher = [Security.Cryptography.SHA256]::Create() $digest = Get-ArtemisFileSha256 -Path $zip
$stream = [IO.File]::OpenRead($zip)
try { $digest = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') }
finally { $stream.Dispose(); $hasher.Dispose() }
if ($digest -ne $candidate.Groups['hash'].Value) { if ($digest -ne $candidate.Groups['hash'].Value) {
throw 'The downloaded uv package failed SHA256 verification.' throw 'The downloaded uv package failed SHA256 verification.'
} }
+43 -125
View File
@@ -33,10 +33,26 @@ function Get-LaunchText {
return $English return $English
} }
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
$ProjectVersion = 'unknown'
try {
$inProjectSection = $false
foreach ($line in Get-Content -LiteralPath (Join-Path $RootDir 'pyproject.toml') -Encoding UTF8 -ErrorAction Stop) {
if ($line -match '^\[project\]\s*$') { $inProjectSection = $true; continue }
if ($inProjectSection -and $line -match '^\[') { break }
if ($inProjectSection -and $line -match '^version\s*=\s*"([^"]+)"') {
$ProjectVersion = $Matches[1]
break
}
}
} catch {}
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files. # 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. # CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
if ($Help) { if ($Help) {
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]') Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
Write-Host '' Write-Host ''
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页') 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 ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
@@ -51,6 +67,7 @@ if ($Help) {
exit 0 exit 0
} }
Write-Host '======================================================' Write-Host '======================================================'
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') { if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像' Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
Write-Host '======================================================' Write-Host '======================================================'
@@ -67,8 +84,6 @@ try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
} catch {} } catch {}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
Set-Location $RootDir Set-Location $RootDir
# Startup never mutates source; upgrades run from a staged, independent process. # Startup never mutates source; upgrades run from a staged, independent process.
@@ -83,6 +98,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check & $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check
# Network/authentication failures are warnings, never a reason to prevent startup. # Network/authentication failures are warnings, never a reason to prevent startup.
} }
. (Join-Path $ScriptDir 'windows_tools.ps1')
. (Join-Path $ScriptDir 'download_config.ps1') . (Join-Path $ScriptDir 'download_config.ps1')
try { try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
@@ -96,55 +112,6 @@ Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI
Write-Host "======================================================" -ForegroundColor Cyan Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
function Start-LocalAdbServer { function Start-LocalAdbServer {
if (Test-CommandExists "adb") { if (Test-CommandExists "adb") {
$adbErrorPreference = $ErrorActionPreference $adbErrorPreference = $ErrorActionPreference
@@ -167,54 +134,6 @@ function Start-LocalAdbServer {
} }
} }
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Invoke-DownloadFile {
param(
[Parameter(Mandatory=$true)][string]$Uri,
[Parameter(Mandatory=$true)][string]$OutFile,
[int]$TimeoutSec = $ArtemisDownloads.timeout_seconds
)
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
# 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11)
if (Test-CommandExists "curl.exe") {
try {
& curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
}
} catch {}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
}
# 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs)
try {
$prevProgress = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
$ProgressPreference = $prevProgress
if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) {
return $true
}
} catch {
$ProgressPreference = $prevProgress
}
if (Test-Path $OutFile) {
Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
}
return $false
}
function Install-PortablePlatformTools { function Install-PortablePlatformTools {
$ptDir = "$env:LOCALAPPDATA\Programs\platform-tools" $ptDir = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$ptDir\adb.exe") { if (Test-Path "$ptDir\adb.exe") {
@@ -258,38 +177,37 @@ function Install-PortableScrcpy {
} }
Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) { if ($ArtemisDownloads.scrcpy_urls.Count -eq 0) {
Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] No scrcpy download source is configured. Set overrides.scrcpy_url.' ' [提示] 未配置可用的 scrcpy 下载源;请设置 overrides.scrcpy_url。') -ForegroundColor Yellow
return $false return $false
} }
$work = Join-Path ([IO.Path]::GetTempPath()) ('artemis-scrcpy-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $work -ErrorAction Stop | Out-Null
try { try {
$zipPath = "$env:TEMP\scrcpy-win64.zip" $zipPath = Join-Path $work 'scrcpy.zip'
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) { if (Invoke-ArtemisVerifiedDownload -Urls $ArtemisDownloads.scrcpy_urls -OutFile $zipPath -Sha256 $ArtemisDownloads.scrcpy_sha256) {
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() $extractDir = Join-Path $work 'extracted'
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) { Add-Type -AssemblyName System.IO.Compression.FileSystem
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.' [IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDir)
}
$extractDir = "$env:TEMP\scrcpy_extract"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
$extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1
if ($extractedFolder) { $contentDir = if (Test-Path -LiteralPath (Join-Path $extractDir 'scrcpy.exe')) { $extractDir }
if (-not (Test-Path $scrcpyDir)) { elseif ($extractedFolder) { $extractedFolder.FullName }
New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null else { throw 'scrcpy ZIP contains no application directory.' }
} if (-not (Test-Path -LiteralPath (Join-Path $contentDir 'scrcpy.exe'))) { throw 'scrcpy ZIP contains no scrcpy.exe.' }
Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force New-Item -ItemType Directory -Path $scrcpyDir -Force -ErrorAction Stop | Out-Null
} Get-ChildItem -LiteralPath $contentDir | Copy-Item -Destination $scrcpyDir -Recurse -Force -ErrorAction Stop
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue $env:PATH = "$scrcpyDir;$env:PATH"
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
if (Test-Path "$scrcpyDir\scrcpy.exe") { return $true
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
} }
} catch { } catch {
Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
} finally {
$fullWork = [IO.Path]::GetFullPath($work)
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if ($fullWork.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $fullWork -Leaf) -like 'artemis-scrcpy-*') {
Remove-Item -LiteralPath $fullWork -Recurse -Force -ErrorAction SilentlyContinue
}
} }
return $false return $false
} }
@@ -446,7 +364,7 @@ if ($missingCore.Count -gt 0) {
} }
if (-not (Test-CommandExists "scrcpy")) { if (-not (Test-CommandExists "scrcpy")) {
if (-not (Install-PortableScrcpy)) { if (-not (Install-PortableScrcpy)) {
Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow Write-Host (Get-LaunchText ' [WARN] All scrcpy sources failed. Check the HTTP/timeout/checksum error above, or configure overrides.scrcpy_url and scrcpy_fallback_urls in config/downloads.json.' ' [提示] scrcpy 下载源均未成功;请查看上方 HTTP、超时或校验错误,或在 config/downloads.json 中设置 overrides.scrcpy_url 与备用地址 scrcpy_fallback_urls。') -ForegroundColor Yellow
} }
} }
} }
+14 -2
View File
@@ -11,6 +11,7 @@ $env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8' $env:PYTHONIOENCODING = 'utf-8'
if ($Help) { if ($Help) {
Write-Host '用法:update.bat 或 update-cn.bat [-Help]' Write-Host '用法:update.bat 或 update-cn.bat [-Help]'
Write-Host '未运行过启动脚本也可升级;缺少的 Python 环境会自动准备。'
Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。' Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。'
Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。' Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。'
exit 0 exit 0
@@ -33,7 +34,7 @@ if (-not $Staged) {
if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' } if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' }
New-Item -ItemType Directory -Path $stage | Out-Null New-Item -ItemType Directory -Path $stage | Out-Null
$stageCreated = $true $stageCreated = $true
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py')) { foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py', 'windows_tools.ps1', 'download_config.ps1', 'bootstrap_python.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name) 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) $forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $stage 'upgrade.ps1'), '-Staged', '-ProjectRoot', $ProjectRoot)
@@ -47,13 +48,15 @@ if (-not $Staged) {
if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force } if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
Exit-ArtemisInstallationLock -Mutex $lock Exit-ArtemisInstallationLock -Mutex $lock
} }
if ($code -ne 0 -and $env:ARTEMIS_NO_PAUSE -ne '1' -and -not [Console]::IsInputRedirected) {
try { Read-Host '[升级失败] 请查看上方原因。按回车键关闭窗口' | Out-Null } catch {}
}
exit $code exit $code
} }
try { try {
Set-Location -LiteralPath $PSScriptRoot Set-Location -LiteralPath $PSScriptRoot
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe' $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 (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' } if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' }
# Refuse dirty checkouts before terminating the user's running processes. # Refuse dirty checkouts before terminating the user's running processes.
@@ -64,6 +67,15 @@ try {
Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow
. (Join-Path $PSScriptRoot 'upgrade_processes.ps1') . (Join-Path $PSScriptRoot 'upgrade_processes.ps1')
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
if (-not (Test-Path -LiteralPath $python)) {
Write-Host '[首次准备] 未找到项目 Python 环境,正在自动安装;随后拉取源码并同步新版依赖。' -ForegroundColor Cyan
. (Join-Path $PSScriptRoot 'windows_tools.ps1')
. (Join-Path $PSScriptRoot 'download_config.ps1')
. (Join-Path $PSScriptRoot 'bootstrap_python.ps1')
Initialize-ArtemisDownloads -ProjectRoot $ProjectRoot -Mode $Mirror
Update-EnvironmentPath
$python = Initialize-ArtemisPython -ProjectRoot $ProjectRoot -RuntimeOnly
}
& $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot & $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot
if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' } if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' }
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
+58
View File
@@ -0,0 +1,58 @@
# Process-local Windows tool discovery shared by startup and the frozen upgrader.
# Keep UTF-8 BOM for Windows PowerShell 5.1.
function Test-CommandExists {
param([string]$Command)
$res = Get-Command $Command -ErrorAction SilentlyContinue
return ($null -ne $res)
}
function Update-EnvironmentPath {
$standardDirs = @(
"$env:LOCALAPPDATA\Microsoft\WinGet\Links",
"$env:LOCALAPPDATA\Programs\node",
"$env:USERPROFILE\.local\share\node",
"$env:LOCALAPPDATA\Programs\scrcpy",
"$env:USERPROFILE\.local\share\scrcpy",
"$env:LOCALAPPDATA\Programs\platform-tools",
"$env:USERPROFILE\.local\share\platform-tools",
"$env:LOCALAPPDATA\Programs\uv",
"$env:LOCALAPPDATA\uv",
"$env:APPDATA\uv",
"$env:USERPROFILE\.cargo\bin",
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\scoop\shims",
"C:\ProgramData\chocolatey\bin",
"$env:LOCALAPPDATA\Android\Sdk\platform-tools",
"$env:LOCALAPPDATA\Android\android-sdk\platform-tools",
"$env:ProgramFiles\Android\platform-tools",
"${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools",
"$env:ProgramFiles\nodejs",
"${env:ProgramFiles(x86)}\nodejs",
"$env:APPDATA\npm"
)
if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs }
if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" }
if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" }
if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs }
if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs }
$regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
$currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique
$env:PATH = $currentPaths -join ";"
# Ensure user-space portable tools take absolute priority over system-level outdated versions
$portableNode = "$env:LOCALAPPDATA\Programs\node"
if (Test-Path "$portableNode\node.exe") {
$env:PATH = "$portableNode;$env:PATH"
}
$portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy"
if (Test-Path "$portableScrcpy\scrcpy.exe") {
$env:PATH = "$portableScrcpy;$env:PATH"
}
$portablePt = "$env:LOCALAPPDATA\Programs\platform-tools"
if (Test-Path "$portablePt\adb.exe") {
$env:PATH = "$portablePt;$env:PATH"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ Windows 启动脚本只提示源码更新。升级使用 `update.bat`(国内
## 结果、证据与交付 ## 结果、证据与交付
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。 - 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。Flash 和 Pro 默认根据任务提示词、用户指导、执行结论和实际证据进行只读终检,不需要另填预期结果。读取 `test_result.status`(`passed` 通过、`failed` 不通过、`inconclusive` 未验证、`blocked` 阻塞)、`reason` 与 `evidence`,结合验证摘要和 `mobile_inspect_trace` 的步骤/截图交付结果。执行失败不能直接认定产品缺陷;已确认的断言失败不得被后续成功覆盖。终检关闭、出错或证据不足时不得宣称通过。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。 - 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。 - 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。 - 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
+8
View File
@@ -25,9 +25,17 @@ NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}" cd "${SCRIPT_DIR}"
ARTEMIS_VERSION="$(awk '
/^\[project\]/ { in_project = 1; next }
/^\[/ { in_project = 0 }
in_project && /^version[[:space:]]*=/ { split($0, fields, "\""); print fields[2]; exit }
' "${SCRIPT_DIR}/pyproject.toml" 2>/dev/null || true)"
ARTEMIS_VERSION="${ARTEMIS_VERSION:-unknown}"
echo -e "${BOLD}${CYAN}======================================================${NC}" echo -e "${BOLD}${CYAN}======================================================${NC}"
echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}" echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
echo -e "${BOLD}${CYAN}======================================================${NC}" echo -e "${BOLD}${CYAN}======================================================${NC}"
echo -e "${CYAN} Version: v${ARTEMIS_VERSION}${NC}"
echo "" echo ""
# 1. Normalize PATH with standard toolchain and package manager paths # 1. Normalize PATH with standard toolchain and package manager paths
+175 -2
View File
@@ -780,7 +780,9 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
) )
report = reports.session_report(job["session_id"]) report = reports.session_report(job["session_id"])
assert "连续 3 次" in report["error"] assert "连续 3 次" in report["error"]
assert report["verdict"] == "未验证(任务异常终止)" assert report["verdict"] == "测试阻塞(AI确认)"
assert report["test_result"]["status"] == "blocked"
assert "连续 3 次" in report["test_result"]["reason"]
assert "abc123secret" not in reports.failure_reason( assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret" folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
) )
@@ -796,7 +798,10 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
) )
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx")) wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
assert "连续 3 次" in wb["任务结果"]["B15"].value assert any(
row[0].value == "异常原因" and "连续 3 次" in row[1].value
for row in wb["任务结果"].iter_rows()
)
assert wb["结论"]["B2"].value == "结果" assert wb["结论"]["B2"].value == "结果"
assert wb["结果表1"]["A2"].value == "系统" assert wb["结果表1"]["A2"].value == "系统"
assert wb["执行证据"]["A1"].font.name == "微软雅黑" assert wb["执行证据"]["A1"].font.name == "微软雅黑"
@@ -810,6 +815,174 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
] ]
def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.save_job(
{**job, "status": "completed", "started_at": time.time() - 50, "ended_at": time.time()}
)
result = {
"status": "failed",
"reason": "预期出现成功提示,实际显示错误",
"evidence": ["步骤 3 点击后截图显示错误提示"],
}
folder = reports.TRACES_PATH / job["session_id"]
folder.mkdir(parents=True)
(folder / "run_outcome.json").write_text(
json.dumps(
{"task_status": "completed", "tests": {}, "test_result": result}, ensure_ascii=False
),
encoding="utf-8",
)
history = client.get("/api/automation/history").json()["items"][0]
batch_job = client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0]
report = client.get(f"/api/automation/reports/{job['session_id']}").json()
for item in (history, batch_job, report):
assert item["status"] == "completed"
assert item["test_result"] == {**result, "source": "ai"}
assert report["verdict"] == "测试不通过(AI确认)"
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
doc_text = "\n".join(
cell.text for table in doc.tables for row in table.rows for cell in row.cells
)
doc_text += "\n" + "\n".join(paragraph.text for paragraph in doc.paragraphs)
assert result["reason"] in doc_text
assert result["evidence"][0] in doc_text
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
assert "测试不通过(AI确认)" in values
assert result["reason"] in values
def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_cache(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
from apps.admin_console.routers import sessions as sessions_api
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
jobs = isolated.jobs(batch["id"])
for job in jobs:
isolated.save_job({**job, "status": "completed", "ended_at": time.time()})
session_id = jobs[0]["session_id"]
ai = {"status": "failed", "reason": "AI识别到预期不匹配", "evidence": ["步骤 3 截图"]}
directory = reports.TRACES_PATH / session_id
directory.mkdir(parents=True)
outcome_file = directory / "run_outcome.json"
original = json.dumps({"task_status": "completed", "test_result": ai}, ensure_ascii=False)
outcome_file.write_text(original, encoding="utf-8")
archive = reports.export_batch(batch["id"])
isolated.put("batch", batch["id"], {**batch, "report_ready": True})
response = client.put(
f"/api/automation/reports/{session_id}/review",
json={"status": "passed", "reason": "已人工核对,符合预期"},
)
assert response.status_code == 200
assert response.json()["status"] == "completed"
assert response.json()["verdict"] == "测试通过(人工确认)"
assert outcome_file.read_text(encoding="utf-8") == original
assert not archive.exists()
assert not isolated.get("batch", batch["id"])["report_ready"]
assert len(isolated.get("case_review", session_id)["reviews"]) == 1
for result in (
response.json(),
client.get("/api/automation/history", params={"q": session_id}).json()["items"][0],
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
):
assert result["test_result"]["source"] == "human"
assert result["test_result"]["status"] == "passed"
assert result["test_result"]["ai_result"] == {**ai, "source": "ai"}
assert result["test_result"]["reviewed_at"] > 0
monkeypatch.setattr(sessions_api, "store", isolated)
monkeypatch.setattr(sessions_api, "TRACES_PATH", reports.TRACES_PATH)
monkeypatch.setattr(
sessions_api.session_repo,
"get_session_by_id",
lambda sid: {"session_id": sid, "status": "completed"},
)
client.app.include_router(sessions_api.router)
detail = client.get(f"/api/sessions/{session_id}").json()
assert detail["test_result"]["source"] == "human"
assert detail["batch_id"] == batch["id"]
downloaded = client.get(f"/api/automation/batches/{batch['id']}/archive")
assert downloaded.status_code == 200
with zipfile.ZipFile(BytesIO(downloaded.content)) as bundle:
wb = load_workbook(BytesIO(bundle.read("批次结果.xlsx")))
assert wb.active["D2"].value == "测试通过(人工确认)"
assert "AI确认" in wb.active["D3"].value
doc = Document(BytesIO(bundle.read("批次汇总.docx")))
assert doc.tables[0].rows[1].cells[3].text == "测试通过(人工确认)"
changed = client.put(f"/api/automation/reports/{session_id}/review", json={"status": "failed"})
assert changed.status_code == 200
assert changed.json()["verdict"] == "测试不通过(人工确认)"
assert len(isolated.get("case_review", session_id)["reviews"]) == 2
assert outcome_file.read_text(encoding="utf-8") == original
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
url = f"/api/automation/reports/{job['session_id']}/review"
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
assert client.put(url, json={"status": "inconclusive"}).status_code == 422
assert client.put(url, json={"status": "passed", "source": "human"}).status_code == 422
assert client.put(url, json={"status": "passed", "reason": "字" * 2001}).status_code == 422
assert (
client.put("/api/automation/reports/missing/review", json={"status": "passed"}).status_code
== 404
)
isolated.save_job({**job, "status": "completed"})
monkeypatch.setattr(api.archive_service, "status", lambda *args: {"status": "preparing"})
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
def test_batch_filter_searches_full_history_and_reviews_are_removed_with_batch(client, isolated):
first, second = [service.create_batch(payload(name=name)) for name in ("第一批", "第二批")]
for batch in (first, second):
for job in isolated.jobs(batch["id"]):
isolated.save_job({**job, "status": "completed"})
with isolated.connection() as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
("single-case", "单独检查", time.time(), "completed"),
)
conn.commit()
filtered = client.get(
"/api/automation/history",
params={"batch_id": first["id"], "q": "用例", "size": 1, "page": 2},
).json()
assert filtered["total"] == 2
assert len(filtered["items"]) == 1
assert filtered["items"][0]["batch_id"] == first["id"]
assert (
client.get("/api/automation/history", params={"batch_id": "__batch__"}).json()["total"] == 4
)
singles = client.get("/api/automation/history", params={"batch_id": "__single__"}).json()
assert [item["session_id"] for item in singles["items"]] == ["single-case"]
assert client.get("/api/automation/history", params={"batch_id": "bad/path"}).status_code == 400
reviewed_id = isolated.jobs(first["id"])[0]["session_id"]
isolated.save_case_review(reviewed_id, "passed", "人工核对")
isolated.begin_delete_batch(first["id"])
isolated.delete_batch(first["id"])
assert isolated.get("case_review", reviewed_id) is None
assert len(isolated.jobs(second["id"])) == 2
isolated.save_case_review("single-case", "failed", "不符合预期")
isolated.delete_history("single-case")
assert isolated.get("case_review", "single-case") is None
def test_task_material_zip_contains_ordered_evidence_and_reports( def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch client, isolated, tmp_path, monkeypatch
): ):
+118
View File
@@ -0,0 +1,118 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from artemis.agents.checker import flash
from artemis.agents.checker.checker import CheckReport
from artemis.context import ArtemisContext, ExecutionSetup
from artemis.runtime.case_result import CaseTestResult
def context(tmp_path, **setup):
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup(**setup)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = tmp_path
return ctx
@pytest.mark.asyncio
@pytest.mark.parametrize("verdict", ["passed", "failed", "inconclusive"])
async def test_flash_reuses_checker_with_original_prompt_and_final_report(
tmp_path, monkeypatch, verdict
):
result = CaseTestResult(
status=verdict, reason="提示词预期与实际对比", evidence=["步骤 3 截图显示实际状态"]
)
check = AsyncMock(return_value=CheckReport(test_result=result))
monkeypatch.setattr(flash, "run_final_check", check)
ctx = context(tmp_path)
execution = {"status": "completed", "explanation": "操作已执行"}
data = await flash.settle_flash_outcome(ctx, "打开设置,预期看到应用入口", execution)
assert check.call_count == 1
assert check.call_args.kwargs["goal"] == "打开设置,预期看到应用入口"
assert check.call_args.kwargs["execution_report"] == execution
assert check.call_args.kwargs["check_items"] == []
assert data["task_status"] == "completed"
assert data["test_result"]["status"] == verdict
assert (
json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))["test_result"]
== data["test_result"]
)
assert ctx.run_outcome == data
@pytest.mark.asyncio
async def test_acceptance_timeout_finishes_with_unknown_verdict(tmp_path, monkeypatch):
async def hanging_check(*args, **kwargs):
await asyncio.Event().wait()
monkeypatch.setattr(flash, "run_final_check", hanging_check)
data = await flash.settle_flash_outcome(
context(tmp_path, checkpoint_timeout=0.01), "任务", {"status": "completed"}
)
assert data["task_status"] == "completed"
assert data["test_result"]["status"] == "inconclusive"
assert "0.01 秒" in data["test_result"]["reason"]
@pytest.mark.asyncio
@pytest.mark.parametrize("setup", [{"disable_checker": True}, {"disable_final_check": True}])
async def test_disabled_acceptance_does_not_call_a_model_or_claim_pass(
tmp_path, monkeypatch, setup
):
check = AsyncMock()
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path, **setup), "任务", {"status": "completed"}
)
check.assert_not_called()
assert data["test_result"]["status"] == "inconclusive"
@pytest.mark.asyncio
async def test_execution_failure_is_blocked_without_another_model_retry(tmp_path, monkeypatch):
check = AsyncMock()
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path), "任务", {"status": "failed", "explanation": "设备观测失败"}
)
check.assert_not_called()
assert data["test_result"]["status"] == "blocked"
assert data["test_result"]["reason"] == "设备观测失败"
@pytest.mark.asyncio
async def test_reported_expectation_mismatch_is_checked_even_when_executor_reports_failed(
tmp_path, monkeypatch
):
check = AsyncMock(
return_value=CheckReport(
test_result=CaseTestResult(
status="failed",
reason="预期英文 Search settings,实际中文 搜索设置。",
evidence=["设置首页截图和 UI 元素 1 显示 搜索设置"],
)
)
)
monkeypatch.setattr(flash, "run_final_check", check)
data = await flash.settle_flash_outcome(
context(tmp_path),
"预期搜索框为英文 Search settings",
{"status": "failed", "explanation": "已检查,实际为中文"},
final_report_received=True,
)
check.assert_awaited_once()
assert data["test_result"]["status"] == "failed"
assert "Search settings" in data["test_result"]["reason"]
@pytest.mark.asyncio
async def test_cancellation_of_acceptance_propagates(tmp_path, monkeypatch):
check = AsyncMock(side_effect=asyncio.CancelledError)
monkeypatch.setattr(flash, "run_final_check", check)
with pytest.raises(asyncio.CancelledError):
await flash.settle_flash_outcome(context(tmp_path), "任务", {"status": "completed"})
assert not (tmp_path / "run_outcome.json").exists()
@@ -0,0 +1,250 @@
"""UIAutomator startup recovery, using the real library with isolated device IO."""
from dataclasses import dataclass, field
import json
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from uiautomator2 import base, core
from artemis.clients import ui_automator_client
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
@dataclass
class Clock:
now: float = 0.0
def time(self):
return self.now
def sleep(self, seconds):
self.now += seconds
class DeviceSocket:
def __init__(self, mode):
self.mode = mode
self.closed = threading.Event()
self.started = threading.Event()
def recv(self, _size):
if not self.started.is_set():
self.started.set()
output = b"[server] INFO: [UiAutomator2Server] Starting Server\n"
if self.mode == "conflict":
output += (
b"java.lang.IllegalStateException: UiAutomationService already registered!"
)
return output
self.closed.wait()
return b""
class DeviceConnection:
def __init__(self, mode):
self.conn = DeviceSocket(mode)
self.close_calls = 0
self.close_error = None
def close(self):
self.close_calls += 1
if self.close_error:
raise self.close_error
self.conn.closed.set()
@dataclass
class DeviceServer:
modes: list[str] = field(default_factory=lambda: ["not_ready"])
connections: list[DeviceConnection] = field(default_factory=list)
previous_closed_at_launch: list[bool] = field(default_factory=list)
clock: Clock = field(default_factory=Clock)
cleanup_callbacks: MagicMock = field(default_factory=MagicMock)
close_error: Exception | None = None
probe_error: BaseException | None = None
def shell(self, command, stream=False):
assert stream is True
assert "com.wetest.uia2.Main" in command
self.previous_closed_at_launch.append(all(c.conn.closed.is_set() for c in self.connections))
index = min(len(self.connections), len(self.modes) - 1)
connection = DeviceConnection(self.modes[index])
connection.close_error = self.close_error
self.connections.append(connection)
return connection
def http_request(self, _device, _port, method, path, *args, **kwargs):
if self.connections:
latest = self.connections[-1]
assert latest.conn.started.wait(timeout=2)
if self.probe_error is not None:
raise self.probe_error
if latest.conn.mode == "ready" and not latest.conn.closed.is_set():
if method == "POST":
return core.HTTPResponse(
json.dumps({"result": {"displayWidth": 1080}}).encode()
)
return core.HTTPResponse(b"pong")
raise core.HTTPError("simulated device server is not ready")
@pytest.fixture
def server(monkeypatch):
simulated = DeviceServer()
device = SimpleNamespace(serial="isolated-startup-device", shell=simulated.shell)
monkeypatch.setattr(base._BaseClient, "_wait_for_device", lambda _self: device)
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", lambda _self: None)
monkeypatch.setattr(core, "_http_request", simulated.http_request)
monkeypatch.setattr(core, "time", simulated.clock)
monkeypatch.setattr(core, "atexit", SimpleNamespace(register=simulated.cleanup_callbacks))
monkeypatch.setattr(ui_automator_client, "time", simulated.clock)
monkeypatch.setattr(ui_automator_client, "_ensure_maestro_not_installed", lambda _s: None)
monkeypatch.setattr(ui_automator_client, "ensure_device_awake", lambda _s: "simulated")
yield simulated
# Close fake sockets even when a regression leaves a real library reader alive.
for connection in simulated.connections:
connection.conn.closed.set()
def test_each_failed_launch_is_closed_before_retry_and_final_failure(server):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
assert server.clock.now == 93.0
assert server.cleanup_callbacks.call_count == 0
assert client._device is None
assert client._awake_strategy is None
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert "isolated-startup-device" in str(caught.value)
assert "3" in str(caught.value)
def test_retry_can_connect_after_failed_launch_was_stopped(server):
server.modes = ["not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
client.connect()
client.connect() # Cached device info also uses the real library RPC path.
assert len(server.connections) == 2
assert server.previous_closed_at_launch == [True, True]
assert server.connections[0].close_calls == 1
assert server.connections[1].close_calls == 0
assert server.cleanup_callbacks.call_count == 1
client.disconnect(stop_server=True)
assert server.connections[1].close_calls == 1
def test_later_connection_can_succeed_after_all_previous_attempts_failed(server):
server.modes = ["not_ready", "not_ready", "not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.connect()
assert len(server.connections) == 4
assert server.previous_closed_at_launch == [True, True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1, 0]
client.disconnect(stop_server=True)
assert server.connections[-1].close_calls == 1
def test_auto_fallback_cleans_failed_launches_before_next_attempt(server):
helper = MagicMock()
helper.connect.side_effect = HelperUnavailable("simulated helper unavailable")
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
uiautomator_factory=lambda: ui_automator_client.UIAutomatorClient(
"isolated-startup-device"
),
state_probe=lambda _serial: "device",
)
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.disconnect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
helper.disconnect.assert_called_once()
def test_service_conflict_closes_own_streams_and_preserves_cause(server):
server.modes = ["conflict"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="already registered") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.AccessibilityServiceAlreadyRegisteredError)
assert len(server.connections) == 3
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_cleanup_error_does_not_replace_original_startup_failure(server):
server.close_error = OSError("simulated cleanup connection error")
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_interrupt_during_startup_also_closes_own_stream(server):
server.probe_error = KeyboardInterrupt()
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(KeyboardInterrupt):
client.connect()
assert len(server.connections) == 1
assert server.connections[0].close_calls == 1
def test_failure_before_launch_does_not_stop_another_device_service(server, monkeypatch):
failure = OSError("simulated jar transfer failure")
def fail_before_launch(_self):
raise failure
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", fail_before_launch)
stop = MagicMock()
monkeypatch.setattr(core.BasicUiautomatorServer, "stop_uiautomator", stop)
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="jar transfer failure") as caught:
client.connect()
assert caught.value.__cause__ is failure
assert server.connections == []
stop.assert_not_called()
def test_default_helper_success_does_not_start_uiautomator(server):
factory = MagicMock()
client = FallbackScreenClient(
"isolated-startup-device",
helper=MagicMock(),
uiautomator_factory=factory,
state_probe=lambda _serial: "device",
)
client.connect()
client.disconnect()
factory.assert_not_called()
assert server.connections == []
+244
View File
@@ -1,12 +1,256 @@
"""Paused tasks yield their device and resume with a fresh lease.""" """Paused tasks yield their device and resume with a fresh lease."""
import asyncio import asyncio
import errno
import json
import logging
import os
from pathlib import Path
import threading
import pytest import pytest
import artemis.runtime.task_control as task_control import artemis.runtime.task_control as task_control
def _windows_access_error(code: int) -> PermissionError:
error = PermissionError(errno.EACCES, "state file is in use")
error.winerror = code
return error
@pytest.mark.parametrize("winerror", [5, 32, 33])
def test_running_state_recovers_from_temporary_windows_file_access(tmp_path, monkeypatch, winerror):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
path = task_control.control_dir("task-123") / "task_control_state.json"
task_control._write_atomic(path, {"status": "queued"})
replace = os.replace
attempts = []
delays = []
def busy_replace(source, target):
attempts.append(source)
if len(attempts) <= 2:
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
raise _windows_access_error(winerror)
return replace(source, target)
monkeypatch.setattr(task_control.os, "replace", busy_replace)
monkeypatch.setattr(task_control.time, "sleep", delays.append)
task_control.TaskControl("task-123", object())._state("running")
assert task_control.read_state("task-123")["status"] == "running"
assert delays and sum(delays) < 2
assert not list(path.parent.glob("*.tmp"))
def test_persistent_windows_access_error_is_bounded_and_preserves_old_state(tmp_path, monkeypatch):
path = tmp_path / "task_control_state.json"
task_control._write_atomic(path, {"status": "queued"})
error = _windows_access_error(5)
attempts = []
delays = []
def denied_replace(source, target):
attempts.append(source)
raise error
monkeypatch.setattr(task_control.os, "replace", denied_replace)
monkeypatch.setattr(task_control.time, "sleep", delays.append)
with pytest.raises(PermissionError) as raised:
task_control._write_atomic(path, {"status": "running"})
assert raised.value is error
assert 1 < len(attempts) < 20
assert sum(delays) < 2
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
assert not list(tmp_path.glob("*.tmp"))
@pytest.mark.parametrize(
"error", [PermissionError(errno.EACCES, "denied"), OSError(errno.ENOSPC, "disk full")]
)
def test_unrelated_file_errors_fail_without_retry_and_clean_temporary_file(
tmp_path, monkeypatch, error
):
path = tmp_path / "task_control_state.json"
task_control._write_atomic(path, {"status": "queued"})
attempts = []
delays = []
def failed_replace(source, target):
attempts.append(source)
raise error
monkeypatch.setattr(task_control.os, "replace", failed_replace)
monkeypatch.setattr(task_control.time, "sleep", delays.append)
with pytest.raises(OSError) as raised:
task_control._write_atomic(path, {"status": "running"})
assert raised.value is error
assert len(attempts) == 1
assert delays == []
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
assert not list(tmp_path.glob("*.tmp"))
def test_temporary_write_failure_cleans_partial_file(tmp_path, monkeypatch):
path = tmp_path / "task_control_state.json"
task_control._write_atomic(path, {"status": "queued"})
write_text = Path.write_text
error = OSError(errno.ENOSPC, "disk full")
def partial_write(target, content, **kwargs):
write_text(target, "{", **kwargs)
raise error
monkeypatch.setattr(Path, "write_text", partial_write)
with pytest.raises(OSError) as raised:
task_control._write_atomic(path, {"status": "running"})
assert raised.value is error
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
assert not list(tmp_path.glob("*.tmp"))
def test_busy_temporary_cleanup_preserves_original_write_error(tmp_path, monkeypatch, caplog):
path = tmp_path / "task_control_state.json"
task_control._write_atomic(path, {"status": "queued"})
error = OSError(errno.ENOSPC, "disk full")
unlink = Path.unlink
def failed_replace(source, target):
raise error
def busy_unlink(target, **kwargs):
if target.suffix == ".tmp":
raise _windows_access_error(32)
return unlink(target, **kwargs)
monkeypatch.setattr(task_control.os, "replace", failed_replace)
monkeypatch.setattr(Path, "unlink", busy_unlink)
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
try:
with caplog.at_level(logging.WARNING, logger=task_control.__name__):
with pytest.raises(OSError) as raised:
task_control._write_atomic(path, {"status": "running"})
assert raised.value is error
assert "Could not remove task control temporary file" in caplog.text
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
finally:
for temporary in tmp_path.glob("*.tmp"):
unlink(temporary)
@pytest.mark.parametrize(
"send_request,filename",
[
(task_control.request_pause, "pause.request"),
(task_control.request_resume, "resume.request"),
],
)
def test_repeated_control_requests_recover_from_file_sharing(
tmp_path, monkeypatch, send_request, filename
):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
send_request("task-123")
replace = os.replace
attempts = []
def busy_replace(source, target):
attempts.append(target)
if len(attempts) == 1:
raise _windows_access_error(32)
return replace(source, target)
monkeypatch.setattr(task_control.os, "replace", busy_replace)
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
send_request("task-123")
assert (
json.loads((task_control.control_dir("task-123") / filename).read_text(encoding="utf-8"))[
"created_at"
]
> 0
)
assert not list(task_control.control_dir("task-123").glob("*.tmp"))
@pytest.mark.parametrize("persistent", [False, True])
def test_release_does_not_fail_task_when_state_cleanup_is_busy(
tmp_path, monkeypatch, caplog, persistent
):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
released = []
class Lock:
def release(self):
released.append(True)
control = task_control.TaskControl("task-123", Lock())
control._state("running")
path = control.directory / "task_control_state.json"
unlink = Path.unlink
attempts = []
def busy_unlink(target, **kwargs):
if target == path:
attempts.append(target)
if persistent or len(attempts) == 1:
raise _windows_access_error(32)
return unlink(target, **kwargs)
monkeypatch.setattr(Path, "unlink", busy_unlink)
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
with caplog.at_level(logging.WARNING, logger=task_control.__name__):
control.release()
assert released == [True]
assert 1 < len(attempts) < 20
assert path.exists() == persistent
if persistent:
assert path.name in caplog.text
@pytest.mark.skipif(os.name != "nt", reason="Windows file sharing semantics")
def test_running_state_survives_real_windows_reader_handle(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
control = task_control.TaskControl("task-123", object())
control._state("queued")
path = control.directory / "task_control_state.json"
reader = path.open(encoding="utf-8")
blocked = threading.Event()
errors = []
replace = os.replace
def release_reader():
if blocked.wait(2):
reader.close()
def observe_replace(source, target):
try:
return replace(source, target)
except OSError as exc:
errors.append(exc.winerror)
blocked.set()
raise
releaser = threading.Thread(target=release_reader)
releaser.start()
monkeypatch.setattr(task_control.os, "replace", observe_replace)
try:
control._state("running")
finally:
blocked.set()
releaser.join(timeout=3)
reader.close()
assert not releaser.is_alive()
assert errors and set(errors).issubset({5, 32, 33})
assert task_control.read_state("task-123")["status"] == "running"
assert not list(control.directory.glob("*.tmp"))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch): async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
+132
View File
@@ -0,0 +1,132 @@
import json
import pytest
from artemis.runtime.case_result import (
CaseTestResult,
normalize_case_result,
read_case_result,
resolve_case_result,
)
from artemis.sdk.agent import attach_test_summary, resolve_trace_suffix
def outcome(status="passed", **extra):
return {
"task_status": "completed",
"test_result": {
"status": status,
"reason": "预期与实际对比",
"evidence": ["步骤 2 截图显示实际结果"],
},
**extra,
}
@pytest.mark.parametrize("status", ["passed", "failed"])
def test_a_claim_without_evidence_is_never_a_verdict(status):
result = normalize_case_result(
CaseTestResult(status=status, reason="执行者说完成了", evidence=[" "])
)
assert result.status == "inconclusive"
assert "证据" in result.reason
def test_completed_execution_and_failed_test_are_independent():
result = resolve_case_result(outcome("failed"), "completed")
assert result.status == "failed"
returned = attach_test_summary(
{"status": "completed", "explanation": "已完成", "test_result": {"status": "passed"}},
outcome("failed"),
)
assert returned["status"] == "completed"
assert returned["test_result"]["status"] == "failed"
assert resolve_trace_suffix("completed", outcome("failed")) == "_TESTFAIL"
@pytest.mark.parametrize("status", ["failed", "cancelled", "interrupted", "skipped"])
def test_incomplete_execution_does_not_inherit_a_pass(status):
result = resolve_case_result(outcome(), status, "环境初始化失败")
assert result.status == "blocked"
assert result.reason == "环境初始化失败"
def test_a_previously_confirmed_assertion_failure_survives_interruption_and_later_pass():
data = outcome(
tests={
"failed": 1,
"failed_items": [
{
"kind": "assert",
"item_text": "预期显示成功提示",
"evidence": "步骤 4 正确点击后显示错误提示",
}
],
}
)
result = resolve_case_result(data, "interrupted", "设备掉线")
assert result.status == "failed"
assert result.evidence == ["步骤 4 正确点击后显示错误提示"]
def test_navigation_failure_is_not_reported_as_a_product_defect():
result = resolve_case_result(
{
"tests": {
"failed": 1,
"failed_items": [
{"kind": "verify", "item_text": "打开设置", "evidence": "页面未打开"}
],
}
},
"failed",
)
assert result.status == "blocked"
def test_legacy_completed_flash_without_verification_is_inconclusive():
assert resolve_case_result({}, "completed").status == "inconclusive"
assert resolve_case_result(None, "running") is None
assert resolve_case_result({"tests": {"passed": 2}}, "completed").status == "passed"
def test_missing_checks_prevent_an_overall_pass():
assert (
resolve_case_result(outcome(tests={"passed": 1, "unchecked": 1}), "completed").status
== "inconclusive"
)
def test_session_reader_handles_missing_and_corrupt_outcomes_without_claiming_pass(tmp_path):
assert read_case_result("case", "completed", root=tmp_path)["status"] == "inconclusive"
directory = tmp_path / "case"
directory.mkdir()
(directory / "run_outcome.json").write_text("{", encoding="utf-8")
assert read_case_result("case", "completed", root=tmp_path)["status"] == "inconclusive"
(directory / "run_outcome.json").write_text(json.dumps(outcome("failed")), encoding="utf-8")
assert read_case_result("case", "completed", root=tmp_path)["status"] == "failed"
with pytest.raises(ValueError):
read_case_result("../case", "completed", root=tmp_path)
def test_failure_persistence_keeps_confirmed_test_failure_and_checkpoint_evidence(
tmp_path, monkeypatch
):
from artemis.runtime import task_result
monkeypatch.setattr(task_result, "TRACES_PATH", tmp_path)
directory = tmp_path / "case"
directory.mkdir()
(directory / "result.json").write_text(
json.dumps(outcome("failed", tests={"checkpoint": {"passed": False}})),
encoding="utf-8",
)
(directory / "run_outcome.json").write_text(
json.dumps(outcome("passed", tests={"passed": 1})), encoding="utf-8"
)
task_result.save_failure_result("case", "任务超时")
for name in ("result.json", "run_outcome.json"):
saved = json.loads((directory / name).read_text(encoding="utf-8"))
assert saved["test_result"]["status"] == "failed"
assert saved["tests"]["checkpoint"]["passed"] is False
assert saved["test_result"]["evidence"] == ["步骤 2 截图显示实际结果"]
+153 -7
View File
@@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif(
def powershell(tmp_path, script, **env): def powershell(tmp_path, script, **env):
path = tmp_path / "test.ps1" path = tmp_path / "test.ps1"
path.write_text( path.write_text(
"$ErrorActionPreference = 'Stop'\n. $env:DOWNLOAD_SCRIPT\n" + script, encoding="ascii" "$ErrorActionPreference = 'Stop'\n"
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
". $env:DOWNLOAD_SCRIPT\n" + script,
encoding="ascii",
) )
clean_env = { clean_env = {
key: value key: value
@@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env):
**env, **env,
}, },
capture_output=True, capture_output=True,
text=True, encoding="utf-8",
timeout=45, timeout=45,
) )
@@ -58,7 +61,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' } if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' } if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' } if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' } if (([Uri]$ArtemisDownloads.scrcpy_url).Host -eq 'github.com') { throw 'China scrcpy still uses GitHub' }
if ($ArtemisDownloads.scrcpy_urls.Count -lt 2) { throw 'China scrcpy has no fallback' }
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' } if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
Remove-Item Env:npm_config_registry Remove-Item Env:npm_config_registry
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
@@ -71,11 +75,153 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
def test_scrcpy_archive_is_checked_before_extraction(): @pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig") def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index( archive = tmp_path / "fixture.zip"
"Remove-Item $extractDir -Recurse" with zipfile.ZipFile(archive, "w") as package:
package.writestr("scrcpy-win64-v4.1/scrcpy.exe", b"verified executable")
package.writestr("scrcpy-win64-v4.1/scrcpy-server", b"verified server")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
install_dir = tmp_path / "user/Programs/scrcpy"
install_dir.mkdir(parents=True)
(install_dir / "keep.txt").write_text("user file")
temp_dir = tmp_path / "temporary"
temp_dir.mkdir()
result = powershell(
tmp_path,
"""
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
if ($errors) { throw 'Launcher parse error' }
$installer = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Install-PortableScrcpy' }, $true)
. ([scriptblock]::Create($installer.Extent.Text))
function Get-LaunchText { param($English, $Chinese); return $English }
$script:attempts = @()
function Invoke-DownloadFile {
param($Uri, $OutFile)
$script:attempts += $Uri
if ($Uri -eq 'https://first.example/scrcpy.zip' -or $env:FIRST_FAILURE -eq 'all') {
Set-Content -LiteralPath $OutFile -Value 'rejected content'
if ($env:FIRST_FAILURE -eq 'hash') { return $true }
$script:ArtemisDownloadError = if ($env:FIRST_FAILURE -eq 'timeout') { 'Download timed out' } else { 'HTTP 404' }
return $false
}
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile -Force
return $true
}
$script:ArtemisDownloads = [pscustomobject]@{
scrcpy_urls = @('https://first.example/scrcpy.zip', 'https://second.example/scrcpy.zip')
scrcpy_sha256 = $env:FIXTURE_HASH
}
$installed = Install-PortableScrcpy
Write-Host "INSTALLED=$installed"
Write-Host ('ATTEMPTS=' + ($script:attempts -join ','))
""",
LOCALAPPDATA=str(tmp_path / "user"),
TEMP=str(temp_dir),
TMP=str(temp_dir),
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
FIRST_FAILURE=first_failure,
FIXTURE_HASH=digest,
) )
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
assert "ATTEMPTS=https://first.example/scrcpy.zip,https://second.example/scrcpy.zip" in (
result.stdout
)
assert (install_dir / "keep.txt").read_text() == "user file"
assert not list(temp_dir.iterdir()), "Installer must clean its temporary download directory"
if first_failure == "all":
assert "INSTALLED=False" in result.stdout
assert "HTTP 404" in result.stdout
assert not (install_dir / "scrcpy.exe").exists()
else:
assert "INSTALLED=True" in result.stdout
assert (install_dir / "scrcpy.exe").read_bytes() == b"verified executable"
assert (install_dir / "scrcpy-server").read_bytes() == b"verified server"
expected_error = {
"http": "HTTP 404",
"timeout": "Download timed out",
"hash": "SHA256 verification failed",
}[first_failure]
assert expected_error in result.stdout
@pytest.mark.parametrize("curl_exit", [22, 28])
def test_download_reports_http_or_timeout_without_repeating_request(tmp_path, curl_exit):
result = powershell(
tmp_path,
"""
function curl.exe {
$target = $args[[Array]::IndexOf($args, '-o') + 1]
Set-Content -LiteralPath $target -Value 'partial download'
$global:LASTEXITCODE = [int]$env:CURL_EXIT
return '404'
}
function Invoke-WebRequest { throw 'HTTP rejection/timeout must not be retried with a second client' }
$destination = Join-Path $env:TEST_ROOT 'download.zip'
$downloaded = Invoke-DownloadFile -Uri 'https://missing.example/file.zip' -OutFile $destination -TimeoutSec 10
Write-Host "DOWNLOADED=$downloaded"
Write-Host "REASON=$script:ArtemisDownloadError"
if (Test-Path -LiteralPath $destination) { throw 'Partial download was left behind' }
""",
CURL_EXIT=str(curl_exit),
)
assert result.returncode == 0, result.stdout + result.stderr
assert "DOWNLOADED=False" in result.stdout
assert ("HTTP 404" if curl_exit == 22 else "timed out after 10 seconds") in result.stdout
@pytest.mark.parametrize(
"fallbacks,valid",
[
(["https://backup.example/scrcpy.zip"], True),
([], True),
(["http://insecure.example/scrcpy.zip"], False),
("https://not-an-array.example/scrcpy.zip", False),
([None], False),
],
)
def test_scrcpy_custom_sources_validate_urls_and_preserve_order(tmp_path, fallbacks, valid):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_url"] = "https://custom.example/scrcpy.zip"
config["overrides"]["scrcpy_fallback_urls"] = fallbacks
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
Write-Host ('SOURCES=' + ($ArtemisDownloads.scrcpy_urls -join ','))
""",
)
if valid:
assert result.returncode == 0, result.stdout + result.stderr
assert "SOURCES=" + ",".join(["https://custom.example/scrcpy.zip", *fallbacks]) in (
result.stdout
)
else:
assert result.returncode != 0
assert "HTTPS URLs" in result.stderr
def test_china_sources_never_fall_back_to_direct_github(tmp_path):
config = json.loads((ROOT / "config/downloads.json").read_text())
config["overrides"]["scrcpy_fallback_urls"] = [
"https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
config["china"]["scrcpy_url"], # Duplicate should be downloaded only once.
]
(tmp_path / "config").mkdir()
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
result = powershell(
tmp_path,
"""
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
if ($ArtemisDownloads.scrcpy_urls.Count -ne 1) { throw 'Invalid China sources' }
if (([Uri]$ArtemisDownloads.scrcpy_urls[0]).Host -eq 'github.com') { throw 'Direct GitHub fallback' }
""",
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("corrupt", [False, True]) @pytest.mark.parametrize("corrupt", [False, True])
+6 -3
View File
@@ -6,7 +6,7 @@ from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver from artemis.drivers.android.adb_driver import AndroidAdbDriver
@patch("artemis.clients.ui_automator_client.u2.connect") @patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch( @patch(
"artemis.clients.ui_automator_client.ensure_device_awake", "artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat", return_value="host_heartbeat",
@@ -25,7 +25,10 @@ def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
mock_connect.assert_called_once_with("device-123") mock_connect.assert_called_once_with("device-123")
@patch("artemis.clients.ui_automator_client.u2.connect", side_effect=RuntimeError("offline")) @patch(
"artemis.clients.ui_automator_client._ManagedUIAutomatorDevice",
side_effect=RuntimeError("offline"),
)
@patch( @patch(
"artemis.clients.ui_automator_client.ensure_device_awake", "artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat", return_value="host_heartbeat",
@@ -43,7 +46,7 @@ def test_failed_ui_connection_does_not_stop_process_awake_service(
assert client._awake_strategy is None assert client._awake_strategy is None
@patch("artemis.clients.ui_automator_client.u2.connect") @patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch( @patch(
"artemis.clients.ui_automator_client.ensure_device_awake", "artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat", return_value="host_heartbeat",
+67 -19
View File
@@ -11,6 +11,15 @@ import time
import pytest import pytest
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
UPGRADE_FILES = (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
"windows_tools.ps1",
"download_config.ps1",
"bootstrap_python.ps1",
)
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"), sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
reason="Windows PowerShell and uv required", reason="Windows PowerShell and uv required",
@@ -138,20 +147,22 @@ def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path
@pytest.mark.parametrize("prepare_exit", [0, 7]) @pytest.mark.parametrize("prepare_exit", [0, 7])
@pytest.mark.parametrize("initialized", [True, False])
def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure( def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
tmp_path, prepare_exit tmp_path, prepare_exit, initialized
): ):
if not shutil.which("git"): if not shutil.which("git"):
pytest.skip("Git required") pytest.skip("Git required")
author = tmp_path / "author" author = tmp_path / "author"
(author / "scripts").mkdir(parents=True) (author / "scripts").mkdir(parents=True)
for name in ( for name in UPGRADE_FILES:
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name) shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name)
(author / "config").mkdir()
shutil.copyfile(ROOT / "config/downloads.json", author / "config/downloads.json")
(author / "pyproject.toml").write_text(
'[project]\nname="upgrade-fixture"\nversion="0.1.0"\nrequires-python=">=3.12"\ndependencies=[]\n'
)
(author / "uv.lock").write_text('version = 1\nrequires-python = ">=3.12"\n')
for name in ("update.bat", "update-cn.bat"): for name in ("update.bat", "update-cn.bat"):
shutil.copyfile(ROOT / name, author / name) shutil.copyfile(ROOT / name, author / name)
(author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n") (author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n")
@@ -161,15 +172,18 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
git(author, "config", "user.email", "fixture@example.test") git(author, "config", "user.email", "fixture@example.test")
git(author, "add", ".") git(author, "add", ".")
git(author, "commit", "-m", "old version") git(author, "commit", "-m", "old version")
installed = tmp_path / "installed with spaces!" installed = tmp_path / "installed 中文 with spaces!"
git(tmp_path, "clone", "-b", "main", str(author), str(installed)) git(tmp_path, "clone", "-b", "main", str(author), str(installed))
make_venv(installed) if initialized:
make_venv(installed)
(installed / ".env").write_text("secret stays local") (installed / ".env").write_text("secret stays local")
# A running file AND both BAT wrappers are replaced during the merge. # 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.ps1").write_text("throw 'new updater must not run in this turn'\n")
(author / "scripts/upgrade_processes.ps1").write_text( (author / "scripts/upgrade_processes.ps1").write_text(
"throw 'new process helper must not run'\n" "throw 'new process helper must not run'\n"
) )
for name in ("windows_tools.ps1", "download_config.ps1", "bootstrap_python.ps1"):
(author / "scripts" / name).write_text("throw 'new bootstrap helper must not run'\n")
(author / "update.bat").write_text("@echo off\nexit /b 33\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 / "update-cn.bat").write_text("@echo off\nexit /b 34\n")
(author / "scripts/start.ps1").write_text( (author / "scripts/start.ps1").write_text(
@@ -184,17 +198,25 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
result = subprocess.run( result = subprocess.run(
["cmd.exe", "/d", "/c", "update-cn.bat"], ["cmd.exe", "/d", "/c", "update-cn.bat"],
cwd=installed, cwd=installed,
env={**os.environ, "UV_OFFLINE": "1", "ARTEMIS_NO_PAUSE": "1"},
capture_output=True, capture_output=True,
encoding="utf-8", encoding="utf-8",
timeout=45, timeout=45,
) )
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
assert result.stderr == "" if initialized:
assert result.stderr == ""
else:
# uv prints normal venv creation progress to stderr.
assert "Creating virtual environment" in result.stderr
assert (installed / "prepared.txt").read_text().strip() == "new version prepared" assert (installed / "prepared.txt").read_text().strip() == "new version prepared"
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD") assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert (installed / ".env").read_text() == "secret stays local" assert (installed / ".env").read_text() == "secret stays local"
assert ("升级完成" in result.stdout) == (prepare_exit == 0) assert ("升级完成" in result.stdout) == (prepare_exit == 0)
assert "new updater must not run" not in result.stdout assert "new updater must not run" not in result.stdout
assert "new bootstrap helper must not run" not in result.stdout
assert ("首次准备" in result.stdout) == (not initialized)
assert (installed / ".venv/Scripts/python.exe").exists()
@pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"]) @pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"])
@@ -262,7 +284,7 @@ def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(t
def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path): def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path):
root = tmp_path / "prepared project" root = tmp_path / "prepared project"
(root / "scripts").mkdir(parents=True) (root / "scripts").mkdir(parents=True)
for name in ("start.ps1", "installation_lock.ps1"): for name in ("start.ps1", "installation_lock.ps1", "windows_tools.ps1"):
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
(root / "scripts/download_config.ps1").write_text( (root / "scripts/download_config.ps1").write_text(
"function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n", "function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n",
@@ -314,17 +336,13 @@ def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_pat
assert (root / ".env").read_text() == "keep credentials" assert (root / ".env").read_text() == "keep credentials"
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path): @pytest.mark.parametrize("initialized", [True, False])
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path, initialized):
if not shutil.which("git"): if not shutil.which("git"):
pytest.skip("Git required") pytest.skip("Git required")
root = tmp_path / "dirty installation" root = tmp_path / "dirty installation"
(root / "scripts").mkdir(parents=True) (root / "scripts").mkdir(parents=True)
for name in ( for name in UPGRADE_FILES:
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
(root / ".gitignore").write_text(".venv/\n") (root / ".gitignore").write_text(".venv/\n")
source = root / "user.txt" source = root / "user.txt"
@@ -334,7 +352,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
git(root, "config", "user.email", "fixture@example.test") git(root, "config", "user.email", "fixture@example.test")
git(root, "add", ".") git(root, "add", ".")
git(root, "commit", "-m", "initial") git(root, "commit", "-m", "initial")
python = make_venv(root) python = make_venv(root) if initialized else sys.executable
source.write_text("user changes") source.write_text("user changes")
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"]) worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
try: try:
@@ -344,6 +362,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
assert "[STOP]" not in result.stdout assert "[STOP]" not in result.stdout
assert worker.poll() is None assert worker.poll() is None
assert source.read_text() == "user changes" assert source.read_text() == "user changes"
assert (root / ".venv").exists() == initialized
finally: finally:
# Windows venv redirectors can have a second interpreter process. # Windows venv redirectors can have a second interpreter process.
import psutil import psutil
@@ -355,3 +374,32 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
if child.is_running(): if child.is_running():
child.kill() child.kill()
worker.wait(timeout=5) worker.wait(timeout=5)
@pytest.mark.parametrize("launcher", ["update.bat", "update-cn.bat"])
def test_fresh_non_git_directory_reports_error_without_bootstrapping_or_hanging(tmp_path, launcher):
root = tmp_path / "未初始化 source with spaces!"
(root / "scripts").mkdir(parents=True)
for name in UPGRADE_FILES:
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
for name in ("update.bat", "update-cn.bat"):
shutil.copyfile(ROOT / name, root / name)
(root / ".env").write_text("existing config must stay local")
result = subprocess.run(
["cmd.exe", "/d", "/c", launcher],
cwd=root,
# Redirected input must not wait for a key, even without ARTEMIS_NO_PAUSE.
env={key: value for key, value in os.environ.items() if key != "ARTEMIS_NO_PAUSE"},
input="",
capture_output=True,
encoding="utf-8",
timeout=15,
)
assert result.returncode == 1
assert result.stderr == ""
assert "[升级失败]" in result.stdout
assert "此目录不含 Git 信息" in result.stdout
assert "首次准备" not in result.stdout
assert "�" not in result.stdout
assert not (root / ".venv").exists()
assert (root / ".env").read_text() == "existing config must stay local"
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.4.0" version = "1.5.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },