Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc75d0c65 | ||
|
|
bdd1aa2c14 | ||
|
|
53c5680260 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 "未记录"),
|
||||
(
|
||||
"验收证据",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.2",
|
||||
"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
-2
@@ -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;} }
|
||||
|
||||
+26
-1
@@ -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;
|
||||
}
|
||||
+26
@@ -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=>{
|
||||
|
||||
+7
-2
@@ -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()]);
|
||||
}
|
||||
+46
@@ -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();
|
||||
});
|
||||
});
|
||||
+49
-4
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Cooperative, session-scoped task guidance and device lease parking."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Awaitable, Callable
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -15,6 +15,26 @@ from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Windows readers may briefly deny rename/delete even when directory permissions
|
||||
# allow writes. Bound the total wait to 1.55s; other IO failures remain immediate.
|
||||
_WINDOWS_ACCESS_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
|
||||
|
||||
|
||||
def _retry_windows_access[T](operation: Callable[[], T]) -> T:
|
||||
delays = iter(_WINDOWS_ACCESS_RETRY_DELAYS)
|
||||
while True:
|
||||
try:
|
||||
return operation()
|
||||
except OSError as exc:
|
||||
delay = next(delays, None)
|
||||
if delay is None or getattr(exc, "winerror", None) not in {5, 32, 33}:
|
||||
raise
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def _unlink_control_file(path: Path) -> None:
|
||||
_retry_windows_access(lambda: path.unlink(missing_ok=True))
|
||||
|
||||
|
||||
def control_dir(session_id: str) -> Path:
|
||||
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
|
||||
@@ -25,8 +45,21 @@ def control_dir(session_id: str) -> Path:
|
||||
def _write_atomic(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
try:
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
_retry_windows_access(lambda: os.replace(temporary, path))
|
||||
except OSError:
|
||||
logger.exception("Cannot write task control file %s", path)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
_unlink_control_file(temporary)
|
||||
except OSError:
|
||||
# Cleanup must never replace the original write error or turn a
|
||||
# successful atomic write into a failed task/control submission.
|
||||
logger.warning(
|
||||
"Could not remove task control temporary file %s", temporary, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def read_state(session_id: str) -> dict:
|
||||
@@ -39,7 +72,7 @@ def read_state(session_id: str) -> dict:
|
||||
|
||||
|
||||
def request_pause(session_id: str) -> None:
|
||||
(control_dir(session_id) / "resume.request").unlink(missing_ok=True)
|
||||
_unlink_control_file(control_dir(session_id) / "resume.request")
|
||||
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
|
||||
|
||||
|
||||
@@ -138,7 +171,7 @@ class TaskControl:
|
||||
pause = self.directory / "pause.request"
|
||||
if not pause.exists():
|
||||
return False
|
||||
pause.unlink(missing_ok=True)
|
||||
_unlink_control_file(pause)
|
||||
if on_pause is not None:
|
||||
await on_pause()
|
||||
self.device_lock.release()
|
||||
@@ -146,7 +179,7 @@ class TaskControl:
|
||||
resume = self.directory / "resume.request"
|
||||
while not resume.exists():
|
||||
await asyncio.sleep(0.2)
|
||||
resume.unlink(missing_ok=True)
|
||||
_unlink_control_file(resume)
|
||||
self._state("resuming")
|
||||
previous = self.device_lock
|
||||
self.device_lock = DeviceExecutionLock(
|
||||
@@ -208,4 +241,10 @@ class TaskControl:
|
||||
|
||||
def release(self) -> None:
|
||||
self.device_lock.release()
|
||||
(self.directory / "task_control_state.json").unlink(missing_ok=True)
|
||||
state_path = self.directory / "task_control_state.json"
|
||||
try:
|
||||
_unlink_control_file(state_path)
|
||||
except OSError:
|
||||
# The lease is already released. An ephemeral status file left
|
||||
# behind must not override the task's result or block the queue.
|
||||
logger.warning("Could not remove task control state file %s", state_path, exc_info=True)
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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 进程。
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
|
||||
|
||||
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
|
||||
|
||||
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
|
||||
|
||||
## Excel 批量导入
|
||||
|
||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.5.0"
|
||||
version = "1.5.2"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -1,12 +1,256 @@
|
||||
"""Paused tasks yield their device and resume with a fresh lease."""
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import artemis.runtime.task_control as task_control
|
||||
|
||||
|
||||
def _windows_access_error(code: int) -> PermissionError:
|
||||
error = PermissionError(errno.EACCES, "state file is in use")
|
||||
error.winerror = code
|
||||
return error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("winerror", [5, 32, 33])
|
||||
def test_running_state_recovers_from_temporary_windows_file_access(tmp_path, monkeypatch, winerror):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
path = task_control.control_dir("task-123") / "task_control_state.json"
|
||||
task_control._write_atomic(path, {"status": "queued"})
|
||||
replace = os.replace
|
||||
attempts = []
|
||||
delays = []
|
||||
|
||||
def busy_replace(source, target):
|
||||
attempts.append(source)
|
||||
if len(attempts) <= 2:
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
|
||||
raise _windows_access_error(winerror)
|
||||
return replace(source, target)
|
||||
|
||||
monkeypatch.setattr(task_control.os, "replace", busy_replace)
|
||||
monkeypatch.setattr(task_control.time, "sleep", delays.append)
|
||||
task_control.TaskControl("task-123", object())._state("running")
|
||||
|
||||
assert task_control.read_state("task-123")["status"] == "running"
|
||||
assert delays and sum(delays) < 2
|
||||
assert not list(path.parent.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_persistent_windows_access_error_is_bounded_and_preserves_old_state(tmp_path, monkeypatch):
|
||||
path = tmp_path / "task_control_state.json"
|
||||
task_control._write_atomic(path, {"status": "queued"})
|
||||
error = _windows_access_error(5)
|
||||
attempts = []
|
||||
delays = []
|
||||
|
||||
def denied_replace(source, target):
|
||||
attempts.append(source)
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(task_control.os, "replace", denied_replace)
|
||||
monkeypatch.setattr(task_control.time, "sleep", delays.append)
|
||||
with pytest.raises(PermissionError) as raised:
|
||||
task_control._write_atomic(path, {"status": "running"})
|
||||
|
||||
assert raised.value is error
|
||||
assert 1 < len(attempts) < 20
|
||||
assert sum(delays) < 2
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error", [PermissionError(errno.EACCES, "denied"), OSError(errno.ENOSPC, "disk full")]
|
||||
)
|
||||
def test_unrelated_file_errors_fail_without_retry_and_clean_temporary_file(
|
||||
tmp_path, monkeypatch, error
|
||||
):
|
||||
path = tmp_path / "task_control_state.json"
|
||||
task_control._write_atomic(path, {"status": "queued"})
|
||||
attempts = []
|
||||
delays = []
|
||||
|
||||
def failed_replace(source, target):
|
||||
attempts.append(source)
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(task_control.os, "replace", failed_replace)
|
||||
monkeypatch.setattr(task_control.time, "sleep", delays.append)
|
||||
with pytest.raises(OSError) as raised:
|
||||
task_control._write_atomic(path, {"status": "running"})
|
||||
|
||||
assert raised.value is error
|
||||
assert len(attempts) == 1
|
||||
assert delays == []
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_temporary_write_failure_cleans_partial_file(tmp_path, monkeypatch):
|
||||
path = tmp_path / "task_control_state.json"
|
||||
task_control._write_atomic(path, {"status": "queued"})
|
||||
write_text = Path.write_text
|
||||
error = OSError(errno.ENOSPC, "disk full")
|
||||
|
||||
def partial_write(target, content, **kwargs):
|
||||
write_text(target, "{", **kwargs)
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", partial_write)
|
||||
with pytest.raises(OSError) as raised:
|
||||
task_control._write_atomic(path, {"status": "running"})
|
||||
|
||||
assert raised.value is error
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_busy_temporary_cleanup_preserves_original_write_error(tmp_path, monkeypatch, caplog):
|
||||
path = tmp_path / "task_control_state.json"
|
||||
task_control._write_atomic(path, {"status": "queued"})
|
||||
error = OSError(errno.ENOSPC, "disk full")
|
||||
unlink = Path.unlink
|
||||
|
||||
def failed_replace(source, target):
|
||||
raise error
|
||||
|
||||
def busy_unlink(target, **kwargs):
|
||||
if target.suffix == ".tmp":
|
||||
raise _windows_access_error(32)
|
||||
return unlink(target, **kwargs)
|
||||
|
||||
monkeypatch.setattr(task_control.os, "replace", failed_replace)
|
||||
monkeypatch.setattr(Path, "unlink", busy_unlink)
|
||||
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=task_control.__name__):
|
||||
with pytest.raises(OSError) as raised:
|
||||
task_control._write_atomic(path, {"status": "running"})
|
||||
assert raised.value is error
|
||||
assert "Could not remove task control temporary file" in caplog.text
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"status": "queued"}
|
||||
finally:
|
||||
for temporary in tmp_path.glob("*.tmp"):
|
||||
unlink(temporary)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"send_request,filename",
|
||||
[
|
||||
(task_control.request_pause, "pause.request"),
|
||||
(task_control.request_resume, "resume.request"),
|
||||
],
|
||||
)
|
||||
def test_repeated_control_requests_recover_from_file_sharing(
|
||||
tmp_path, monkeypatch, send_request, filename
|
||||
):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
send_request("task-123")
|
||||
replace = os.replace
|
||||
attempts = []
|
||||
|
||||
def busy_replace(source, target):
|
||||
attempts.append(target)
|
||||
if len(attempts) == 1:
|
||||
raise _windows_access_error(32)
|
||||
return replace(source, target)
|
||||
|
||||
monkeypatch.setattr(task_control.os, "replace", busy_replace)
|
||||
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
|
||||
send_request("task-123")
|
||||
|
||||
assert (
|
||||
json.loads((task_control.control_dir("task-123") / filename).read_text(encoding="utf-8"))[
|
||||
"created_at"
|
||||
]
|
||||
> 0
|
||||
)
|
||||
assert not list(task_control.control_dir("task-123").glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("persistent", [False, True])
|
||||
def test_release_does_not_fail_task_when_state_cleanup_is_busy(
|
||||
tmp_path, monkeypatch, caplog, persistent
|
||||
):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
released = []
|
||||
|
||||
class Lock:
|
||||
def release(self):
|
||||
released.append(True)
|
||||
|
||||
control = task_control.TaskControl("task-123", Lock())
|
||||
control._state("running")
|
||||
path = control.directory / "task_control_state.json"
|
||||
unlink = Path.unlink
|
||||
attempts = []
|
||||
|
||||
def busy_unlink(target, **kwargs):
|
||||
if target == path:
|
||||
attempts.append(target)
|
||||
if persistent or len(attempts) == 1:
|
||||
raise _windows_access_error(32)
|
||||
return unlink(target, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", busy_unlink)
|
||||
monkeypatch.setattr(task_control.time, "sleep", lambda _: None)
|
||||
with caplog.at_level(logging.WARNING, logger=task_control.__name__):
|
||||
control.release()
|
||||
|
||||
assert released == [True]
|
||||
assert 1 < len(attempts) < 20
|
||||
assert path.exists() == persistent
|
||||
if persistent:
|
||||
assert path.name in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows file sharing semantics")
|
||||
def test_running_state_survives_real_windows_reader_handle(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
control = task_control.TaskControl("task-123", object())
|
||||
control._state("queued")
|
||||
path = control.directory / "task_control_state.json"
|
||||
reader = path.open(encoding="utf-8")
|
||||
blocked = threading.Event()
|
||||
errors = []
|
||||
replace = os.replace
|
||||
|
||||
def release_reader():
|
||||
if blocked.wait(2):
|
||||
reader.close()
|
||||
|
||||
def observe_replace(source, target):
|
||||
try:
|
||||
return replace(source, target)
|
||||
except OSError as exc:
|
||||
errors.append(exc.winerror)
|
||||
blocked.set()
|
||||
raise
|
||||
|
||||
releaser = threading.Thread(target=release_reader)
|
||||
releaser.start()
|
||||
monkeypatch.setattr(task_control.os, "replace", observe_replace)
|
||||
try:
|
||||
control._state("running")
|
||||
finally:
|
||||
blocked.set()
|
||||
releaser.join(timeout=3)
|
||||
reader.close()
|
||||
|
||||
assert not releaser.is_alive()
|
||||
assert errors and set(errors).issubset({5, 32, 33})
|
||||
assert task_control.read_state("task-123")["status"] == "running"
|
||||
assert not list(control.directory.glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user