Compare commits

..
3 Commits
47 changed files with 1399 additions and 224 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
+61 -7
View File
@@ -13,7 +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 artemis.runtime.case_result import read_case_result 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"])
@@ -24,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)
@@ -125,8 +131,8 @@ def batch_by_request(request_id: str):
def batch_detail(batch_id: str): def batch_detail(batch_id: str):
jobs = store.jobs(batch_id) jobs = store.jobs(batch_id)
for job in jobs: for job in jobs:
job["test_result"] = read_case_result( job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "" job["session_id"], job["status"], job.get("error") or "", storage=store
) )
return {**require("batch", batch_id), "jobs": jobs} return {**require("batch", batch_id), "jobs": jobs}
@@ -207,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),
@@ -215,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 (
@@ -226,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'),
@@ -237,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("_", "\\_") + "%"
@@ -266,8 +320,8 @@ 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_case_result( item["test_result"] = read_reviewed_result(
item["session_id"], item["status"], item.get("error") or "" 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)
+12 -5
View File
@@ -20,7 +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 artemis.runtime.case_result import read_case_result 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
@@ -83,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)
@@ -131,8 +132,12 @@ 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_case_result( row_dict["test_result"] = read_reviewed_result(
s_id, row_dict.get("status") or "", row_dict.get("error") or "", root=TRACES_PATH 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, [])
@@ -194,8 +199,10 @@ async def get_session_details(session_id: str):
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")
result = dict(row) result = dict(row)
result["test_result"] = read_case_result( job = store.job(session_id) or {}
session_id, result.get("status") or "", root=TRACES_PATH 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 return result
+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,
)
+16 -4
View File
@@ -24,6 +24,7 @@ from apps.admin_console.database.repositories.step_repository import StepReposit
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 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()
@@ -65,7 +66,10 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str: def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result") result = (outcome or {}).get("test_result")
if isinstance(result, dict) and result.get("status") in RESULT_LABELS: if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
return RESULT_LABELS[result["status"]] 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 "验证失败"
@@ -183,9 +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"))
test_result = resolve_case_result(outcome, status, 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: if test_result:
outcome = {**outcome, "test_result": test_result.model_dump()} 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 = "未验证(任务异常终止)"
@@ -204,7 +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.model_dump() if test_result else None, "test_result": test_result,
"outcome": outcome, "outcome": outcome,
"content": content, "content": content,
"steps": steps, "steps": steps,
@@ -241,6 +246,13 @@ 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 "未记录"), ("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
( (
"验收证据", "验收证据",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.5.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.5.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.5.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,7 +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)" /> <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)">
@@ -586,7 +587,7 @@ limitations under the License.
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) { @if (block.data.phase === 'outcome' && block.data.test_result) {
<app-test-result-card [result]="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 { } @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">
@@ -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;
@@ -27,6 +27,7 @@ import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../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 { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.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';
@@ -239,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, TestResultBadgeComponent, TestResultCardComponent], 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
@@ -390,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);
@@ -854,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);
@@ -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>
@@ -15,7 +16,7 @@
<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><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></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" />} @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>@if(r.expected){<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>
@@ -84,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>}
@@ -103,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><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" />@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';
@@ -8,6 +8,7 @@ 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 { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.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';
@@ -24,6 +25,7 @@ interface ArchiveProgress {
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);
@@ -50,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: () => {} });
}); });
@@ -57,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,7 +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)" /> <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)">
@@ -120,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;
@@ -24,6 +24,7 @@ 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 { 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';
@@ -32,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, TestResultBadgeComponent], 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
@@ -55,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,27 @@ 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('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => { it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [ http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
@@ -1,6 +1,6 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component'; import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { Component, DestroyRef, inject, signal } from '@angular/core'; 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';
@@ -14,18 +14,29 @@ import { AgentService } from '../../services/agent.service';
@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><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" /> <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;}
@@ -35,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=>{
@@ -8,7 +8,7 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
@Component({ @Component({
selector: 'app-test-result-badge', selector: 'app-test-result-badge',
standalone: true, standalone: true,
template: `<span [class]="'test-result-badge ' + status()" [class.prominent]="prominent()" [title]="result()?.reason || '执行状态与测试结论分别记录'"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`, template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [` styles: [`
:host{display:inline-flex;vertical-align:middle;max-width:100%} :host{display:inline-flex;vertical-align:middle;max-width:100%}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569} .test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
@@ -17,6 +17,8 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c} .failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74} .inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24} .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{padding:4px 9px;font-size:14px;gap:5px}
.prominent .material-symbols-outlined{font-size:18px} .prominent .material-symbols-outlined{font-size:18px}
`] `]
@@ -24,8 +26,11 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
export class TestResultBadgeComponent { export class TestResultBadgeComponent {
result = input<CaseTestResult | null | undefined>(); result = input<CaseTestResult | null | undefined>();
executionStatus = input(''); executionStatus = input('');
compact = input(false);
prominent = input(false); prominent = input(false);
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending')); status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
label = computed(() => this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]); 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()]); icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
} }
@@ -1,7 +1,11 @@
import { TestBed } from '@angular/core/testing'; 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'; import { TestResultCardComponent } from './test-result-card.component';
describe('用例测试结果卡片', () => { describe('用例测试结果卡片', () => {
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
afterEach(() => TestBed.inject(HttpTestingController).verify());
it('显式展示不通过及原因,允许展开证据', () => { it('显式展示不通过及原因,允许展开证据', () => {
const fixture = TestBed.createComponent(TestResultCardComponent); const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']}); fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
@@ -16,4 +20,46 @@ describe('用例测试结果卡片', () => {
expect(details.open).toBeTrue(); expect(details.open).toBeTrue();
expect(details.textContent).toContain('截图显示搜索设置'); 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();
});
}); });
@@ -1,15 +1,30 @@
import { Component, input } from '@angular/core'; import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model'; 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'; import { TestResultBadgeComponent } from './test-result-badge.component';
@Component({ @Component({
selector: 'app-test-result-card', selector: 'app-test-result-card',
standalone: true, standalone: true,
imports: [TestResultBadgeComponent], imports: [TestResultBadgeComponent, DatePipe, FormsModule],
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status"> template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
<header><span class="caption">用例测试结果</span><app-test-result-badge [result]="result()" [prominent]="true" /></header> <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> <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(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>`, </section>`,
styles: [` styles: [`
:host{display:block;margin:8px 0} :host{display:block;margin:8px 0}
@@ -19,13 +34,43 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb} .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} 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{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} .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} 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} 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} 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}} @media(max-width:480px){.test-result-card{padding:10px 12px}}
`] `]
}) })
export class TestResultCardComponent { export class TestResultCardComponent {
private http = inject(HttpClient);
result = input.required<CaseTestResult>(); 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 || '复验保存失败,请重试'); }
});
}
} }
@@ -25,10 +25,20 @@ export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked'; status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string; reason: string;
evidence: 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';
@@ -40,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;
@@ -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"
+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)
+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": ""
} }
} }
+3 -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 进程。
+4
View File
@@ -12,6 +12,10 @@
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。 报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
## Excel 批量导入 ## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。 1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.5.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"
}
}
+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
+127 -4
View File
@@ -780,7 +780,7 @@ 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 report["test_result"]["status"] == "blocked"
assert "连续 3 次" in report["test_result"]["reason"] assert "连续 3 次" in report["test_result"]["reason"]
assert "abc123secret" not in reports.failure_reason( assert "abc123secret" not in reports.failure_reason(
@@ -844,8 +844,8 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
report = client.get(f"/api/automation/reports/{job['session_id']}").json() report = client.get(f"/api/automation/reports/{job['session_id']}").json()
for item in (history, batch_job, report): for item in (history, batch_job, report):
assert item["status"] == "completed" assert item["status"] == "completed"
assert item["test_result"] == result assert item["test_result"] == {**result, "source": "ai"}
assert report["verdict"] == "测试不通过" assert report["verdict"] == "测试不通过(AI确认)"
paths = reports.export_session(job["session_id"]) paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx")) doc = Document(next(path for path in paths if path.suffix == ".docx"))
doc_text = "\n".join( doc_text = "\n".join(
@@ -856,10 +856,133 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
assert result["evidence"][0] in doc_text assert result["evidence"][0] in doc_text
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"))
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row] values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
assert "测试不通过" in values assert "测试不通过(AI确认)" in values
assert result["reason"] 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
): ):
+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)
+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])
+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.5.0" version = "1.5.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },