feat: release 1.5.1 with human review and batch filters
This commit is contained in:
1 parent
53c5680260
commit
bdd1aa2c14
36 files changed
+672
-57
No files matched your search
@@ -13,7 +13,7 @@ from apps.admin_console.services import automation_service as service
|
|||||||
from apps.admin_console.services import batch_archive as archive_service
|
from apps.admin_console.services import batch_archive as archive_service
|
||||||
from apps.admin_console.services import report_export, task_import
|
from apps.admin_console.services import report_export, task_import
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
from artemis.runtime.case_result import read_case_result
|
from apps.admin_console.services.case_review import read_reviewed_result
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ class ReportPreferences(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseReviewRequest(BaseModel):
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
status: Literal["passed", "failed"]
|
||||||
|
reason: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class BatchRequest(ReportPreferences):
|
class BatchRequest(ReportPreferences):
|
||||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
@@ -125,8 +131,8 @@ def batch_by_request(request_id: str):
|
|||||||
def batch_detail(batch_id: str):
|
def batch_detail(batch_id: str):
|
||||||
jobs = store.jobs(batch_id)
|
jobs = store.jobs(batch_id)
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
job["test_result"] = read_case_result(
|
job["test_result"] = read_reviewed_result(
|
||||||
job["session_id"], job["status"], job.get("error") or ""
|
job["session_id"], job["status"], job.get("error") or "", storage=store
|
||||||
)
|
)
|
||||||
return {**require("batch", batch_id), "jobs": jobs}
|
return {**require("batch", batch_id), "jobs": jobs}
|
||||||
|
|
||||||
@@ -207,6 +213,42 @@ async def download_report(
|
|||||||
raise HTTPException(404, str(exc)) from exc
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reports/{session_id}/review")
|
||||||
|
def review_case(session_id: str, value: CaseReviewRequest):
|
||||||
|
report(session_id)
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
batch_id = job.get("batch_id")
|
||||||
|
if (
|
||||||
|
batch_id
|
||||||
|
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
current = report(session_id)
|
||||||
|
if current["status"] not in {
|
||||||
|
"completed",
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"interrupted",
|
||||||
|
"skipped",
|
||||||
|
}:
|
||||||
|
raise HTTPException(409, "请在任务结束后进行人工复验")
|
||||||
|
try:
|
||||||
|
store.save_case_review(session_id, value.status, value.reason)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
if batch_id:
|
||||||
|
archive_service.forget(batch_id)
|
||||||
|
# A finishing worker can publish report_ready just after export;
|
||||||
|
# removing only the generated ZIP also prevents that stale cache hit.
|
||||||
|
try:
|
||||||
|
archive_service.archive_path(batch_id).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass # An open download may hold the file; report_ready is already invalidated.
|
||||||
|
return report(session_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
def history(
|
def history(
|
||||||
q: str = Query(default="", max_length=200),
|
q: str = Query(default="", max_length=200),
|
||||||
@@ -215,6 +257,7 @@ def history(
|
|||||||
end: float | None = None,
|
end: float | None = None,
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
size: int = Query(default=30, ge=1, le=100),
|
size: int = Query(default=30, ge=1, le=100),
|
||||||
|
batch_id: str = Query(default="", max_length=100),
|
||||||
):
|
):
|
||||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||||
query = """WITH history AS (
|
query = """WITH history AS (
|
||||||
@@ -226,7 +269,7 @@ def history(
|
|||||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||||
COALESCE(json_extract(j.payload,'$.error'),'') error
|
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
||||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||||
@@ -237,11 +280,22 @@ def history(
|
|||||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.error'),'')
|
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
|
||||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||||
) """
|
) """
|
||||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||||
args = []
|
args = []
|
||||||
|
if batch_id == "__batch__":
|
||||||
|
where += " AND batch_id IS NOT NULL"
|
||||||
|
elif batch_id == "__single__":
|
||||||
|
where += " AND batch_id IS NULL"
|
||||||
|
elif batch_id:
|
||||||
|
try:
|
||||||
|
identifier(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
where += " AND batch_id = ?"
|
||||||
|
args.append(batch_id)
|
||||||
if q:
|
if q:
|
||||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
@@ -266,8 +320,8 @@ def history(
|
|||||||
items = [dict(row) for row in rows]
|
items = [dict(row) for row in rows]
|
||||||
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||||
for item in items:
|
for item in items:
|
||||||
item["test_result"] = read_case_result(
|
item["test_result"] = read_reviewed_result(
|
||||||
item["session_id"], item["status"], item.get("error") or ""
|
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||||
)
|
)
|
||||||
if job := jobs.get(item["session_id"]):
|
if job := jobs.get(item["session_id"]):
|
||||||
item["name"] = task_import.execution_title(job)
|
item["name"] = task_import.execution_title(job)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
from artemis.config import DB_PATH, TRACES_PATH
|
from artemis.config import DB_PATH, TRACES_PATH
|
||||||
from artemis.runtime import trace_store
|
from artemis.runtime import trace_store
|
||||||
from artemis.runtime.case_result import read_case_result
|
from apps.admin_console.services.case_review import read_reviewed_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
@@ -83,6 +83,7 @@ def _list_sessions_sync():
|
|||||||
job = jobs_by_id.get(s_id)
|
job = jobs_by_id.get(s_id)
|
||||||
if job:
|
if job:
|
||||||
case = job.get("case") or {}
|
case = job.get("case") or {}
|
||||||
|
row_dict["batch_id"] = job.get("batch_id")
|
||||||
row_dict["case_id"] = case.get("case_id") or ""
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
||||||
row_dict["iteration"] = job.get("iteration", 1)
|
row_dict["iteration"] = job.get("iteration", 1)
|
||||||
@@ -131,8 +132,12 @@ def _list_sessions_sync():
|
|||||||
else:
|
else:
|
||||||
row_dict["video_url"] = None
|
row_dict["video_url"] = None
|
||||||
row_dict["recording_status"] = recording_status
|
row_dict["recording_status"] = recording_status
|
||||||
row_dict["test_result"] = read_case_result(
|
row_dict["test_result"] = read_reviewed_result(
|
||||||
s_id, row_dict.get("status") or "", row_dict.get("error") or "", root=TRACES_PATH
|
s_id,
|
||||||
|
row_dict.get("status") or "",
|
||||||
|
row_dict.get("error") or "",
|
||||||
|
root=TRACES_PATH,
|
||||||
|
storage=store,
|
||||||
)
|
)
|
||||||
|
|
||||||
agent_names = agent_names_by_session.get(s_id, [])
|
agent_names = agent_names_by_session.get(s_id, [])
|
||||||
@@ -194,8 +199,10 @@ async def get_session_details(session_id: str):
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||||
result = dict(row)
|
result = dict(row)
|
||||||
result["test_result"] = read_case_result(
|
job = store.job(session_id) or {}
|
||||||
session_id, result.get("status") or "", root=TRACES_PATH
|
result["batch_id"] = job.get("batch_id")
|
||||||
|
result["test_result"] = read_reviewed_result(
|
||||||
|
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from artemis.core.diagnostics import readiness_engine
|
from artemis.core.diagnostics import readiness_engine
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.task_import import execution_title
|
||||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||||
from artemis.runtime.task_control import (
|
from artemis.runtime.task_control import (
|
||||||
add_instruction,
|
add_instruction,
|
||||||
@@ -423,6 +425,15 @@ async def get_status():
|
|||||||
seen_ids.add(sid)
|
seen_ids.add(sid)
|
||||||
queue_data.append(g_item)
|
queue_data.append(g_item)
|
||||||
|
|
||||||
|
batch_jobs = store.jobs_for_sessions(
|
||||||
|
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||||
|
)
|
||||||
|
queue_data = [{**item} for item in queue_data]
|
||||||
|
for item in [*queue_data, *active_tasks]:
|
||||||
|
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||||
|
item["batch_id"] = job.get("batch_id")
|
||||||
|
item["display_name"] = execution_title(job)
|
||||||
|
|
||||||
if is_running:
|
if is_running:
|
||||||
is_paused = state.is_paused
|
is_paused = state.is_paused
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -149,6 +149,52 @@ class AutomationStore:
|
|||||||
def preferences(self) -> dict:
|
def preferences(self) -> dict:
|
||||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||||
|
|
||||||
|
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
|
||||||
|
"""Append reviews atomically; invalidate the batch's generated report."""
|
||||||
|
identifier(session_id)
|
||||||
|
if status not in {"passed", "failed"}:
|
||||||
|
raise ValueError("人工复验结果只能为通过或不通过")
|
||||||
|
review = {
|
||||||
|
"status": status,
|
||||||
|
"reason": reason.strip()
|
||||||
|
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
|
||||||
|
"reviewed_at": time.time(),
|
||||||
|
}
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
record = json.loads(row[0]) if row else {"reviews": []}
|
||||||
|
record["reviews"].append(review)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
|
(
|
||||||
|
"case_review",
|
||||||
|
session_id,
|
||||||
|
json.dumps(record, ensure_ascii=False),
|
||||||
|
review["reviewed_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
job = conn.execute(
|
||||||
|
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if job and job[0]:
|
||||||
|
batch = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
|
||||||
|
).fetchone()
|
||||||
|
if not batch or json.loads(batch[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除,不能保存复验")
|
||||||
|
payload = json.loads(batch[0])
|
||||||
|
payload["report_ready"] = False
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||||
|
(json.dumps(payload, ensure_ascii=False), review["reviewed_at"], job[0]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return review
|
||||||
|
|
||||||
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
||||||
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
||||||
request_id = payload.get("request_id")
|
request_id = payload.get("request_id")
|
||||||
@@ -247,6 +293,11 @@ class AutomationStore:
|
|||||||
batch = json.loads(row[0])
|
batch = json.loads(row[0])
|
||||||
if batch.get("status") != "deleting":
|
if batch.get("status") != "deleting":
|
||||||
raise ValueError("批次尚未准备删除")
|
raise ValueError("批次尚未准备删除")
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||||
|
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
@@ -260,9 +311,14 @@ class AutomationStore:
|
|||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
if session_id:
|
if session_id:
|
||||||
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute("DELETE FROM automation_jobs")
|
conn.execute("DELETE FROM automation_jobs")
|
||||||
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
||||||
|
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Human review overlays the AI verdict without changing execution evidence."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apps.admin_console.services.automation_store import AutomationStore
|
||||||
|
from artemis.runtime.case_result import read_case_result
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_result(
|
||||||
|
session_id: str, ai_result: dict | None, storage: AutomationStore
|
||||||
|
) -> dict | None:
|
||||||
|
original = {**ai_result, "source": "ai"} if ai_result else None
|
||||||
|
record = storage.get("case_review", session_id)
|
||||||
|
reviews = (record or {}).get("reviews") or []
|
||||||
|
if not reviews:
|
||||||
|
return original
|
||||||
|
latest = reviews[-1]
|
||||||
|
return {
|
||||||
|
"status": latest["status"],
|
||||||
|
"reason": latest["reason"],
|
||||||
|
"evidence": [],
|
||||||
|
"source": "human",
|
||||||
|
"reviewed_at": latest["reviewed_at"],
|
||||||
|
"ai_result": original,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_reviewed_result(
|
||||||
|
session_id: str,
|
||||||
|
execution_status: str,
|
||||||
|
error: str = "",
|
||||||
|
*,
|
||||||
|
storage: AutomationStore,
|
||||||
|
root: Path | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
return reviewed_result(
|
||||||
|
session_id,
|
||||||
|
read_case_result(session_id, execution_status, error, root=root),
|
||||||
|
storage,
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ from apps.admin_console.database.repositories.step_repository import StepReposit
|
|||||||
from apps.admin_console.services.automation_store import store, identifier
|
from apps.admin_console.services.automation_store import store, identifier
|
||||||
from apps.admin_console.services.task_import import execution_title
|
from apps.admin_console.services.task_import import execution_title
|
||||||
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
||||||
|
from apps.admin_console.services.case_review import reviewed_result
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
EXPORT_LOCK = threading.RLock()
|
||||||
@@ -65,7 +66,10 @@ def text(value) -> str:
|
|||||||
def verdict(outcome: dict | None) -> str:
|
def verdict(outcome: dict | None) -> str:
|
||||||
result = (outcome or {}).get("test_result")
|
result = (outcome or {}).get("test_result")
|
||||||
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
|
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
|
||||||
return RESULT_LABELS[result["status"]]
|
label = RESULT_LABELS[result["status"]]
|
||||||
|
if result.get("source"):
|
||||||
|
label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
|
||||||
|
return label
|
||||||
tests = (outcome or {}).get("tests") or {}
|
tests = (outcome or {}).get("tests") or {}
|
||||||
if tests.get("failed", 0):
|
if tests.get("failed", 0):
|
||||||
return "验证失败"
|
return "验证失败"
|
||||||
@@ -183,9 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
case = meta.get("case") or {}
|
case = meta.get("case") or {}
|
||||||
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
|
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
|
||||||
error = failure_reason(directory, status, meta.get("error"))
|
error = failure_reason(directory, status, meta.get("error"))
|
||||||
test_result = resolve_case_result(outcome, status, error)
|
ai_result = resolve_case_result(outcome, status, error)
|
||||||
|
test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store)
|
||||||
if test_result:
|
if test_result:
|
||||||
outcome = {**outcome, "test_result": test_result.model_dump()}
|
outcome = {**outcome, "test_result": test_result}
|
||||||
verification = verdict(outcome)
|
verification = verdict(outcome)
|
||||||
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
||||||
verification = "未验证(任务异常终止)"
|
verification = "未验证(任务异常终止)"
|
||||||
@@ -204,7 +209,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
"expected": case.get("expected", ""),
|
"expected": case.get("expected", ""),
|
||||||
"status": status,
|
"status": status,
|
||||||
"verdict": verification,
|
"verdict": verification,
|
||||||
"test_result": test_result.model_dump() if test_result else None,
|
"test_result": test_result,
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"content": content,
|
"content": content,
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
@@ -241,6 +246,13 @@ def metadata(report: dict) -> list[tuple]:
|
|||||||
("预期结果", report["expected"] or "未指定"),
|
("预期结果", report["expected"] or "未指定"),
|
||||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||||
("验证结论", report["verdict"]),
|
("验证结论", report["verdict"]),
|
||||||
|
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
|
||||||
|
(
|
||||||
|
"原 AI 结论",
|
||||||
|
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
|
||||||
|
if (report.get("test_result") or {}).get("source") == "human"
|
||||||
|
else "—",
|
||||||
|
),
|
||||||
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
|
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
|
||||||
(
|
(
|
||||||
"验收证据",
|
"验收证据",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
||||||
</div>
|
</div>
|
||||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template>
|
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
|
||||||
|
|
||||||
<!-- Dropdown Popover Panel for Task Queue -->
|
<!-- Dropdown Popover Panel for Task Queue -->
|
||||||
<ng-template
|
<ng-template
|
||||||
@@ -61,6 +61,7 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined">list_alt</span>
|
<span class="material-symbols-outlined">list_alt</span>
|
||||||
<span>任务队列</span>
|
<span>任务队列</span>
|
||||||
</div>
|
</div>
|
||||||
|
<app-task-batch-filter />
|
||||||
<div class="dropdown-section-label">
|
<div class="dropdown-section-label">
|
||||||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||||||
<span>当前队列 ({{ activeQueue().length }})</span>
|
<span>当前队列 ({{ activeQueue().length }})</span>
|
||||||
@@ -86,7 +87,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
{{ getTaskStatus(session) | uiLabel }}
|
{{ getTaskStatus(session) | uiLabel }}
|
||||||
</span>
|
</span>
|
||||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" />
|
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||||
<div class="item-header-actions">
|
<div class="item-header-actions">
|
||||||
@if (getDeviceSerial(session)) {
|
@if (getDeviceSerial(session)) {
|
||||||
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||||
@@ -586,7 +587,7 @@ limitations under the License.
|
|||||||
result rows close the block (see check-result below). -->
|
result rows close the block (see check-result below). -->
|
||||||
@if (isCheckerBlock(block)) {
|
@if (isCheckerBlock(block)) {
|
||||||
@if (block.data.phase === 'outcome' && block.data.test_result) {
|
@if (block.data.phase === 'outcome' && block.data.test_result) {
|
||||||
<app-test-result-card [result]="block.data.test_result" />
|
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||||
<div class="check-header">
|
<div class="check-header">
|
||||||
|
|||||||
@@ -3702,9 +3702,12 @@ $stream-row-height: 25px;
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 10.5px;
|
box-sizing: border-box;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 5px;
|
padding: 0 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../service
|
|||||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||||
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
|
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
|
||||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||||
|
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
||||||
@@ -239,7 +240,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -390,7 +391,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
@@ -854,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Top Nav Action Methods
|
// Top Nav Action Methods
|
||||||
|
public openBatchQueue(): void {
|
||||||
|
this.isAutomationOpen.set(false);
|
||||||
|
this.isNotesDropdownOpen.set(false);
|
||||||
|
this.isTaskDropdownOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
public toggleTaskDropdown(event: Event): void {
|
public toggleTaskDropdown(event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.isTaskDropdownOpen.update(v => !v);
|
this.isTaskDropdownOpen.update(v => !v);
|
||||||
|
|||||||
+5
-2
@@ -5,6 +5,7 @@
|
|||||||
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
|
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
|
||||||
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
|
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
|
||||||
@if (tab() === 'reports') {
|
@if (tab() === 'reports') {
|
||||||
|
@if(batch(); as b){<button type="button" class="back-to-batch" (click)="tab.set('batches')">返回批次:{{b.name}}</button>}
|
||||||
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
|
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
|
||||||
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
|
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
|
||||||
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
|
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<div class="report-overview">
|
<div class="report-overview">
|
||||||
<strong class="report-title">{{r.name}}</strong>
|
<strong class="report-title">{{r.name}}</strong>
|
||||||
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
||||||
@if(r.test_result; as result){<app-test-result-card [result]="result" />}
|
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
||||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||||
</div>
|
</div>
|
||||||
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
||||||
@@ -84,6 +85,8 @@
|
|||||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||||
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
|
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button type="button" (click)="showBatchQueue(b.id)">在任务队列查看此批次</button>
|
||||||
|
@if(agent.currentSessionId()){<button type="button" (click)="tab.set('reports')">查看当前任务报告</button>}
|
||||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||||
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||||
@@ -103,6 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
||||||
|
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
|
||||||
|
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
|
||||||
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
||||||
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
||||||
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
||||||
|
|||||||
+26
-1
@@ -12,7 +12,9 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
currentSessionId: signal<string | null>(null),
|
currentSessionId: signal<string | null>(null),
|
||||||
runningSessionId: signal<string | null>(null),
|
runningSessionId: signal<string | null>(null),
|
||||||
agentStatus: signal('idle'),
|
agentStatus: signal('idle'),
|
||||||
sessions: signal<any[]>([])
|
sessions: signal<any[]>([]),
|
||||||
|
resultRevision: signal(0), batchFilter: signal(''), activeTab: signal('automation'),
|
||||||
|
selectSession: jasmine.createSpy('selectSession'), applyCaseReview: jasmine.createSpy('applyCaseReview')
|
||||||
};
|
};
|
||||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||||
http.delete.calls.reset();
|
http.delete.calls.reset();
|
||||||
http.post.calls.reset();
|
http.post.calls.reset();
|
||||||
|
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AutomationPanelComponent],
|
imports: [AutomationPanelComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -117,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('查看同批次用例时保留批次列表,可直接筛选该批次队列', () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
component.batch.set({id:'batch-1',name:'冒烟批次',status:'completed',jobs:[
|
||||||
|
{session_id:'task-a',status:'completed',case:{name:'用例 A',goal:'观察设置'}},
|
||||||
|
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
|
||||||
|
jobs[0].click(); jobs[1].click();
|
||||||
|
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||||
|
expect(component.tab()).toBe('batches');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.job').length).toBe(2);
|
||||||
|
const queueRequested = jasmine.createSpy('queueRequested');
|
||||||
|
component.queueRequested.subscribe(queueRequested);
|
||||||
|
component.showBatchQueue('batch-1');
|
||||||
|
expect(agent.batchFilter()).toBe('batch-1'); expect(agent.activeTab()).toBe('tasks');
|
||||||
|
expect(queueRequested).toHaveBeenCalled();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
||||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
@@ -8,6 +8,7 @@ import { SystemService } from '../../services/system.service';
|
|||||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||||
|
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||||
|
|
||||||
interface ArchiveProgress {
|
interface ArchiveProgress {
|
||||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
@@ -24,6 +25,7 @@ interface ArchiveProgress {
|
|||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
})
|
})
|
||||||
export class AutomationPanelComponent {
|
export class AutomationPanelComponent {
|
||||||
|
queueRequested = output<void>();
|
||||||
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
@@ -50,6 +52,7 @@ export class AutomationPanelComponent {
|
|||||||
const timer = setInterval(() => void this.refresh(), 5000);
|
const timer = setInterval(() => void this.refresh(), 5000);
|
||||||
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
this.agent.resultRevision();
|
||||||
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
||||||
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
||||||
});
|
});
|
||||||
@@ -57,6 +60,9 @@ export class AutomationPanelComponent {
|
|||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||||
|
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
|
||||||
|
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
||||||
|
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
||||||
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||||
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||||
private async prepareBatchArchive(id: string) {
|
private async prepareBatchArchive(id: string) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ limitations under the License.
|
|||||||
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
||||||
<!-- Tasks Tab Content -->
|
<!-- Tasks Tab Content -->
|
||||||
@if (agentService.activeTab() === 'tasks') {
|
@if (agentService.activeTab() === 'tasks') {
|
||||||
|
<app-task-batch-filter />
|
||||||
<!-- Active Queue Section -->
|
<!-- Active Queue Section -->
|
||||||
<div class="queue-section-header">
|
<div class="queue-section-header">
|
||||||
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
|
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
|
||||||
@@ -82,7 +83,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
{{ getTaskStatus(session) | uiLabel }}
|
{{ getTaskStatus(session) | uiLabel }}
|
||||||
</span>
|
</span>
|
||||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" />
|
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||||
<div class="task-card-header-actions">
|
<div class="task-card-header-actions">
|
||||||
@if (getDeviceSerial(session)) {
|
@if (getDeviceSerial(session)) {
|
||||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||||
@@ -120,7 +121,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<app-task-id-copy [taskId]="session.session_id" />
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
|
|||||||
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 10.5px;
|
box-sizing: border-box;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 5px;
|
padding: 0 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { Session } from '../../core/models/session.model';
|
import { Session } from '../../core/models/session.model';
|
||||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
|
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||||
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-interface',
|
selector: 'app-chat-interface',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './chat-interface.component.html',
|
templateUrl: './chat-interface.component.html',
|
||||||
styleUrl: './chat-interface.component.scss',
|
styleUrl: './chat-interface.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -55,7 +56,7 @@ export class ChatInterfaceComponent {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'paused' || status === 'pending';
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
|
|||||||
@@ -43,6 +43,17 @@
|
|||||||
border-color: rgba(203, 213, 225, 0.9);
|
border-color: rgba(203, 213, 225, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
padding: 0 10px 0 8px;
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-tab-btn {
|
.nav-tab-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { version } from '../../../../package.json';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-nav-switcher',
|
selector: 'app-nav-switcher',
|
||||||
@@ -43,9 +44,12 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||||
<span class="tab-label">工作区</span>
|
<span class="tab-label">工作区</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
||||||
</nav>
|
</nav>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
styleUrls: ['./nav-switcher.component.scss']
|
styleUrls: ['./nav-switcher.component.scss']
|
||||||
})
|
})
|
||||||
export class NavSwitcherComponent {}
|
export class NavSwitcherComponent {
|
||||||
|
readonly version = version;
|
||||||
|
}
|
||||||
+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;
|
let http: HttpTestingController;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
||||||
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]});
|
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
|
||||||
|
const agent = TestBed.inject(AgentService);
|
||||||
|
agent.batchFilter.set('batch-2'); fixture.detectChanges();
|
||||||
|
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
|
||||||
|
expect(filtered.request.params.get('page')).toBe('1');
|
||||||
|
expect(filtered.request.params.get('q')).toBe('设置');
|
||||||
|
filtered.flush({total:0,items:[]});
|
||||||
|
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
|
||||||
|
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
|
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
@@ -14,18 +14,29 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@if(loading()){<small>正在查询…</small>}
|
@if(loading()){<small>正在查询…</small>}
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
</section>`,
|
</section>`,
|
||||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||||
})
|
})
|
||||||
export class TaskHistoryComponent {
|
export class TaskHistoryComponent {
|
||||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||||
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
||||||
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||||
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
constructor(){
|
||||||
|
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
||||||
|
this.load();
|
||||||
|
effect(()=>{
|
||||||
|
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
||||||
|
if(nextFilter!==filter || nextRevision!==resultRevision){
|
||||||
|
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
|
||||||
|
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||||
|
}
|
||||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||||
@@ -35,6 +46,7 @@ export class TaskHistoryComponent {
|
|||||||
load(){
|
load(){
|
||||||
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
||||||
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
||||||
|
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
||||||
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
||||||
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
||||||
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
||||||
|
|||||||
+7
-2
@@ -8,7 +8,7 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-test-result-badge',
|
selector: 'app-test-result-badge',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
template: `<span [class]="'test-result-badge ' + status()" [class.prominent]="prominent()" [title]="result()?.reason || '执行状态与测试结论分别记录'"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||||
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
||||||
@@ -17,6 +17,8 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
|||||||
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
||||||
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
||||||
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
||||||
|
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
||||||
|
.compact .material-symbols-outlined{font-size:13px}
|
||||||
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
||||||
.prominent .material-symbols-outlined{font-size:18px}
|
.prominent .material-symbols-outlined{font-size:18px}
|
||||||
`]
|
`]
|
||||||
@@ -24,8 +26,11 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
|||||||
export class TestResultBadgeComponent {
|
export class TestResultBadgeComponent {
|
||||||
result = input<CaseTestResult | null | undefined>();
|
result = input<CaseTestResult | null | undefined>();
|
||||||
executionStatus = input('');
|
executionStatus = input('');
|
||||||
|
compact = input(false);
|
||||||
prominent = input(false);
|
prominent = input(false);
|
||||||
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
||||||
label = computed(() => this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
label = computed(() => this.result()?.source === 'human'
|
||||||
|
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
|
||||||
|
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||||
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
||||||
}
|
}
|
||||||
+46
@@ -1,7 +1,11 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestResultCardComponent } from './test-result-card.component';
|
import { TestResultCardComponent } from './test-result-card.component';
|
||||||
|
|
||||||
describe('用例测试结果卡片', () => {
|
describe('用例测试结果卡片', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
|
||||||
|
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||||
it('显式展示不通过及原因,允许展开证据', () => {
|
it('显式展示不通过及原因,允许展开证据', () => {
|
||||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
||||||
@@ -16,4 +20,46 @@ describe('用例测试结果卡片', () => {
|
|||||||
expect(details.open).toBeTrue();
|
expect(details.open).toBeTrue();
|
||||||
expect(details.textContent).toContain('截图显示搜索设置');
|
expect(details.textContent).toContain('截图显示搜索设置');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
|
||||||
|
fixture.componentRef.setInput('result', ai);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
|
||||||
|
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '实际符合预期,已人工核对';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
|
||||||
|
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
|
||||||
|
expect(request.request.method).toBe('PUT');
|
||||||
|
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
|
||||||
|
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
|
||||||
|
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
|
||||||
|
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
|
||||||
|
expect(saved).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('人工通过');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
|
||||||
|
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
|
||||||
|
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
|
||||||
|
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
|
||||||
|
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '人工发现异常';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-fail').click();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.note).toBe('人工发现异常');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+49
-4
@@ -1,15 +1,30 @@
|
|||||||
import { Component, input } from '@angular/core';
|
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||||
import { CaseTestResult } from '../../core/models/session.model';
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-test-result-card',
|
selector: 'app-test-result-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TestResultBadgeComponent],
|
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
||||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||||
<header><span class="caption">用例测试结果</span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||||
<p class="reason">{{result().reason}}</p>
|
<p class="reason">{{result().reason}}</p>
|
||||||
|
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||||
|
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
||||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
||||||
|
@if(canReview()){
|
||||||
|
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||||
|
@if(reviewing()){
|
||||||
|
<div class="review-form" aria-label="人工复验">
|
||||||
|
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
|
||||||
|
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
|
||||||
|
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</section>`,
|
</section>`,
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:block;margin:8px 0}
|
:host{display:block;margin:8px 0}
|
||||||
@@ -19,13 +34,43 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
|
|||||||
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
||||||
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||||
.caption{font-size:12px;font-weight:600;color:#475569}
|
.caption{font-size:12px;font-weight:600;color:#475569}
|
||||||
|
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
|
||||||
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
||||||
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
||||||
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
||||||
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
|
||||||
|
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
||||||
|
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
|
||||||
|
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
||||||
|
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
|
||||||
|
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
|
||||||
|
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
|
||||||
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class TestResultCardComponent {
|
export class TestResultCardComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
result = input.required<CaseTestResult>();
|
result = input.required<CaseTestResult>();
|
||||||
|
sessionId = input<string | null>(null);
|
||||||
|
executionStatus = input('');
|
||||||
|
reviewSaved = output<CaseReviewResponse>();
|
||||||
|
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
|
||||||
|
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
|
||||||
|
constructor() {
|
||||||
|
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
|
||||||
|
}
|
||||||
|
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
|
||||||
|
saveReview(status: 'passed' | 'failed') {
|
||||||
|
const id = this.sessionId();
|
||||||
|
if (!id || !this.canReview() || this.saving()) return;
|
||||||
|
this.saving.set(true); this.error.set('');
|
||||||
|
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
|
||||||
|
next: response => {
|
||||||
|
this.saving.set(false); this.reviewSaved.emit(response);
|
||||||
|
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
|
||||||
|
},
|
||||||
|
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,10 +25,20 @@ export interface CaseTestResult {
|
|||||||
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
||||||
reason: string;
|
reason: string;
|
||||||
evidence: string[];
|
evidence: string[];
|
||||||
|
source?: 'ai' | 'human';
|
||||||
|
reviewed_at?: number;
|
||||||
|
ai_result?: CaseTestResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseReviewResponse {
|
||||||
|
id: string;
|
||||||
|
test_result: CaseTestResult;
|
||||||
|
verdict: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskQueueItem {
|
export interface TaskQueueItem {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
batch_id?: string | null;
|
||||||
goal: string;
|
goal: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||||
@@ -40,6 +50,7 @@ export interface TaskQueueItem {
|
|||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
batch_id?: string | null;
|
||||||
initial_goal: string;
|
initial_goal: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time?: number;
|
end_time?: number;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
|
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
||||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||||
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
||||||
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
||||||
@@ -85,6 +85,23 @@ export class AgentService {
|
|||||||
private rawSessions = signal<Session[]>([]);
|
private rawSessions = signal<Session[]>([]);
|
||||||
private pendingQueue = signal<Session[]>([]);
|
private pendingQueue = signal<Session[]>([]);
|
||||||
public activeTasks = signal<any[]>([]);
|
public activeTasks = signal<any[]>([]);
|
||||||
|
public batchFilter = signal('');
|
||||||
|
public resultRevision = signal(0);
|
||||||
|
|
||||||
|
public matchesBatchFilter(session: Session): boolean {
|
||||||
|
const filter = this.batchFilter();
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter === '__batch__') return !!session.batch_id;
|
||||||
|
if (filter === '__single__') return !session.batch_id;
|
||||||
|
return session.batch_id === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyCaseReview(review: CaseReviewResponse): void {
|
||||||
|
this.rawSessions.update(items => items.map(session => session.session_id === review.id
|
||||||
|
? {...session, test_result: review.test_result} : session));
|
||||||
|
this.resultRevision.update(value => value + 1);
|
||||||
|
this.fetchSessions();
|
||||||
|
}
|
||||||
// Persistent tracking of active/pending sessions across polling boundaries
|
// Persistent tracking of active/pending sessions across polling boundaries
|
||||||
private activeSessionTracking = new Map<string, Session>();
|
private activeSessionTracking = new Map<string, Session>();
|
||||||
|
|
||||||
@@ -129,6 +146,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
const finalSession: Session = {
|
const finalSession: Session = {
|
||||||
...s,
|
...s,
|
||||||
|
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
||||||
status: sStatus,
|
status: sStatus,
|
||||||
device_serial: serial,
|
device_serial: serial,
|
||||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||||
@@ -149,6 +167,7 @@ export class AgentService {
|
|||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const finalPending: Session = {
|
const finalPending: Session = {
|
||||||
...p,
|
...p,
|
||||||
|
batch_id: p.batch_id || activeMatch?.batch_id || null,
|
||||||
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
||||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||||
};
|
};
|
||||||
@@ -165,6 +184,8 @@ export class AgentService {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
const newSession: Session = {
|
const newSession: Session = {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
|
batch_id: at.batch_id || null,
|
||||||
|
display_name: at.display_name,
|
||||||
initial_goal: at.goal || goal || '',
|
initial_goal: at.goal || goal || '',
|
||||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||||
status: at.status || 'running',
|
status: at.status || 'running',
|
||||||
@@ -1578,6 +1599,8 @@ export class AgentService {
|
|||||||
if (typeof item === 'object' && item !== null) {
|
if (typeof item === 'object' && item !== null) {
|
||||||
return {
|
return {
|
||||||
session_id: item.session_id || `pending-task-${index}`,
|
session_id: item.session_id || `pending-task-${index}`,
|
||||||
|
batch_id: item.batch_id || null,
|
||||||
|
display_name: item.display_name,
|
||||||
initial_goal: item.goal || '',
|
initial_goal: item.goal || '',
|
||||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||||
status: item.status || 'pending',
|
status: item.status || 'pending',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022"
|
"module": "ES2022"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
|
|
||||||
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
|
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
|
||||||
|
|
||||||
|
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
|
||||||
|
|
||||||
|
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
|
||||||
|
|
||||||
## Excel 批量导入
|
## Excel 批量导入
|
||||||
|
|
||||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
+17
-2
@@ -33,10 +33,26 @@ function Get-LaunchText {
|
|||||||
return $English
|
return $English
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$RootDir = Split-Path -Parent $ScriptDir
|
||||||
|
$ProjectVersion = 'unknown'
|
||||||
|
try {
|
||||||
|
$inProjectSection = $false
|
||||||
|
foreach ($line in Get-Content -LiteralPath (Join-Path $RootDir 'pyproject.toml') -Encoding UTF8 -ErrorAction Stop) {
|
||||||
|
if ($line -match '^\[project\]\s*$') { $inProjectSection = $true; continue }
|
||||||
|
if ($inProjectSection -and $line -match '^\[') { break }
|
||||||
|
if ($inProjectSection -and $line -match '^version\s*=\s*"([^"]+)"') {
|
||||||
|
$ProjectVersion = $Matches[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files.
|
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files.
|
||||||
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
|
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
|
||||||
if ($Help) {
|
if ($Help) {
|
||||||
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
|
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
|
||||||
|
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
|
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
|
||||||
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
|
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
|
||||||
@@ -51,6 +67,7 @@ if ($Help) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
Write-Host '======================================================'
|
Write-Host '======================================================'
|
||||||
|
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
|
||||||
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
|
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
|
||||||
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
|
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
|
||||||
Write-Host '======================================================'
|
Write-Host '======================================================'
|
||||||
@@ -67,8 +84,6 @@ try {
|
|||||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
|
||||||
$RootDir = Split-Path -Parent $ScriptDir
|
|
||||||
Set-Location $RootDir
|
Set-Location $RootDir
|
||||||
|
|
||||||
# Startup never mutates source; upgrades run from a staged, independent process.
|
# Startup never mutates source; upgrades run from a staged, independent process.
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ NC='\033[0m'
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "${SCRIPT_DIR}"
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
ARTEMIS_VERSION="$(awk '
|
||||||
|
/^\[project\]/ { in_project = 1; next }
|
||||||
|
/^\[/ { in_project = 0 }
|
||||||
|
in_project && /^version[[:space:]]*=/ { split($0, fields, "\""); print fields[2]; exit }
|
||||||
|
' "${SCRIPT_DIR}/pyproject.toml" 2>/dev/null || true)"
|
||||||
|
ARTEMIS_VERSION="${ARTEMIS_VERSION:-unknown}"
|
||||||
|
|
||||||
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
||||||
echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
|
echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
|
||||||
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
||||||
|
echo -e "${CYAN} Version: v${ARTEMIS_VERSION}${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 1. Normalize PATH with standard toolchain and package manager paths
|
# 1. Normalize PATH with standard toolchain and package manager paths
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
|
|||||||
)
|
)
|
||||||
report = reports.session_report(job["session_id"])
|
report = reports.session_report(job["session_id"])
|
||||||
assert "连续 3 次" in report["error"]
|
assert "连续 3 次" in report["error"]
|
||||||
assert report["verdict"] == "测试阻塞"
|
assert report["verdict"] == "测试阻塞(AI确认)"
|
||||||
assert report["test_result"]["status"] == "blocked"
|
assert report["test_result"]["status"] == "blocked"
|
||||||
assert "连续 3 次" in report["test_result"]["reason"]
|
assert "连续 3 次" in report["test_result"]["reason"]
|
||||||
assert "abc123secret" not in reports.failure_reason(
|
assert "abc123secret" not in reports.failure_reason(
|
||||||
@@ -844,8 +844,8 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
|
|||||||
report = client.get(f"/api/automation/reports/{job['session_id']}").json()
|
report = client.get(f"/api/automation/reports/{job['session_id']}").json()
|
||||||
for item in (history, batch_job, report):
|
for item in (history, batch_job, report):
|
||||||
assert item["status"] == "completed"
|
assert item["status"] == "completed"
|
||||||
assert item["test_result"] == result
|
assert item["test_result"] == {**result, "source": "ai"}
|
||||||
assert report["verdict"] == "测试不通过"
|
assert report["verdict"] == "测试不通过(AI确认)"
|
||||||
paths = reports.export_session(job["session_id"])
|
paths = reports.export_session(job["session_id"])
|
||||||
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
||||||
doc_text = "\n".join(
|
doc_text = "\n".join(
|
||||||
@@ -856,10 +856,133 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
|
|||||||
assert result["evidence"][0] in doc_text
|
assert result["evidence"][0] in doc_text
|
||||||
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
|
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
|
||||||
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
|
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
|
||||||
assert "测试不通过" in values
|
assert "测试不通过(AI确认)" in values
|
||||||
assert result["reason"] in values
|
assert result["reason"] in values
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_cache(
|
||||||
|
client, isolated, monkeypatch
|
||||||
|
):
|
||||||
|
from artemis.runtime import case_result
|
||||||
|
from apps.admin_console.routers import sessions as sessions_api
|
||||||
|
|
||||||
|
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
jobs = isolated.jobs(batch["id"])
|
||||||
|
for job in jobs:
|
||||||
|
isolated.save_job({**job, "status": "completed", "ended_at": time.time()})
|
||||||
|
session_id = jobs[0]["session_id"]
|
||||||
|
ai = {"status": "failed", "reason": "AI识别到预期不匹配", "evidence": ["步骤 3 截图"]}
|
||||||
|
directory = reports.TRACES_PATH / session_id
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
outcome_file = directory / "run_outcome.json"
|
||||||
|
original = json.dumps({"task_status": "completed", "test_result": ai}, ensure_ascii=False)
|
||||||
|
outcome_file.write_text(original, encoding="utf-8")
|
||||||
|
archive = reports.export_batch(batch["id"])
|
||||||
|
isolated.put("batch", batch["id"], {**batch, "report_ready": True})
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/automation/reports/{session_id}/review",
|
||||||
|
json={"status": "passed", "reason": "已人工核对,符合预期"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "completed"
|
||||||
|
assert response.json()["verdict"] == "测试通过(人工确认)"
|
||||||
|
assert outcome_file.read_text(encoding="utf-8") == original
|
||||||
|
assert not archive.exists()
|
||||||
|
assert not isolated.get("batch", batch["id"])["report_ready"]
|
||||||
|
assert len(isolated.get("case_review", session_id)["reviews"]) == 1
|
||||||
|
for result in (
|
||||||
|
response.json(),
|
||||||
|
client.get("/api/automation/history", params={"q": session_id}).json()["items"][0],
|
||||||
|
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
|
||||||
|
):
|
||||||
|
assert result["test_result"]["source"] == "human"
|
||||||
|
assert result["test_result"]["status"] == "passed"
|
||||||
|
assert result["test_result"]["ai_result"] == {**ai, "source": "ai"}
|
||||||
|
assert result["test_result"]["reviewed_at"] > 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions_api, "store", isolated)
|
||||||
|
monkeypatch.setattr(sessions_api, "TRACES_PATH", reports.TRACES_PATH)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sessions_api.session_repo,
|
||||||
|
"get_session_by_id",
|
||||||
|
lambda sid: {"session_id": sid, "status": "completed"},
|
||||||
|
)
|
||||||
|
client.app.include_router(sessions_api.router)
|
||||||
|
detail = client.get(f"/api/sessions/{session_id}").json()
|
||||||
|
assert detail["test_result"]["source"] == "human"
|
||||||
|
assert detail["batch_id"] == batch["id"]
|
||||||
|
|
||||||
|
downloaded = client.get(f"/api/automation/batches/{batch['id']}/archive")
|
||||||
|
assert downloaded.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(downloaded.content)) as bundle:
|
||||||
|
wb = load_workbook(BytesIO(bundle.read("批次结果.xlsx")))
|
||||||
|
assert wb.active["D2"].value == "测试通过(人工确认)"
|
||||||
|
assert "AI确认" in wb.active["D3"].value
|
||||||
|
doc = Document(BytesIO(bundle.read("批次汇总.docx")))
|
||||||
|
assert doc.tables[0].rows[1].cells[3].text == "测试通过(人工确认)"
|
||||||
|
|
||||||
|
changed = client.put(f"/api/automation/reports/{session_id}/review", json={"status": "failed"})
|
||||||
|
assert changed.status_code == 200
|
||||||
|
assert changed.json()["verdict"] == "测试不通过(人工确认)"
|
||||||
|
assert len(isolated.get("case_review", session_id)["reviews"]) == 2
|
||||||
|
assert outcome_file.read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
url = f"/api/automation/reports/{job['session_id']}/review"
|
||||||
|
assert client.put(url, json={"status": "passed"}).status_code == 409
|
||||||
|
assert isolated.get("case_review", job["session_id"]) is None
|
||||||
|
assert client.put(url, json={"status": "inconclusive"}).status_code == 422
|
||||||
|
assert client.put(url, json={"status": "passed", "source": "human"}).status_code == 422
|
||||||
|
assert client.put(url, json={"status": "passed", "reason": "字" * 2001}).status_code == 422
|
||||||
|
assert (
|
||||||
|
client.put("/api/automation/reports/missing/review", json={"status": "passed"}).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
isolated.save_job({**job, "status": "completed"})
|
||||||
|
monkeypatch.setattr(api.archive_service, "status", lambda *args: {"status": "preparing"})
|
||||||
|
assert client.put(url, json={"status": "passed"}).status_code == 409
|
||||||
|
assert isolated.get("case_review", job["session_id"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_filter_searches_full_history_and_reviews_are_removed_with_batch(client, isolated):
|
||||||
|
first, second = [service.create_batch(payload(name=name)) for name in ("第一批", "第二批")]
|
||||||
|
for batch in (first, second):
|
||||||
|
for job in isolated.jobs(batch["id"]):
|
||||||
|
isolated.save_job({**job, "status": "completed"})
|
||||||
|
with isolated.connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
|
||||||
|
("single-case", "单独检查", time.time(), "completed"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
filtered = client.get(
|
||||||
|
"/api/automation/history",
|
||||||
|
params={"batch_id": first["id"], "q": "用例", "size": 1, "page": 2},
|
||||||
|
).json()
|
||||||
|
assert filtered["total"] == 2
|
||||||
|
assert len(filtered["items"]) == 1
|
||||||
|
assert filtered["items"][0]["batch_id"] == first["id"]
|
||||||
|
assert (
|
||||||
|
client.get("/api/automation/history", params={"batch_id": "__batch__"}).json()["total"] == 4
|
||||||
|
)
|
||||||
|
singles = client.get("/api/automation/history", params={"batch_id": "__single__"}).json()
|
||||||
|
assert [item["session_id"] for item in singles["items"]] == ["single-case"]
|
||||||
|
assert client.get("/api/automation/history", params={"batch_id": "bad/path"}).status_code == 400
|
||||||
|
reviewed_id = isolated.jobs(first["id"])[0]["session_id"]
|
||||||
|
isolated.save_case_review(reviewed_id, "passed", "人工核对")
|
||||||
|
isolated.begin_delete_batch(first["id"])
|
||||||
|
isolated.delete_batch(first["id"])
|
||||||
|
assert isolated.get("case_review", reviewed_id) is None
|
||||||
|
assert len(isolated.jobs(second["id"])) == 2
|
||||||
|
isolated.save_case_review("single-case", "failed", "不符合预期")
|
||||||
|
isolated.delete_history("single-case")
|
||||||
|
assert isolated.get("case_review", "single-case") is None
|
||||||
|
|
||||||
|
|
||||||
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||||
client, isolated, tmp_path, monkeypatch
|
client, isolated, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in new issue
Block a user