Compare commits

...
2 Commits
45 changed files with 1109 additions and 217 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ cd Artemis-Eapil
.\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
+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 report_export, task_import
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"])
@@ -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):
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
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):
jobs = store.jobs(batch_id)
for job in jobs:
job["test_result"] = read_case_result(
job["session_id"], job["status"], job.get("error") or ""
job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store
)
return {**require("batch", batch_id), "jobs": jobs}
@@ -207,6 +213,42 @@ async def download_report(
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")
def history(
q: str = Query(default="", max_length=200),
@@ -215,6 +257,7 @@ def history(
end: float | None = None,
page: int = Query(default=1, ge=1),
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.
query = """WITH history AS (
@@ -226,7 +269,7 @@ def history(
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
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
UNION ALL
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.subcategory'),''),
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)
) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
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:
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("_", "\\_") + "%"
@@ -266,8 +320,8 @@ def history(
items = [dict(row) for row in rows]
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
for item in items:
item["test_result"] = read_case_result(
item["session_id"], item["status"], item.get("error") or ""
item["test_result"] = read_reviewed_result(
item["session_id"], item["status"], item.get("error") or "", storage=store
)
if job := jobs.get(item["session_id"]):
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.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:
from admin_console.core.state import state
@@ -83,6 +83,7 @@ def _list_sessions_sync():
job = jobs_by_id.get(s_id)
if job:
case = job.get("case") or {}
row_dict["batch_id"] = job.get("batch_id")
row_dict["case_id"] = case.get("case_id") or ""
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
row_dict["iteration"] = job.get("iteration", 1)
@@ -131,8 +132,12 @@ def _list_sessions_sync():
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
row_dict["test_result"] = read_case_result(
s_id, row_dict.get("status") or "", row_dict.get("error") or "", root=TRACES_PATH
row_dict["test_result"] = read_reviewed_result(
s_id,
row_dict.get("status") or "",
row_dict.get("error") or "",
root=TRACES_PATH,
storage=store,
)
agent_names = agent_names_by_session.get(s_id, [])
@@ -194,8 +199,10 @@ async def get_session_details(session_id: str):
if not row:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
result = dict(row)
result["test_result"] = read_case_result(
session_id, result.get("status") or "", root=TRACES_PATH
job = store.job(session_id) or {}
result["batch_id"] = job.get("batch_id")
result["test_result"] = read_reviewed_result(
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
)
return result
+11
View File
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
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.task_control import (
add_instruction,
@@ -423,6 +425,15 @@ async def get_status():
seen_ids.add(sid)
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:
is_paused = state.is_paused
return {
@@ -149,6 +149,52 @@ class AutomationStore:
def preferences(self) -> dict:
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:
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
request_id = payload.get("request_id")
@@ -247,6 +293,11 @@ class AutomationStore:
batch = json.loads(row[0])
if batch.get("status") != "deleting":
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_records WHERE kind='batch' AND id=?",
@@ -260,9 +311,14 @@ class AutomationStore:
with self.connection() as conn:
if 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:
conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
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.task_import import execution_title
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock()
@@ -65,7 +66,10 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result")
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 {}
if tests.get("failed", 0):
return "验证失败"
@@ -183,9 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
case = meta.get("case") or {}
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
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:
outcome = {**outcome, "test_result": test_result.model_dump()}
outcome = {**outcome, "test_result": test_result}
verification = verdict(outcome)
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)"
@@ -204,7 +209,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"expected": case.get("expected", ""),
"status": status,
"verdict": verification,
"test_result": test_result.model_dump() if test_result else None,
"test_result": test_result,
"outcome": outcome,
"content": content,
"steps": steps,
@@ -241,6 +246,13 @@ def metadata(report: dict) -> list[tuple]:
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
("验证结论", 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 "未记录"),
(
"验收证据",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.5.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.5.0",
"version": "1.5.1",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.5.0",
"version": "1.5.1",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -44,7 +44,7 @@ limitations under the License.
</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>
<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 -->
<ng-template
@@ -61,6 +61,7 @@ limitations under the License.
<span class="material-symbols-outlined">list_alt</span>
<span>任务队列</span>
</div>
<app-task-batch-filter />
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span>
@@ -86,7 +87,7 @@ limitations under the License.
}
{{ getTaskStatus(session) | uiLabel }}
</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">
@if (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). -->
@if (isCheckerBlock(block)) {
@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 {
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
<div class="check-header">
@@ -3702,9 +3702,12 @@ $stream-row-height: 25px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700;
padding: 2px 5px;
padding: 0 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
@@ -27,6 +27,7 @@ import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../service
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
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({
selector: 'app-agent-stream',
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',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -390,7 +391,7 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((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) => {
const statusA = this.getTaskStatus(a);
@@ -854,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
// Top Nav Action Methods
public openBatchQueue(): void {
this.isAutomationOpen.set(false);
this.isNotesDropdownOpen.set(false);
this.isTaskDropdownOpen.set(true);
}
public toggleTaskDropdown(event: Event): void {
event.stopPropagation();
this.isTaskDropdownOpen.update(v => !v);
@@ -5,6 +5,7 @@
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
@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>
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
@@ -15,7 +16,7 @@
<div class="report-overview">
<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>
@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> }
</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>
<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">
<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 === '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>}
@@ -103,6 +106,6 @@
</div>
}
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@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>}
}
@@ -1,4 +1,6 @@
: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;
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;} }
@@ -12,7 +12,9 @@ describe('AutomationPanelComponent batch deletion', () => {
currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null),
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']);
@@ -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.delete.calls.reset();
http.post.calls.reset();
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
await TestBed.configureTestingModule({
imports: [AutomationPanelComponent],
providers: [
@@ -117,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
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', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
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 { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
@@ -8,6 +8,7 @@ import { SystemService } from '../../services/system.service';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model';
interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -24,6 +25,7 @@ interface ArchiveProgress {
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
})
export class AutomationPanelComponent {
queueRequested = output<void>();
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
batches = signal<any[]>([]); preview = signal<any>(null);
@@ -50,6 +52,7 @@ export class AutomationPanelComponent {
const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
effect(() => {
this.agent.resultRevision();
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: () => {} });
});
@@ -57,6 +60,9 @@ export class AutomationPanelComponent {
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; }
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} 秒`; }
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
private async prepareBatchArchive(id: string) {
@@ -58,6 +58,7 @@ limitations under the License.
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter />
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -82,7 +83,7 @@ limitations under the License.
}
{{ getTaskStatus(session) | uiLabel }}
</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">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
@@ -120,7 +121,7 @@ limitations under the License.
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
<div class="task-card-footer">
<app-task-id-copy [taskId]="session.session_id" />
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
box-sizing: border-box;
height: 20px;
line-height: 14px;
font-size: 11px;
font-weight: 700;
padding: 2px 5px;
padding: 0 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
@@ -24,6 +24,7 @@ import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
@@ -32,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -55,7 +56,7 @@ export class ChatInterfaceComponent {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((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) => {
const statusA = this.getTaskStatus(a);
@@ -43,6 +43,17 @@
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 {
display: inline-flex;
align-items: center;
@@ -17,6 +17,7 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../../package.json';
@Component({
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="tab-label">工作区</span>
</a>
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
</nav>
`,
changeDetection: ChangeDetectionStrategy.Eager,
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;
beforeEach(() => {
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);
});
afterEach(() => http.verify());
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
const agent = TestBed.inject(AgentService);
agent.batchFilter.set('batch-2'); fixture.detectChanges();
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
expect(filtered.request.params.get('page')).toBe('1');
expect(filtered.request.params.get('q')).toBe('设置');
filtered.flush({total:0,items:[]});
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
fixture.destroy();
});
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
@@ -1,6 +1,6 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.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 { FormsModule } from '@angular/forms';
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(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(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>}
@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>
</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 {
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>();
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("删除失败,请稍后重试")});}
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;}
@@ -35,6 +46,7 @@ export class TaskHistoryComponent {
load(){
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);
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.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=>{
@@ -8,7 +8,7 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
@Component({
selector: 'app-test-result-badge',
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: [`
: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}
@@ -17,6 +17,8 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
.compact .material-symbols-outlined{font-size:13px}
.prominent{padding:4px 9px;font-size:14px;gap:5px}
.prominent .material-symbols-outlined{font-size:18px}
`]
@@ -24,8 +26,11 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
export class TestResultBadgeComponent {
result = input<CaseTestResult | null | undefined>();
executionStatus = input('');
compact = input(false);
prominent = input(false);
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
label = computed(() => this.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()]);
}
@@ -1,7 +1,11 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestResultCardComponent } from './test-result-card.component';
describe('用例测试结果卡片', () => {
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
afterEach(() => TestBed.inject(HttpTestingController).verify());
it('显式展示不通过及原因,允许展开证据', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
@@ -16,4 +20,46 @@ describe('用例测试结果卡片', () => {
expect(details.open).toBeTrue();
expect(details.textContent).toContain('截图显示搜索设置');
});
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
fixture.componentRef.setInput('result', ai);
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '实际符合预期,已人工核对';
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
expect(saved).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('人工通过');
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
fixture.destroy();
});
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
const fixture = TestBed.createComponent(TestResultCardComponent);
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
fixture.componentInstance.note = '人工发现异常';
fixture.nativeElement.querySelector('.confirm-fail').click();
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
fixture.detectChanges();
expect(fixture.componentInstance.note).toBe('人工发现异常');
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
fixture.destroy();
});
});
@@ -1,15 +1,30 @@
import { Component, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
import { TestResultBadgeComponent } from './test-result-badge.component';
@Component({
selector: 'app-test-result-card',
standalone: true,
imports: [TestResultBadgeComponent],
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
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>
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
@if(canReview()){
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
@if(reviewing()){
<div class="review-form" aria-label="人工复验">
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
</div>
}
}
</section>`,
styles: [`
:host{display:block;margin:8px 0}
@@ -19,13 +34,43 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
.caption{font-size:12px;font-weight:600;color:#475569}
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
@media(max-width:480px){.test-result-card{padding:10px 12px}}
`]
})
export class TestResultCardComponent {
private http = inject(HttpClient);
result = input.required<CaseTestResult>();
sessionId = input<string | null>(null);
executionStatus = input('');
reviewSaved = output<CaseReviewResponse>();
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
constructor() {
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
}
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
saveReview(status: 'passed' | 'failed') {
const id = this.sessionId();
if (!id || !this.canReview() || this.saving()) return;
this.saving.set(true); this.error.set('');
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
next: response => {
this.saving.set(false); this.reviewSaved.emit(response);
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
},
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
});
}
}
@@ -25,10 +25,20 @@ export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string;
evidence: string[];
source?: 'ai' | 'human';
reviewed_at?: number;
ai_result?: CaseTestResult | null;
}
export interface CaseReviewResponse {
id: string;
test_result: CaseTestResult;
verdict: string;
}
export interface TaskQueueItem {
session_id: string;
batch_id?: string | null;
goal: string;
profile?: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
@@ -40,6 +50,7 @@ export interface TaskQueueItem {
export interface Session {
session_id: string;
batch_id?: string | null;
initial_goal: string;
start_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 { 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 { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
@@ -85,6 +85,23 @@ export class AgentService {
private rawSessions = signal<Session[]>([]);
private pendingQueue = signal<Session[]>([]);
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
private activeSessionTracking = new Map<string, Session>();
@@ -129,6 +146,7 @@ export class AgentService {
}
const finalSession: Session = {
...s,
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
status: sStatus,
device_serial: serial,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
@@ -149,6 +167,7 @@ export class AgentService {
|| !!activeMatch;
const finalPending: Session = {
...p,
batch_id: p.batch_id || activeMatch?.batch_id || null,
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
};
@@ -165,6 +184,8 @@ export class AgentService {
if (!existing) {
const newSession: Session = {
session_id: sid,
batch_id: at.batch_id || null,
display_name: at.display_name,
initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: at.status || 'running',
@@ -1578,6 +1599,8 @@ export class AgentService {
if (typeof item === 'object' && item !== null) {
return {
session_id: item.session_id || `pending-task-${index}`,
batch_id: item.batch_id || null,
display_name: item.display_name,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending',
+1
View File
@@ -14,6 +14,7 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
+6 -1
View File
@@ -7,7 +7,11 @@
"npm_registry": "https://registry.npmmirror.com",
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
"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": {
"python_index": "",
@@ -16,6 +20,7 @@
"node_mirror": "",
"adb_url": "",
"scrcpy_url": "",
"scrcpy_fallback_urls": null,
"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、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。
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 进程。
+4
View File
@@ -12,6 +12,10 @@
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "1.5.0"
version = "1.5.1"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+5 -1
View File
@@ -8,7 +8,7 @@ function Get-BootstrapText {
}
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
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。')
}
# 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.
# --inexact preserves any optional development tools already installed.
$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.
# 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 {
param([string]$ProjectRoot, [string]$Mode)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$path = Join-Path $ProjectRoot 'config\downloads.json'
$config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
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.
if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX }
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.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$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
} else {
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.' }
$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.' }
$hasher = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($zip)
try { $digest = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') }
finally { $stream.Dispose(); $hasher.Dispose() }
$digest = Get-ArtemisFileSha256 -Path $zip
if ($digest -ne $candidate.Groups['hash'].Value) {
throw 'The downloaded uv package failed SHA256 verification.'
}
+43 -125
View File
@@ -33,10 +33,26 @@ function Get-LaunchText {
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.
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
if ($Help) {
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
Write-Host ''
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
@@ -51,6 +67,7 @@ if ($Help) {
exit 0
}
Write-Host '======================================================'
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
Write-Host '======================================================'
@@ -67,8 +84,6 @@ try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
} catch {}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
Set-Location $RootDir
# 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
# Network/authentication failures are warnings, never a reason to prevent startup.
}
. (Join-Path $ScriptDir 'windows_tools.ps1')
. (Join-Path $ScriptDir 'download_config.ps1')
try {
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 ""
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 {
if (Test-CommandExists "adb") {
$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 {
$ptDir = "$env:LOCALAPPDATA\Programs\platform-tools"
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
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
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
if ($ArtemisDownloads.scrcpy_urls.Count -eq 0) {
Write-Host (Get-LaunchText ' [WARN] No scrcpy download source is configured. Set overrides.scrcpy_url.' ' [提示] 未配置可用的 scrcpy 下载源;请设置 overrides.scrcpy_url。') -ForegroundColor Yellow
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 {
$zipPath = "$env:TEMP\scrcpy-win64.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
}
$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
$zipPath = Join-Path $work 'scrcpy.zip'
if (Invoke-ArtemisVerifiedDownload -Urls $ArtemisDownloads.scrcpy_urls -OutFile $zipPath -Sha256 $ArtemisDownloads.scrcpy_sha256) {
$extractDir = Join-Path $work 'extracted'
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDir)
$extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1
if ($extractedFolder) {
if (-not (Test-Path $scrcpyDir)) {
New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null
}
Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force
}
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") {
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
$contentDir = if (Test-Path -LiteralPath (Join-Path $extractDir 'scrcpy.exe')) { $extractDir }
elseif ($extractedFolder) { $extractedFolder.FullName }
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.' }
New-Item -ItemType Directory -Path $scrcpyDir -Force -ErrorAction Stop | Out-Null
Get-ChildItem -LiteralPath $contentDir | Copy-Item -Destination $scrcpyDir -Recurse -Force -ErrorAction Stop
$env:PATH = "$scrcpyDir;$env:PATH"
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
return $true
}
} catch {
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
}
@@ -446,7 +364,7 @@ if ($missingCore.Count -gt 0) {
}
if (-not (Test-CommandExists "scrcpy")) {
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'
if ($Help) {
Write-Host '用法:update.bat 或 update-cn.bat [-Help]'
Write-Host '未运行过启动脚本也可升级;缺少的 Python 环境会自动准备。'
Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。'
Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。'
exit 0
@@ -33,7 +34,7 @@ if (-not $Staged) {
if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' }
New-Item -ItemType Directory -Path $stage | Out-Null
$stageCreated = $true
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py')) {
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)
}
$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 }
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
}
try {
Set-Location -LiteralPath $PSScriptRoot
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $python)) { throw '尚未安装项目 Python 环境;请先运行 start.bat 或 start-cn.bat。' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' }
# Refuse dirty checkouts before terminating the user's running processes.
@@ -64,6 +67,15 @@ try {
Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow
. (Join-Path $PSScriptRoot 'upgrade_processes.ps1')
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
if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' }
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)"
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} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
echo -e "${BOLD}${CYAN}======================================================${NC}"
echo -e "${CYAN} Version: v${ARTEMIS_VERSION}${NC}"
echo ""
# 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"])
assert "连续 3 次" in report["error"]
assert report["verdict"] == "测试阻塞"
assert report["verdict"] == "测试阻塞(AI确认)"
assert report["test_result"]["status"] == "blocked"
assert "连续 3 次" in report["test_result"]["reason"]
assert "abc123secret" not in reports.failure_reason(
@@ -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()
for item in (history, batch_job, report):
assert item["status"] == "completed"
assert item["test_result"] == result
assert report["verdict"] == "测试不通过"
assert item["test_result"] == {**result, "source": "ai"}
assert report["verdict"] == "测试不通过(AI确认)"
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
doc_text = "\n".join(
@@ -856,10 +856,133 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
assert result["evidence"][0] in doc_text
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
assert "测试不通过" in values
assert "测试不通过(AI确认)" in values
assert result["reason"] in values
def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_cache(
client, isolated, monkeypatch
):
from artemis.runtime import case_result
from apps.admin_console.routers import sessions as sessions_api
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
batch = service.create_batch(payload())
jobs = isolated.jobs(batch["id"])
for job in jobs:
isolated.save_job({**job, "status": "completed", "ended_at": time.time()})
session_id = jobs[0]["session_id"]
ai = {"status": "failed", "reason": "AI识别到预期不匹配", "evidence": ["步骤 3 截图"]}
directory = reports.TRACES_PATH / session_id
directory.mkdir(parents=True)
outcome_file = directory / "run_outcome.json"
original = json.dumps({"task_status": "completed", "test_result": ai}, ensure_ascii=False)
outcome_file.write_text(original, encoding="utf-8")
archive = reports.export_batch(batch["id"])
isolated.put("batch", batch["id"], {**batch, "report_ready": True})
response = client.put(
f"/api/automation/reports/{session_id}/review",
json={"status": "passed", "reason": "已人工核对,符合预期"},
)
assert response.status_code == 200
assert response.json()["status"] == "completed"
assert response.json()["verdict"] == "测试通过(人工确认)"
assert outcome_file.read_text(encoding="utf-8") == original
assert not archive.exists()
assert not isolated.get("batch", batch["id"])["report_ready"]
assert len(isolated.get("case_review", session_id)["reviews"]) == 1
for result in (
response.json(),
client.get("/api/automation/history", params={"q": session_id}).json()["items"][0],
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
):
assert result["test_result"]["source"] == "human"
assert result["test_result"]["status"] == "passed"
assert result["test_result"]["ai_result"] == {**ai, "source": "ai"}
assert result["test_result"]["reviewed_at"] > 0
monkeypatch.setattr(sessions_api, "store", isolated)
monkeypatch.setattr(sessions_api, "TRACES_PATH", reports.TRACES_PATH)
monkeypatch.setattr(
sessions_api.session_repo,
"get_session_by_id",
lambda sid: {"session_id": sid, "status": "completed"},
)
client.app.include_router(sessions_api.router)
detail = client.get(f"/api/sessions/{session_id}").json()
assert detail["test_result"]["source"] == "human"
assert detail["batch_id"] == batch["id"]
downloaded = client.get(f"/api/automation/batches/{batch['id']}/archive")
assert downloaded.status_code == 200
with zipfile.ZipFile(BytesIO(downloaded.content)) as bundle:
wb = load_workbook(BytesIO(bundle.read("批次结果.xlsx")))
assert wb.active["D2"].value == "测试通过(人工确认)"
assert "AI确认" in wb.active["D3"].value
doc = Document(BytesIO(bundle.read("批次汇总.docx")))
assert doc.tables[0].rows[1].cells[3].text == "测试通过(人工确认)"
changed = client.put(f"/api/automation/reports/{session_id}/review", json={"status": "failed"})
assert changed.status_code == 200
assert changed.json()["verdict"] == "测试不通过(人工确认)"
assert len(isolated.get("case_review", session_id)["reviews"]) == 2
assert outcome_file.read_text(encoding="utf-8") == original
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
url = f"/api/automation/reports/{job['session_id']}/review"
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
assert client.put(url, json={"status": "inconclusive"}).status_code == 422
assert client.put(url, json={"status": "passed", "source": "human"}).status_code == 422
assert client.put(url, json={"status": "passed", "reason": "字" * 2001}).status_code == 422
assert (
client.put("/api/automation/reports/missing/review", json={"status": "passed"}).status_code
== 404
)
isolated.save_job({**job, "status": "completed"})
monkeypatch.setattr(api.archive_service, "status", lambda *args: {"status": "preparing"})
assert client.put(url, json={"status": "passed"}).status_code == 409
assert isolated.get("case_review", job["session_id"]) is None
def test_batch_filter_searches_full_history_and_reviews_are_removed_with_batch(client, isolated):
first, second = [service.create_batch(payload(name=name)) for name in ("第一批", "第二批")]
for batch in (first, second):
for job in isolated.jobs(batch["id"]):
isolated.save_job({**job, "status": "completed"})
with isolated.connection() as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
("single-case", "单独检查", time.time(), "completed"),
)
conn.commit()
filtered = client.get(
"/api/automation/history",
params={"batch_id": first["id"], "q": "用例", "size": 1, "page": 2},
).json()
assert filtered["total"] == 2
assert len(filtered["items"]) == 1
assert filtered["items"][0]["batch_id"] == first["id"]
assert (
client.get("/api/automation/history", params={"batch_id": "__batch__"}).json()["total"] == 4
)
singles = client.get("/api/automation/history", params={"batch_id": "__single__"}).json()
assert [item["session_id"] for item in singles["items"]] == ["single-case"]
assert client.get("/api/automation/history", params={"batch_id": "bad/path"}).status_code == 400
reviewed_id = isolated.jobs(first["id"])[0]["session_id"]
isolated.save_case_review(reviewed_id, "passed", "人工核对")
isolated.begin_delete_batch(first["id"])
isolated.delete_batch(first["id"])
assert isolated.get("case_review", reviewed_id) is None
assert len(isolated.jobs(second["id"])) == 2
isolated.save_case_review("single-case", "failed", "不符合预期")
isolated.delete_history("single-case")
assert isolated.get("case_review", "single-case") is None
def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
+153 -7
View File
@@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif(
def powershell(tmp_path, script, **env):
path = tmp_path / "test.ps1"
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 = {
key: value
@@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env):
**env,
},
capture_output=True,
text=True,
encoding="utf-8",
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 ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror 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' }
Remove-Item Env:npm_config_registry
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
def test_scrcpy_archive_is_checked_before_extraction():
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
"Remove-Item $extractDir -Recurse"
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
archive = tmp_path / "fixture.zip"
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])
+67 -19
View File
@@ -11,6 +11,15 @@ import time
import pytest
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(
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
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("initialized", [True, False])
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"):
pytest.skip("Git required")
author = tmp_path / "author"
(author / "scripts").mkdir(parents=True)
for name in (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
for name in UPGRADE_FILES:
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"):
shutil.copyfile(ROOT / name, author / name)
(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, "add", ".")
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))
make_venv(installed)
if initialized:
make_venv(installed)
(installed / ".env").write_text("secret stays local")
# A running file AND both BAT wrappers are replaced during the merge.
(author / "scripts/upgrade.ps1").write_text("throw 'new updater must not run in this turn'\n")
(author / "scripts/upgrade_processes.ps1").write_text(
"throw 'new process helper must not run'\n"
)
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-cn.bat").write_text("@echo off\nexit /b 34\n")
(author / "scripts/start.ps1").write_text(
@@ -184,17 +198,25 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
result = subprocess.run(
["cmd.exe", "/d", "/c", "update-cn.bat"],
cwd=installed,
env={**os.environ, "UV_OFFLINE": "1", "ARTEMIS_NO_PAUSE": "1"},
capture_output=True,
encoding="utf-8",
timeout=45,
)
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
assert result.stderr == ""
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 git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert (installed / ".env").read_text() == "secret stays local"
assert ("升级完成" in result.stdout) == (prepare_exit == 0)
assert "new updater must not run" not in result.stdout
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"])
@@ -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):
root = tmp_path / "prepared project"
(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)
(root / "scripts/download_config.ps1").write_text(
"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"
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"):
pytest.skip("Git required")
root = tmp_path / "dirty installation"
(root / "scripts").mkdir(parents=True)
for name in (
"upgrade.ps1",
"upgrade_processes.ps1",
"installation_lock.ps1",
"update_installation.py",
):
for name in UPGRADE_FILES:
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
(root / ".gitignore").write_text(".venv/\n")
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, "add", ".")
git(root, "commit", "-m", "initial")
python = make_venv(root)
python = make_venv(root) if initialized else sys.executable
source.write_text("user changes")
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
try:
@@ -344,6 +362,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
assert "[STOP]" not in result.stdout
assert worker.poll() is None
assert source.read_text() == "user changes"
assert (root / ".venv").exists() == initialized
finally:
# Windows venv redirectors can have a second interpreter process.
import psutil
@@ -355,3 +374,32 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
if child.is_running():
child.kill()
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]]
name = "artemis"
version = "1.5.0"
version = "1.5.1"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },