feat: support batch repetition and selective case import
This commit is contained in:
1 parent
df66897ff4
commit
4d6560dbd4
16 files changed
+286
-27
No files matched your search
@@ -73,4 +73,5 @@ ARTEMIS_DEFAULT_PROFILE=flash
|
|||||||
|
|
||||||
# 批量任务与 Excel 导入限制
|
# 批量任务与 Excel 导入限制
|
||||||
ARTEMIS_IMPORT_MAX_ROWS=1000
|
ARTEMIS_IMPORT_MAX_ROWS=1000
|
||||||
|
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
|
||||||
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
||||||
@@ -30,6 +30,7 @@ class BatchRequest(ReportPreferences):
|
|||||||
device_serial: str = Field(min_length=1, max_length=200)
|
device_serial: str = Field(min_length=1, max_length=200)
|
||||||
profile: Literal["flash", "pro"] = "flash"
|
profile: Literal["flash", "pro"] = "flash"
|
||||||
stop_on_failure: bool = False
|
stop_on_failure: bool = False
|
||||||
|
repeat_count: int = Field(default=1, ge=1, strict=True)
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
ge=30,
|
ge=30,
|
||||||
@@ -52,6 +53,7 @@ def preferences():
|
|||||||
return {
|
return {
|
||||||
**store.preferences(),
|
**store.preferences(),
|
||||||
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
|
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -256,7 +258,12 @@ def history(
|
|||||||
[*args, size, (page - 1) * size],
|
[*args, size, (page - 1) * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
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])
|
||||||
for item in items:
|
for item in items:
|
||||||
if item["case_id"]:
|
if job := jobs.get(item["session_id"]):
|
||||||
|
item["name"] = task_import.execution_title(job)
|
||||||
|
item["iteration"] = job.get("iteration", 1)
|
||||||
|
item["repeat_count"] = job.get("repeat_count", 1)
|
||||||
|
elif item["case_id"]:
|
||||||
item["name"] = task_import.case_title(item)
|
item["name"] = task_import.case_title(item)
|
||||||
return {"items": items, "total": total, "page": page, "size": size}
|
return {"items": items, "total": total, "page": page, "size": size}
|
||||||
@@ -28,7 +28,7 @@ try:
|
|||||||
from admin_console.services.media_service import media_service
|
from admin_console.services.media_service import media_service
|
||||||
from admin_console.services.model_service import model_service
|
from admin_console.services.model_service import model_service
|
||||||
from admin_console.services.automation_store import store
|
from admin_console.services.automation_store import store
|
||||||
from admin_console.services.task_import import case_title
|
from admin_console.services.task_import import execution_title
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
@@ -36,7 +36,7 @@ except ImportError:
|
|||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
from apps.admin_console.services.task_import import case_title
|
from apps.admin_console.services.task_import import execution_title
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["sessions"])
|
router = APIRouter(tags=["sessions"])
|
||||||
@@ -83,7 +83,9 @@ def _list_sessions_sync():
|
|||||||
if job:
|
if job:
|
||||||
case = job.get("case") or {}
|
case = job.get("case") or {}
|
||||||
row_dict["case_id"] = case.get("case_id") or ""
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
row_dict["display_name"] = case_title(case) 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["repeat_count"] = job.get("repeat_count", 1)
|
||||||
row_dict["error"] = job.get("error") or ""
|
row_dict["error"] = job.get("error") or ""
|
||||||
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
row_dict["status"] = job["status"]
|
row_dict["status"] = job["status"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
||||||
from apps.admin_console.services.task_import import case_goal, case_title
|
from apps.admin_console.services.task_import import case_goal, execution_title
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
from artemis.runtime.task_limits import task_timeout
|
from artemis.runtime.task_limits import task_timeout
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ async def tick_batches():
|
|||||||
runtime_overrides=options,
|
runtime_overrides=options,
|
||||||
session_id=job["session_id"],
|
session_id=job["session_id"],
|
||||||
ingress="batch",
|
ingress="batch",
|
||||||
display_name=case_title(job["case"]),
|
display_name=execution_title(job),
|
||||||
)
|
)
|
||||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||||
@@ -330,6 +330,15 @@ def has_legacy_replay(batch_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def create_batch(payload: dict) -> dict:
|
def create_batch(payload: dict) -> dict:
|
||||||
|
repeat_count = payload.get("repeat_count", 1)
|
||||||
|
if type(repeat_count) is not int or repeat_count < 1:
|
||||||
|
raise ValueError("循环次数必须为正整数")
|
||||||
|
cases = payload["cases"]
|
||||||
|
total = len(cases) * repeat_count
|
||||||
|
if not cases or total > settings.ARTEMIS_BATCH_MAX_EXECUTIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"请选择用例;用例数 × 循环次数不能超过 {settings.ARTEMIS_BATCH_MAX_EXECUTIONS} 次执行"
|
||||||
|
)
|
||||||
bid = new_id()
|
bid = new_id()
|
||||||
batch = {
|
batch = {
|
||||||
"id": bid,
|
"id": bid,
|
||||||
@@ -342,18 +351,25 @@ def create_batch(payload: dict) -> dict:
|
|||||||
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||||
"request_id": payload.get("request_id"),
|
"request_id": payload.get("request_id"),
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_count": len(cases),
|
||||||
|
"total_executions": total,
|
||||||
}
|
}
|
||||||
jobs = []
|
jobs = []
|
||||||
for number, case in enumerate(payload["cases"], 1):
|
for iteration in range(1, repeat_count + 1):
|
||||||
|
for number, case in enumerate(cases, 1):
|
||||||
jobs.append(
|
jobs.append(
|
||||||
{
|
{
|
||||||
"session_id": new_id(),
|
"session_id": new_id(),
|
||||||
"batch_id": bid,
|
"batch_id": bid,
|
||||||
"position": number,
|
"position": len(jobs) + 1,
|
||||||
|
"iteration": iteration,
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_position": number,
|
||||||
"case": case,
|
"case": case,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"formats": batch["formats"],
|
"formats": batch["formats"],
|
||||||
"created_at": time.time(),
|
"created_at": batch["created_at"],
|
||||||
"device_serial": batch["device_serial"],
|
"device_serial": batch["device_serial"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -412,7 +428,13 @@ def batch_action(batch_id: str, action: str) -> dict:
|
|||||||
if not failed:
|
if not failed:
|
||||||
raise ValueError("没有可重跑的失败或中断任务")
|
raise ValueError("没有可重跑的失败或中断任务")
|
||||||
revised = create_batch(
|
revised = create_batch(
|
||||||
{**batch, "name": batch["name"] + " · 失败项重跑", "cases": [j["case"] for j in failed]}
|
{
|
||||||
|
**batch,
|
||||||
|
"name": batch["name"] + " · 失败项重跑",
|
||||||
|
"cases": [j["case"] for j in failed],
|
||||||
|
"repeat_count": 1,
|
||||||
|
"request_id": None,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return revised
|
return revised
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
|||||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||||
from apps.admin_console.services.automation_store import store, identifier
|
from apps.admin_console.services.automation_store import store, identifier
|
||||||
from apps.admin_console.services.task_import import case_title
|
from apps.admin_console.services.task_import import execution_title
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
EXPORT_LOCK = threading.RLock()
|
||||||
@@ -190,7 +190,9 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||||
|
"iteration": meta.get("iteration", 1),
|
||||||
|
"repeat_count": meta.get("repeat_count", 1),
|
||||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||||
"expected": case.get("expected", ""),
|
"expected": case.get("expected", ""),
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -189,3 +189,11 @@ def case_title(case: dict) -> str:
|
|||||||
case_id = str(case.get("case_id") or "").strip()
|
case_id = str(case.get("case_id") or "").strip()
|
||||||
name = str(case.get("name") or "").strip()
|
name = str(case.get("name") or "").strip()
|
||||||
return " ".join(part for part in (case_id, name) if part)
|
return " ".join(part for part in (case_id, name) if part)
|
||||||
|
|
||||||
|
|
||||||
|
def execution_title(job: dict) -> str:
|
||||||
|
"""Distinguish repeated executions without altering the original case or prompt."""
|
||||||
|
title = case_title(job.get("case") or {})
|
||||||
|
if job.get("repeat_count", 1) > 1:
|
||||||
|
title += f" · 第 {job['iteration']} / {job['repeat_count']} 轮"
|
||||||
|
return title
|
||||||
+20
-5
@@ -47,26 +47,41 @@
|
|||||||
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||||
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
||||||
@if(preview(); as p) {
|
@if(preview(); as p) {
|
||||||
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
<div class="selection-toolbar"><span>已选 <strong>{{selectedCount()}}</strong> / {{p.tasks.length}} 个用例 · 错误 {{p.errors.length}} 条</span><button type="button" [disabled]="busy() || !p.tasks.length" (click)="toggleAllRows()">{{selectedCount() === p.tasks.length ? '全不选' : '全选'}}</button></div>
|
||||||
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
||||||
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
|
<div class="case-preview-list" aria-label="选择导入用例">
|
||||||
|
@for(t of p.tasks; track t.row){
|
||||||
|
<div class="case-preview" [class.unselected]="!selectedRows().has(t.row)">
|
||||||
|
<span class="selection-column"><input type="checkbox" [checked]="selectedRows().has(t.row)" [disabled]="busy()" (change)="toggleRow(t.row)" [attr.aria-label]="'导入用例:' + (t.case_id ? t.case_id + ' ' : '') + t.name"></span>
|
||||||
|
<div class="case-preview-content"><strong>{{t.case_id ? t.case_id + ' ' : ''}}{{t.name}}</strong><small>第 {{t.row}} 行</small>
|
||||||
|
<p class="case-goal" [title]="t.goal">{{t.goal}}</p>
|
||||||
|
@if(caseConfig(t.task_config); as config){<details><summary>自定义参数</summary><pre>{{config}}</pre></details>}@else{<small>使用批次 / 全局参数</small>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
||||||
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
||||||
|
<label for="batch-repeat-count">整批循环次数<input id="batch-repeat-count" type="number" min="1" [max]="maxExecutions" step="1" [(ngModel)]="repeatCount"></label>
|
||||||
|
<p class="execution-summary">{{selectedCount()}} 个用例 × {{repeatCount || 0}} 轮,共 {{selectedCount() * (repeatCount || 0)}} 次执行</p>
|
||||||
|
<p class="muted">每轮按表格顺序执行所选用例,分别保存结果。未勾选用例不会导入。</p>
|
||||||
|
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</p>}
|
||||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||||
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
||||||
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
<section><h3>批次记录</h3>
|
<section><h3>批次记录</h3>
|
||||||
@for(b of batches(); track b.id){
|
@for(b of batches(); track b.id){
|
||||||
<div class="batch-record">
|
<div class="batch-record">
|
||||||
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button>
|
||||||
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if(!batches().length){<p>暂无批次。</p>}
|
@if(!batches().length){<p>暂无批次。</p>}
|
||||||
</section>
|
</section>
|
||||||
@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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@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>}
|
||||||
@@ -87,6 +102,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}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,13 @@ input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-bo
|
|||||||
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
|
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
|
||||||
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
||||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||||
|
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;}
|
||||||
|
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
|
||||||
|
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}
|
||||||
|
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}}
|
||||||
|
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}}
|
||||||
|
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
|
||||||
|
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
|
||||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||||
|
|||||||
+55
@@ -22,6 +22,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||||
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();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AutomationPanelComponent],
|
imports: [AutomationPanelComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -32,6 +33,60 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('selects all imported cases by default, toggles all and submits only checked cases with rounds', async () => {
|
||||||
|
const tasks = [
|
||||||
|
{ row: 2, case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } },
|
||||||
|
{ row: 3, case_id: '1.2', name: '观察首页', goal: '观察首页', task_config: {} }
|
||||||
|
];
|
||||||
|
(http.post.and as any).callFake((url: string) => of(url.endsWith('/preview')
|
||||||
|
? { valid: true, tasks, errors: [] } : url.endsWith('/batches')
|
||||||
|
? { id: 'selected-batch', status: 'draft' } : { id: 'selected-batch', status: 'running' }));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
component.upload({ target: { files: [new File(['test'], '用例.xlsx')] } } as unknown as Event);
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const checkboxes = () => [...fixture.nativeElement.querySelectorAll('.selection-column input')] as HTMLInputElement[];
|
||||||
|
expect(checkboxes().every(c => c.checked)).toBeTrue();
|
||||||
|
const toggle = fixture.nativeElement.querySelector('.selection-toolbar button') as HTMLButtonElement;
|
||||||
|
expect(toggle.textContent).toContain('全不选');
|
||||||
|
toggle.click(); fixture.detectChanges();
|
||||||
|
expect(component.selectedCount()).toBe(0);
|
||||||
|
expect((fixture.nativeElement.querySelector('.primary') as HTMLButtonElement).disabled).toBeTrue();
|
||||||
|
expect(toggle.textContent).toContain('全选');
|
||||||
|
toggle.click(); fixture.detectChanges();
|
||||||
|
expect(component.selectedCount()).toBe(2);
|
||||||
|
checkboxes()[1].click();
|
||||||
|
component.repeatCount = 3; component.device = 'test-device';
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.execution-summary').textContent).toContain('共 3 次执行');
|
||||||
|
component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
|
||||||
|
expect(request?.[1]).toEqual(jasmine.objectContaining({
|
||||||
|
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
|
||||||
|
repeat_count: 3, device_serial: 'test-device'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty selections and invalid or excessive loop counts without creating a batch', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.preview.set({ valid: true, tasks: [{ row: 2, name: '检查', goal: '观察' }], errors: [] });
|
||||||
|
component.device = 'test-device';
|
||||||
|
component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(component.error()).toContain('至少勾选');
|
||||||
|
component.selectedRows.set(new Set([2]));
|
||||||
|
for (const count of [0, 1.5, 10001]) {
|
||||||
|
component.repeatCount = count; component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(component.error()).toContain('循环次数');
|
||||||
|
}
|
||||||
|
expect(http.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('confirms deletion and removes the batch from the visible list', async () => {
|
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
fixture.componentInstance.tab.set('batches');
|
fixture.componentInstance.tab.set('batches');
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export class AutomationPanelComponent {
|
|||||||
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);
|
||||||
|
selectedRows = signal<Set<number>>(new Set());
|
||||||
|
selectedCount = computed(() => this.selectedRows().size);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
archiveProgress = signal<ArchiveProgress | null>(null);
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||||
archiveBatchId = signal(''); archiveBusy = signal(false);
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||||
@@ -37,10 +39,11 @@ export class AutomationPanelComponent {
|
|||||||
});
|
});
|
||||||
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
||||||
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
||||||
|
repeatCount = 1; maxExecutions = 10000;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
const destroy = inject(DestroyRef);
|
const destroy = inject(DestroyRef);
|
||||||
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; });
|
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; this.maxExecutions = p.batch_max_executions || 10000; });
|
||||||
void this.refresh();
|
void this.refresh();
|
||||||
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); });
|
||||||
@@ -104,13 +107,32 @@ export class AutomationPanelComponent {
|
|||||||
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
|
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
|
||||||
upload(event: Event) {
|
upload(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
|
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
|
||||||
void this.action(async () => { this.preview.set(null); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); });
|
void this.action(async () => { this.preview.set(null); this.selectedRows.set(new Set()); const data = new FormData(); data.append('file',file); const preview: any = await firstValueFrom(this.http.post('/api/automation/import/preview',data)); this.preview.set(preview); this.selectedRows.set(new Set<number>(preview.tasks.map((t: any) => t.row))); this.repeatCount = 1; this.name = file.name.replace(/\.xlsx$/i,''); });
|
||||||
|
}
|
||||||
|
toggleRow(row: number) {
|
||||||
|
const selected = new Set(this.selectedRows());
|
||||||
|
if (selected.has(row)) selected.delete(row); else selected.add(row);
|
||||||
|
this.selectedRows.set(selected);
|
||||||
|
}
|
||||||
|
toggleAllRows() {
|
||||||
|
const rows: number[] = (this.preview()?.tasks || []).map((t: any) => t.row);
|
||||||
|
this.selectedRows.set(this.selectedCount() === rows.length ? new Set() : new Set(rows));
|
||||||
|
}
|
||||||
|
validRepetitions() {
|
||||||
|
return Number.isInteger(this.repeatCount) && this.repeatCount >= 1
|
||||||
|
&& this.selectedCount() * this.repeatCount <= this.maxExecutions;
|
||||||
|
}
|
||||||
|
caseConfig(config: Record<string, unknown> | undefined) {
|
||||||
|
const overrides = Object.fromEntries(Object.entries(config || {}).filter(([, value]) => value !== null && value !== undefined));
|
||||||
|
return Object.keys(overrides).length ? JSON.stringify(overrides, null, 2) : '';
|
||||||
}
|
}
|
||||||
startBatch() { void this.action(async () => {
|
startBatch() { void this.action(async () => {
|
||||||
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
||||||
|
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
||||||
|
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
||||||
if (!this.device) throw Error('请选择执行设备');
|
if (!this.device) throw Error('请选择执行设备');
|
||||||
const cases = this.preview().tasks.map(({row, ...c}: any) => c);
|
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
|
||||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
||||||
this.batch.set(created);
|
this.batch.set(created);
|
||||||
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
||||||
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class Settings(BaseSettings):
|
|||||||
# Consecutive Flash responses without any tool call before failing the task.
|
# Consecutive Flash responses without any tool call before failing the task.
|
||||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
|
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
|
||||||
|
ARTEMIS_BATCH_MAX_EXECUTIONS: int = Field(default=10000, ge=1)
|
||||||
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
|
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
|
||||||
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
|
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
|
||||||
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
|
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||||
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
|
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
|
||||||
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
|
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
|
||||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
4. 上传文件,查看逐行校验结果。默认勾选全部有效用例,可逐项取消,或用“全选 / 全不选”切换;未勾选用例不会导入。所有错误修正后才能提交。
|
||||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
5. 明确选择设备、执行模式、超时时间和整批循环次数(默认 1),查看“已选用例数 × 轮数”的执行总数,然后点击“确认导入并开始执行”。
|
||||||
|
|
||||||
|
循环次数独立保存在每个批次中。每轮按表格顺序执行整批所选用例,然后进入下一轮;各次执行有独立任务编号、状态和报告,队列及报告标题标注轮次。暂停、重启恢复只继续未开始项;“失败即停止”跳过全部后续轮次,“复制失败项为新批次”只复制失败记录各执行一次,不沿用原循环次数。单批默认最多 10000 次执行,可用 `.env` 中 `ARTEMIS_BATCH_MAX_EXECUTIONS` 调整;网页和 MCP 使用同一限制。
|
||||||
|
|
||||||
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||||
|
|
||||||
|
|||||||
@@ -225,13 +225,19 @@ async def mobile_batch(
|
|||||||
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
|
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
|
||||||
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
|
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
|
||||||
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
|
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
|
||||||
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
|
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}.
|
||||||
|
repeat_count is a positive integer: execute all selected cases in order for each
|
||||||
|
round, with independent session IDs, statuses and reports. The default total limit
|
||||||
|
(cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS.
|
||||||
|
Only include user-selected cases; get returns iteration/repeat_count per job.
|
||||||
|
Required device
|
||||||
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
||||||
Reuse the same request_id and exact payload after a connection failure; never invent
|
Reuse the same request_id and exact payload after a connection failure; never invent
|
||||||
a new ID for the same submission. lookup(request_id) resolves an uncertain outcome.
|
a new ID for the same submission. lookup(request_id) resolves an uncertain outcome.
|
||||||
get returns per-case status. pause stops future dispatch, not the active task;
|
get returns per-case status. pause stops future dispatch, not the active task;
|
||||||
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
|
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
|
||||||
retry_failed creates a new draft only after an explicit rerun request; it is not
|
retry_failed copies each failed execution once into a new draft (repeat_count=1)
|
||||||
|
only after an explicit rerun request; it is not
|
||||||
transport-idempotent, so after an uncertain response use list instead of retrying.
|
transport-idempotent, so after an uncertain response use list instead of retrying.
|
||||||
delete removes a non-active batch and its task sessions, reports and files after
|
delete removes a non-active batch and its task sessions, reports and files after
|
||||||
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
|
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"formats": ["docx", "xlsx"],
|
"formats": ["docx", "xlsx"],
|
||||||
"stop_on_failure": false,
|
"stop_on_failure": false,
|
||||||
"timeout_seconds": 1800,
|
"timeout_seconds": 1800,
|
||||||
|
"repeat_count": 1,
|
||||||
"cases": [
|
"cases": [
|
||||||
{
|
{
|
||||||
"case_id": "DEMO-001",
|
"case_id": "DEMO-001",
|
||||||
@@ -42,13 +43,15 @@
|
|||||||
|
|
||||||
`create` 只保存已校验的草稿。检查返回批次编号和范围,用户已经授权执行时接着 `mobile_batch(action="start", batch_id=...)`;用户只要求准备方案时留在草稿,不启动。保存 `batch_id`,用 `get` 跟踪各项状态。
|
`create` 只保存已校验的草稿。检查返回批次编号和范围,用户已经授权执行时接着 `mobile_batch(action="start", batch_id=...)`;用户只要求准备方案时留在草稿,不启动。保存 `batch_id`,用 `get` 跟踪各项状态。
|
||||||
|
|
||||||
|
用户要求压测时,在 `batch.repeat_count` 设置正整数循环次数(默认 1),只向 `batch.cases` 提交用户选择的用例。每轮按原顺序执行整批,再进入下一轮;各次执行独立保存编号和报告,`get` 中的 job 包含 `iteration`、`repeat_count`。总执行数为用例数乘以轮数,默认上限 10000,由服务的 `ARTEMIS_BATCH_MAX_EXECUTIONS` 控制。不要重复调用 `start` 来实现循环,也不要将循环次数写入每项 `task_config`。失败即停止会跳过后续全部轮次;复制失败项时各失败执行只重跑一次,新的批次循环次数为 1。
|
||||||
|
|
||||||
创建请求超时可能已经落库。先 `lookup(request_id=...)`;需要重发时保留**相同 request_id 和相同内容**。服务会返回原批次,内容不同会拒绝。不要通过换编号“修复超时”,否则会形成重复批次。
|
创建请求超时可能已经落库。先 `lookup(request_id=...)`;需要重发时保留**相同 request_id 和相同内容**。服务会返回原批次,内容不同会拒绝。不要通过换编号“修复超时”,否则会形成重复批次。
|
||||||
|
|
||||||
## Excel 导入
|
## Excel 导入
|
||||||
|
|
||||||
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
|
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
|
||||||
|
|
||||||
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
|
`preview` 返回 `valid/tasks/errors`,不会执行。网页默认全选,可取消用例;AI 也应按用户指定范围筛选 `tasks`。所有错误处理完后,将所选项的预览字段 `row` 去掉,作为 `batch.cases` 提交,并说明实际选中数量。不要跳过错误行却声称全部导入成功。
|
||||||
|
|
||||||
## 状态与异常
|
## 状态与异常
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,107 @@ def test_preferences_and_required_device(client):
|
|||||||
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_repetitions_are_ordered_independent_and_idempotent(client, isolated):
|
||||||
|
request = payload(repeat_count=3, request_id="repeat-test")
|
||||||
|
created = client.post("/api/automation/batches", json=request)
|
||||||
|
assert created.status_code == 200
|
||||||
|
batch = created.json()
|
||||||
|
assert (batch["case_count"], batch["repeat_count"], batch["total_executions"]) == (2, 3, 6)
|
||||||
|
jobs = isolated.jobs(batch["id"])
|
||||||
|
assert len({j["session_id"] for j in jobs}) == 6
|
||||||
|
assert [j["position"] for j in jobs] == list(range(1, 7))
|
||||||
|
assert [(j["iteration"], j["case_position"]) for j in jobs] == [
|
||||||
|
(1, 1),
|
||||||
|
(1, 2),
|
||||||
|
(2, 1),
|
||||||
|
(2, 2),
|
||||||
|
(3, 1),
|
||||||
|
(3, 2),
|
||||||
|
]
|
||||||
|
assert [j["case"]["goal"] for j in jobs] == ["观察设置", "观察首页"] * 3
|
||||||
|
assert all(j["status"] == "pending" for j in jobs)
|
||||||
|
assert client.post("/api/automation/batches", json=request).json()["id"] == batch["id"]
|
||||||
|
assert len(isolated.jobs(batch["id"])) == 6
|
||||||
|
assert (
|
||||||
|
client.post("/api/automation/batches", json={**request, "repeat_count": 2}).status_code
|
||||||
|
== 409
|
||||||
|
)
|
||||||
|
# Each selected failure is copied once, including failures from different rounds.
|
||||||
|
for job in (jobs[0], jobs[4]):
|
||||||
|
isolated.save_job({**job, "status": "failed"})
|
||||||
|
retry = service.batch_action(batch["id"], "retry_failed")
|
||||||
|
assert retry["repeat_count"] == 1
|
||||||
|
assert len(isolated.jobs(retry["id"])) == 2
|
||||||
|
report = reports.session_report(jobs[4]["session_id"])
|
||||||
|
assert "第 3 / 3 轮" in report["name"]
|
||||||
|
archive = reports.export_batch(batch["id"])
|
||||||
|
with zipfile.ZipFile(archive) as z:
|
||||||
|
assert len([n for n in z.namelist() if n.endswith(".xlsx")]) == 7
|
||||||
|
assert len(set(z.namelist())) == len(z.namelist())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("count", [0, -1, 1.5, True, "3", None])
|
||||||
|
def test_batch_rejects_invalid_repetitions(client, isolated, count):
|
||||||
|
assert (
|
||||||
|
client.post("/api/automation/batches", json=payload(repeat_count=count)).status_code == 422
|
||||||
|
)
|
||||||
|
assert isolated.records("batch") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_limit_is_checked_before_creating_records(client, isolated, monkeypatch):
|
||||||
|
monkeypatch.setattr(service.settings, "ARTEMIS_BATCH_MAX_EXECUTIONS", 5)
|
||||||
|
assert client.get("/api/automation/preferences").json()["batch_max_executions"] == 5
|
||||||
|
response = client.post("/api/automation/batches", json=payload(repeat_count=3))
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "5" in response.json()["detail"]
|
||||||
|
assert not isolated.records("batch")
|
||||||
|
assert not isolated.jobs()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_dispatch_continues_after_failure_and_resume_without_repeating(
|
||||||
|
isolated, monkeypatch
|
||||||
|
):
|
||||||
|
from apps.admin_console import server
|
||||||
|
from artemis.runtime.device_pool import DeviceStatus
|
||||||
|
|
||||||
|
batch = service.create_batch(payload(repeat_count=2))
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||||
|
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"artemis.runtime.device_pool.try_list_devices_async",
|
||||||
|
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||||
|
)
|
||||||
|
for index in range(4):
|
||||||
|
await service.tick_batches()
|
||||||
|
await service.tick_batches() # Polling must not enqueue the same execution twice.
|
||||||
|
assert enqueue.await_count == index + 1
|
||||||
|
job = isolated.jobs(batch["id"])[index]
|
||||||
|
assert enqueue.call_args.kwargs["session_id"] == job["session_id"]
|
||||||
|
assert f"第 {job['iteration']} / 2 轮" in enqueue.call_args.kwargs["display_name"]
|
||||||
|
isolated.save_job({**job, "status": "failed" if index == 0 else "completed"})
|
||||||
|
if index == 1:
|
||||||
|
service.batch_action(batch["id"], "pause")
|
||||||
|
await service.tick_batches()
|
||||||
|
assert enqueue.await_count == 2
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
await service.tick_batches()
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "completed"
|
||||||
|
assert enqueue.await_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeat_fail_fast_skips_future_rounds(isolated):
|
||||||
|
batch = service.create_batch(payload(repeat_count=3, stop_on_failure=True))
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "failed"})
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
await service.tick_batches()
|
||||||
|
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed"] + ["skipped"] * 5
|
||||||
|
|
||||||
|
|
||||||
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
|
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/api/automation/batches", json=payload(request_id="delete-example")
|
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||||
|
|||||||
@@ -53,9 +53,14 @@ async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await tools.mobile_batch("create", batch={"name": "x"})
|
await tools.mobile_batch("create", batch={"name": "x"})
|
||||||
request.assert_not_awaited()
|
request.assert_not_awaited()
|
||||||
await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"})
|
selected = [{"name": "选中用例", "goal": "观察设置"}]
|
||||||
|
await tools.mobile_batch(
|
||||||
|
"create", request_id="stable-id", batch={"name": "x", "cases": selected, "repeat_count": 3}
|
||||||
|
)
|
||||||
assert request.await_count == 1
|
assert request.await_count == 1
|
||||||
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
|
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
|
||||||
|
assert request.call_args.kwargs["json"]["cases"] == selected
|
||||||
|
assert request.call_args.kwargs["json"]["repeat_count"] == 3
|
||||||
assert request.call_args.args[1] == "/api/automation/batches"
|
assert request.call_args.args[1] == "/api/automation/batches"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user