From 8dbe054a8c5fe32fd898a81d15972e380233f053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Wed, 30 Sep 2026 00:04:16 +0800 Subject: [PATCH] feat: show progress while preparing batch archives --- apps/admin_console/routers/automation.py | 28 ++++- apps/admin_console/services/batch_archive.py | 117 ++++++++++++++++++ apps/admin_console/services/report_export.py | 12 +- .../automation-panel.component.html | 17 ++- .../automation-panel.component.scss | 8 ++ .../automation-panel.component.spec.ts | 56 ++++++++- .../automation-panel.component.ts | 44 +++++++ tests/unit/admin_console/test_automation.py | 58 +++++++++ 8 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 apps/admin_console/services/batch_archive.py diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py index bf9511f..4167a09 100644 --- a/apps/admin_console/routers/automation.py +++ b/apps/admin_console/routers/automation.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services import automation_service as service +from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import report_export, task_import router = APIRouter(prefix="/api/automation", tags=["automation"]) @@ -128,10 +129,13 @@ def delete_batch(batch_id: str): except ValueError as exc: raise HTTPException(400, str(exc)) from exc try: + if archive_service.status(key, require("batch", key))["status"] == "preparing": + raise HTTPException(409, "报告正在打包,请完成后再删除批次") if not service.delete_batch(key): raise HTTPException(404, "批次不存在") except ValueError as exc: raise HTTPException(409, str(exc)) from exc + archive_service.forget(key) return {"batch_id": key, "status": "deleted"} @@ -144,10 +148,30 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed raise HTTPException(409, str(exc)) from exc +@router.post("/batches/{batch_id}/archive/prepare") +def prepare_batch_archive(batch_id: str): + batch = require("batch", batch_id) + return archive_service.prepare(batch_id, batch) + + +@router.get("/batches/{batch_id}/archive/status") +def batch_archive_status(batch_id: str): + batch = require("batch", batch_id) + return archive_service.status(batch_id, batch) + + @router.get("/batches/{batch_id}/archive") async def batch_archive(batch_id: str): - require("batch", batch_id) - path = await asyncio.to_thread(report_export.export_batch, batch_id) + batch = require("batch", batch_id) + if archive_service.status(batch_id, batch)["status"] == "preparing": + raise HTTPException(409, "报告正在打包,请等待准备完成") + path = archive_service.archive_path(batch_id) + if ( + not path.is_file() + or not batch.get("report_ready") + and archive_service.status(batch_id, batch)["status"] != "ready" + ): + path = await asyncio.to_thread(report_export.export_batch, batch_id) return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip") diff --git a/apps/admin_console/services/batch_archive.py b/apps/admin_console/services/batch_archive.py new file mode 100644 index 0000000..3a0e1f2 --- /dev/null +++ b/apps/admin_console/services/batch_archive.py @@ -0,0 +1,117 @@ +"""Background preparation and observable progress for batch report downloads.""" + +import threading +import time + +from apps.admin_console.services import report_export + +_lock = threading.Lock() +_states: dict[str, dict] = {} + + +def archive_path(batch_id: str): + return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" + + +def _snapshot(state: dict) -> dict: + elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"]) + completed, total = state["completed"], state["total"] + eta = ( + max(0, round(elapsed / completed * (total - completed))) + if completed and total > completed + else None + ) + return { + "status": state["status"], + "stage": state["stage"], + "completed": completed, + "total": total, + "elapsed_seconds": round(elapsed), + "eta_seconds": eta, + "error": state.get("error"), + } + + +def status(batch_id: str, batch: dict) -> dict: + with _lock: + state = _states.get(batch_id) + if state: + return _snapshot(state) + if batch.get("report_ready") and archive_path(batch_id).is_file(): + return { + "status": "ready", + "stage": "报告包已就绪", + "completed": 0, + "total": 0, + "elapsed_seconds": 0, + "eta_seconds": None, + "error": None, + } + return { + "status": "idle", + "stage": "尚未开始打包", + "completed": 0, + "total": 0, + "elapsed_seconds": 0, + "eta_seconds": None, + "error": None, + } + + +def prepare(batch_id: str, batch: dict) -> dict: + with _lock: + current = _states.get(batch_id) + if current and current["status"] == "preparing": + return _snapshot(current) + if batch.get("report_ready") and archive_path(batch_id).is_file(): + _states.pop(batch_id, None) + return status_from_cache() + state = { + "status": "preparing", + "stage": "等待打包", + "completed": 0, + "total": len(report_export.store.jobs(batch_id)), + "started_at": time.monotonic(), + } + _states[batch_id] = state + + def update(completed: int, total: int, stage: str) -> None: + with _lock: + state.update(completed=completed, total=total, stage=stage) + + def run() -> None: + try: + with report_export.EXPORT_LOCK: + latest = report_export.store.get("batch", batch_id) + if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()): + report_export.export_batch(batch_id, progress=update) + with _lock: + state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic()) + except Exception as exc: + with _lock: + state.update( + status="failed", + stage="打包失败", + error=str(exc)[:500], + finished_at=time.monotonic(), + ) + + threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start() + return status(batch_id, batch) + + +def status_from_cache() -> dict: + return { + "status": "ready", + "stage": "报告包已就绪", + "completed": 0, + "total": 0, + "elapsed_seconds": 0, + "eta_seconds": None, + "error": None, + } + + +def forget(batch_id: str) -> None: + with _lock: + _states.pop(batch_id, None) diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 5b16710..3d23d14 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -1,7 +1,7 @@ """Evidence-backed Word/Excel artifacts, independent of the language model runtime.""" from datetime import datetime -from typing import cast +from typing import Callable, cast import json from pathlib import Path import re @@ -695,7 +695,7 @@ def export_session_media(session_id: str, kind: str) -> Path: return target -def export_batch(batch_id: str) -> Path: +def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path: with EXPORT_LOCK: batch = store.get("batch", identifier(batch_id)) if not batch: @@ -704,6 +704,8 @@ def export_batch(batch_id: str) -> Path: directory.mkdir(parents=True, exist_ok=True) formats = batch["formats"] records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] + if progress: + progress(0, len(records), "正在生成批次汇总") rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")] rows += [ (r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"]) @@ -734,6 +736,8 @@ def export_batch(batch_id: str) -> Path: for path in outputs: archive.write(path, path.name) for number, report in enumerate(records, 1): + if progress: + progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}") prefix = f"任务报告/{number:04d}_{archive_case_name(report)}" for path in export_session(report["id"], formats): archive.write(path, f"{prefix}/{path.name}") @@ -741,5 +745,9 @@ def export_batch(batch_id: str) -> Path: archive.write(source, f"{prefix}/步骤截图/{name}") for source, name in session_videos(report): archive.write(source, f"{prefix}/任务录屏/{name}") + if progress: + progress(number, len(records), f"已打包任务 {number}/{len(records)}") + if progress: + progress(len(records), len(records), "正在完成压缩包") temporary.replace(target) return target diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html index e98894e..4f58d49 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html @@ -71,8 +71,21 @@ @if(b.status === 'running'){} @if(b.status === 'draft' || b.status === 'paused'){} @if(b.status !== 'running' && b.status !== 'deleting'){} - 下载批次报告 ZIP -

服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。

+ +

服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。

+ @if(archiveBatchId() === b.id && archiveProgress(); as progress){ +
+
+ @if(progress.status === 'preparing'){} + {{progress.stage}} +
+ @if(progress.status === 'preparing'){ +
+

已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}

+ } @else if(progress.status === 'failed'){

{{progress.error}}。可点击下载按钮重试。

} + @else if(progress.status === 'ready'){

打包完成,下载已开始。

} +
+ } @if(b.report_error){

报告生成失败:{{b.report_error}}

} @for(j of b.jobs; track j.session_id){
{{label(j.status)}}@if(j.error){{{j.error}}}
} } diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss index a92ca3d..35d8ea6 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss @@ -18,6 +18,14 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align: .device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;} .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;} +.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;} +.archive-progress-heading{display:flex;align-items:center;gap:8px;} +.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;} +.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;} +.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;} +.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;} +.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;} +@keyframes archive-spin{to{transform:rotate(360deg);}} .code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;} .preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;} .job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;} diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts index 61ff9bd..c96df6f 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts @@ -14,12 +14,13 @@ describe('AutomationPanelComponent batch deletion', () => { agentStatus: signal('idle'), sessions: signal([]) }; - const http = jasmine.createSpyObj('HttpClient', ['get', 'delete']); + const http = jasmine.createSpyObj('HttpClient', ['get', 'post', 'delete']); beforeEach(async () => { (http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : [])); 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.delete.calls.reset(); await TestBed.configureTestingModule({ imports: [AutomationPanelComponent], @@ -77,4 +78,57 @@ describe('AutomationPanelComponent batch deletion', () => { expect(root.querySelector('.report-body')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body table td')?.textContent).toBe('设置'); }); + + it('prepares a batch archive before starting the browser download', async () => { + const fixture = TestBed.createComponent(AutomationPanelComponent); + const component = fixture.componentInstance; + component.tab.set('batches'); + await new Promise(resolve => setTimeout(resolve, 0)); + component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] }); + const click = spyOn(HTMLAnchorElement.prototype, 'click'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {}); + expect(click).toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始'); + }); + + it('shows archive errors and lets the user retry', async () => { + http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' })); + const fixture = TestBed.createComponent(AutomationPanelComponent); + const component = fixture.componentInstance; + component.tab.set('batches'); + await new Promise(resolve => setTimeout(resolve, 0)); + component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] }); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取'); + expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse(); + }); + + it('shows live archive progress and estimated remaining time while preparing', async () => { + http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null })); + (http.get.and as any).callFake((url: string) => of(url.endsWith('/status') + ? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null } + : url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : [])); + const fixture = TestBed.createComponent(AutomationPanelComponent); + const component = fixture.componentInstance; + component.tab.set('batches'); + await new Promise(resolve => setTimeout(resolve, 0)); + component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] }); + spyOn(HTMLAnchorElement.prototype, 'click'); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click(); + await new Promise(resolve => setTimeout(resolve, 0)); + fixture.detectChanges(); + const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement; + expect(progress.textContent).toContain('已处理 1 / 4 个任务'); + expect(progress.textContent).toContain('预计还需约 36 秒'); + expect(progress.querySelector('.archive-spinner')).not.toBeNull(); + fixture.destroy(); + }); }); diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts index 95d42ff..01e1730 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -7,6 +7,16 @@ import { AgentService } from '../../services/agent.service'; import { SystemService } from '../../services/system.service'; import { renderMarkdownToHtml } from '../../utils/markdown-parser.util'; +interface ArchiveProgress { + status: 'idle' | 'preparing' | 'ready' | 'failed'; + stage: string; + completed: number; + total: number; + elapsed_seconds: number; + eta_seconds: number | null; + error: string | null; +} + @Component({ selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule], templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' @@ -16,6 +26,8 @@ export class AutomationPanelComponent { tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); batches = signal([]); preview = signal(null); batch = signal(null); report = signal(null); + archiveProgress = signal(null); + archiveBatchId = signal(''); archiveBusy = signal(false); guidance = ''; taskStatus = computed(() => { const sid = this.agent.currentSessionId(); @@ -40,6 +52,38 @@ export class AutomationPanelComponent { formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; } label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record)[s] || s; } renderReport(content: string) { return renderMarkdownToHtml(content || ''); } + duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; } + downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); } + private async prepareBatchArchive(id: string) { + if (this.archiveBusy()) return; + this.archiveBusy.set(true); this.archiveBatchId.set(id); + this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }); + const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`; + try { + let progress = await firstValueFrom(this.http.post(`${base}/prepare`, {})); + while (!this.destroyed && progress.status === 'preparing') { + this.archiveProgress.set(progress); + await new Promise(resolve => setTimeout(resolve, 1000)); + if (this.destroyed) return; + progress = await firstValueFrom(this.http.get(`${base}/status`)); + } + if (this.destroyed) return; + this.archiveProgress.set(progress); + if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败'); + if (progress.status !== 'ready') throw Error('打包任务已中断,请重试'); + const link = document.createElement('a'); + link.href = base; + link.download = `批次报告-${id.slice(0, 8)}.zip`; + document.body.appendChild(link); link.click(); link.remove(); + this.notice.set('报告包已就绪,正在开始下载'); + } catch (e: any) { + const detail = e.error?.detail; + this.archiveProgress.set({ + ...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }), + status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败' + }); + } finally { this.archiveBusy.set(false); } + } async action(fn: () => Promise) { if (this.busy()) return; this.busy.set(true); this.error.set(''); this.notice.set(''); diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index f85b661..03140c2 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -3,6 +3,7 @@ from io import BytesIO import json import sqlite3 +import threading import time from unittest.mock import AsyncMock, Mock from uuid import UUID @@ -108,6 +109,63 @@ def test_invalid_archive_and_empty_template(): assert not task_import.parse_workbook(task_import.template())["valid"] +def test_batch_archive_preparation_reports_progress_and_reuses_ready_file( + client, isolated, monkeypatch +): + batch = service.create_batch(payload()) + batch_id = batch["id"] + started = threading.Event() + release = threading.Event() + calls = [] + + def slow_export(identifier, progress=None): + calls.append(identifier) + progress(0, 2, "正在打包任务 1/2") + started.set() + assert release.wait(5) + progress(2, 2, "正在完成压缩包") + path = reports.EXPORT_ROOT / "batches" / identifier / "批次报告.zip" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"prepared archive") + return path + + monkeypatch.setattr(reports, "export_batch", slow_export) + base = f"/api/automation/batches/{batch_id}/archive" + try: + response = client.post(base + "/prepare") + assert response.status_code == 200 + assert response.json()["status"] == "preparing" + assert started.wait(2) + progress = client.get(base + "/status").json() + assert progress["total"] == 2 + assert progress["stage"] == "正在打包任务 1/2" + assert client.get(base).status_code == 409 + finally: + release.set() + for _ in range(50): + if client.get(base + "/status").json()["status"] == "ready": + break + time.sleep(0.02) + assert client.get(base + "/status").json()["status"] == "ready" + assert client.get(base).content == b"prepared archive" + assert calls == [batch_id] + + +def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch): + batch = service.create_batch(payload()) + batch_id = batch["id"] + isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True}) + path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" + path.parent.mkdir(parents=True) + path.write_bytes(b"existing archive") + exporter = Mock(side_effect=AssertionError("archive must not be rebuilt")) + monkeypatch.setattr(reports, "export_batch", exporter) + base = f"/api/automation/batches/{batch_id}/archive" + assert client.post(base + "/prepare").json()["status"] == "ready" + assert client.get(base).content == b"existing archive" + exporter.assert_not_called() + + def test_preview_does_not_enqueue(client, isolated): response = client.post( "/api/automation/import/preview",