From 6c3c6864f33dcde69f7269eba20811e3ec4d8786 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 30 Jul 2026 18:41:56 +0800 Subject: [PATCH] feat(sql-file): show per-file execution statistics --- .../SqlFileExecutionDialog.retry.spec.ts | 208 ++++++++++++++++++ .../sql-file/SqlFileExecutionDialog.vue | 145 ++++++++++-- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + apps/desktop/src/lib/backend/tauri.ts | 2 + crates/dbx-core/src/sql.rs | 6 + crates/dbx-core/src/sql_file_import.rs | 120 +++++++++- .../app-tests/sqlFileExecutionDialog.test.ts | 11 + 14 files changed, 496 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts new file mode 100644 index 0000000000..2c9e3bd1c3 --- /dev/null +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts @@ -0,0 +1,208 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addSqlFileTask: vi.fn(), + cancelSqlFileExecution: vi.fn(), + ensureConnected: vi.fn(), + executeSqlFiles: vi.fn(), + fetchSqlFileTargetOptions: vi.fn(), + listenSqlFileProgress: vi.fn(), + openFileDialog: vi.fn(), + previewSqlFile: vi.fn(), + progressHandler: undefined as undefined | ((progress: Record) => void), + refreshDatabaseTreeNode: vi.fn(), + requestConfirmation: vi.fn(), + toast: vi.fn(), + unlisten: vi.fn(), + updateSqlFileTask: vi.fn(), + uuid: vi.fn(), +})); + +function passthrough(tag: string) { + return defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h(tag, attrs, slots.default?.()); + }, + }); +} + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid })); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/composables/useExportTracker", () => ({ + useExportTracker: () => ({ addSqlFileTask: mocks.addSqlFileTask, updateSqlFileTask: mocks.updateSqlFileTask }), +})); +vi.mock("@/composables/useDatabaseOptions", () => ({ fetchSqlFileTargetOptions: mocks.fetchSqlFileTargetOptions })); +vi.mock("@/lib/connection/connectionLevelDatabaseBootstrap", () => ({ requiresSqlFileTargetDatabaseSelection: () => false })); +vi.mock("@/lib/database/productionSafety", () => ({ productionContextForDatabase: () => ({ active: false, databases: [] }) })); +vi.mock("@/stores/productionSafetyStore", () => ({ + useProductionSafetyStore: () => ({ requestConfirmation: mocks.requestConfirmation }), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql" }], + ensureConnected: mocks.ensureConnected, + getConfig: () => ({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }), + refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode, + }), +})); +vi.mock("@/lib/backend/api", () => ({ + cancelSqlFileExecution: mocks.cancelSqlFileExecution, + executeSqlFiles: mocks.executeSqlFiles, + listenSqlFileProgress: mocks.listenSqlFileProgress, + previewSqlFile: mocks.previewSqlFile, +})); +vi.mock("@lucide/vue", () => { + const Icon = passthrough("span"); + return { Check: Icon, CheckSquare: Icon, FileCode: Icon, FolderOpen: Icon, Loader2: Icon, Play: Icon, Square: Icon, X: Icon }; +}); +vi.mock("@/components/ui/dialog", () => ({ + Dialog: passthrough("div"), + DialogFooter: passthrough("div"), + DialogHeader: passthrough("div"), + DialogScrollContent: passthrough("div"), + DialogTitle: passthrough("div"), +})); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: passthrough("div"), + TooltipContent: passthrough("div"), + TooltipTrigger: passthrough("div"), +})); +vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") })); +vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") })); +vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") })); +vi.mock("@/components/ui/select", () => ({ + Select: passthrough("div"), + SelectContent: passthrough("div"), + SelectItem: passthrough("div"), + SelectTrigger: passthrough("div"), + SelectValue: passthrough("span"), +})); +vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: passthrough("span") })); +vi.mock("@/components/connection/ConnectionGroupBadge.vue", () => ({ default: passthrough("span") })); + +import SqlFileExecutionDialog from "./SqlFileExecutionDialog.vue"; + +let app: ReturnType | undefined; +let root: HTMLDivElement | undefined; + +function progress(executionId: string, status: string, overrides: Record = {}) { + return { + executionId, + status, + statementIndex: 0, + successCount: 0, + failureCount: 0, + affectedRows: 0, + elapsedMs: 10, + statementSummary: "", + ...overrides, + }; +} + +function findButton(label: string): HTMLButtonElement { + const button = Array.from(root!.querySelectorAll("button")).find((item) => item.textContent?.trim().endsWith(label)); + expect(button, `button ${label}`).toBeDefined(); + return button!; +} + +async function mountReadyDialog() { + root = document.createElement("div"); + document.body.append(root); + app = createApp(SqlFileExecutionDialog, { open: true }); + app.mount(root); + + await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled()); + findButton("sqlFile.browse").click(); + await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false)); +} + +async function completeFirstExecution() { + mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => { + mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 0, fileName: "first.sql" })); + mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 0, fileName: "first.sql", statementIndex: 1, successCount: 1 })); + mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1 })); + mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1, affectedRows: 2 })); + mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 2, successCount: 2, affectedRows: 2 })); + }); + + findButton("sqlFile.execute").click(); + await vi.waitFor(() => expect(root!.querySelector("table")).not.toBeNull()); + expect(root!.querySelector("table")!.textContent).toContain("first.sql"); + expect(root!.querySelector("table")!.textContent).toContain("second.sql"); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.progressHandler = undefined; + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.fetchSqlFileTargetOptions.mockResolvedValue([]); + mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]); + mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({ + fileName: filePath.split("/").pop()!, + filePath, + sizeBytes: 9, + preview: "select 1;", + canExecuteWithoutSelectedDatabase: true, + })); + mocks.listenSqlFileProgress.mockImplementation((handler: (event: Record) => void) => { + mocks.progressHandler = handler; + return mocks.unlisten; + }); + mocks.cancelSqlFileExecution.mockResolvedValue(true); + mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined); + mocks.requestConfirmation.mockResolvedValue(true); + mocks.uuid.mockReturnValueOnce("run-1").mockReturnValueOnce("run-2"); +}); + +afterEach(() => { + app?.unmount(); + root?.remove(); + app = undefined; + root = undefined; +}); + +describe("SqlFileExecutionDialog retries", () => { + it("does not restore a completed run's file summary after an early retry failure", async () => { + await mountReadyDialog(); + await completeFirstExecution(); + + mocks.ensureConnected.mockRejectedValueOnce(new Error("retry connection failed")); + findButton("sqlFile.execute").click(); + + await vi.waitFor(() => expect(root!.textContent).toContain("retry connection failed")); + expect(root!.querySelector("table")).toBeNull(); + }); + + it("does not restore a completed run's file summary after an early retry cancellation", async () => { + await mountReadyDialog(); + await completeFirstExecution(); + const connectionGate = deferred(); + mocks.ensureConnected.mockImplementationOnce(() => connectionGate.promise); + + findButton("sqlFile.execute").click(); + await nextTick(); + findButton("sqlFile.cancel").click(); + connectionGate.resolve(); + + await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.cancelled")); + expect(root!.querySelector("table")).toBeNull(); + expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue index 458fa45808..f78dd88dab 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -95,6 +95,24 @@ const terminalError = ref(""); const refreshedTarget = ref(false); const MAX_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024; +// Per-file results accumulated from backend file-boundary events during +// multi-file execution. Populated only when previews.length > 1. +interface PerFileSummary { + fileName: string; + statementIndex: number; + successCount: number; + failureCount: number; + affectedRows: number; +} +const perFileResults = ref([]); +const currentFileIndex = ref(-1); +const currentFileName = ref(""); +function resetPerFileState() { + perFileResults.value = []; + currentFileIndex.value = -1; + currentFileName.value = ""; +} + const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type))); const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value)); @@ -201,6 +219,7 @@ function resetExecution() { terminalStatus.value = "idle"; terminalError.value = ""; refreshedTarget.value = false; + resetPerFileState(); } function resetState() { @@ -353,6 +372,8 @@ async function startExecution() { if (!confirmed) return; } + resetPerFileState(); + refreshedTarget.value = false; const batchId = uuid(); executionId.value = batchId; running.value = true; @@ -387,6 +408,35 @@ async function startExecution() { terminalStatus.value = next.status; terminalError.value = next.error ?? terminalError.value; updateSqlFileTask(batchId, next); + + // Detect per-file boundary events from the backend (populated only + // during multi-file execution). The backend emits a file-start + // event (status=Running, fileIndex set) and a file-done event + // (status=StatementDone, fileIndex set, counters are diff-based). + // Resolve user-visible names via displayFileNames (by fileIndex → + // previews) so Web mode never shows server temp UUID paths. + if (previews.value.length > 1) { + const fi = next.fileIndex; + if (fi != null) { + const preview = previews.value[fi]; + const displayName = preview ? (displayFileNames.value.get(preview.filePath) ?? next.fileName ?? "") : (next.fileName ?? ""); + if (next.status === "running") { + currentFileIndex.value = fi; + currentFileName.value = displayName; + } else if (next.status === "statementDone") { + perFileResults.value[fi] = { + fileName: displayName, + statementIndex: next.statementIndex, + successCount: next.successCount, + failureCount: next.failureCount, + affectedRows: next.affectedRows, + }; + currentFileIndex.value = fi; + currentFileName.value = displayName; + } + } + } + if (isTerminalProgress(next.status)) { resolveTerminalProgress(next); } @@ -639,31 +689,80 @@ watch(
-
-
-
{{ t("sqlFile.statement") }}
-
{{ progress?.statementIndex ?? 0 }}
-
-
-
{{ t("sqlFile.succeeded") }}
-
- {{ progress?.successCount ?? 0 }} -
-
-
-
{{ t("sqlFile.failed") }}
-
- {{ progress?.failureCount ?? 0 }} -
-
-
-
{{ t("sqlFile.affectedRows") }}
-
- {{ (progress?.affectedRows ?? 0).toLocaleString() }} -
-
+
+ + {{ t("sqlFile.fileProgress", { current: currentFileIndex + 1, total: previews.length }) }} — {{ currentFileName }}
+ + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a98bd8dedc..68efd04204 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3915,6 +3915,9 @@ export default { error: "Error", cancelled: "Cancelled", }, + fileProgress: "File {current} of {total}", + fileColumn: "File", + totalFiles: "{count} files", }, sqlFileTree: { title: "SQL Files", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 60b70eb1d5..e7c02ffbba 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3721,6 +3721,9 @@ export default withEnglishFallback({ error: "Error", cancelled: "Cancelado", }, + fileProgress: "Archivo {current} de {total}", + fileColumn: "Archivo", + totalFiles: "{count} archivos", }, diff: { title: "Comparar esquemas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index f3f78a67a2..223708f9d7 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3719,6 +3719,11 @@ export default withEnglishFallback({ error: "Errore", cancelled: "Annullato", }, + fileProgress: "File {current} di {total}", + fileColumn: "File", + totalFiles: "{count} file", + expandSummary: "Espandi tabella riepilogativa", + restoreSummary: "Ripristina tabella riepilogativa", }, diff: { title: "Confronta Schemi", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 022630a479..11e0e740fb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3720,6 +3720,11 @@ export default withEnglishFallback({ error: "エラー", cancelled: "キャンセル済み", }, + fileProgress: "ファイル {current}/{total}", + fileColumn: "ファイル", + totalFiles: "{count} ファイル", + expandSummary: "展開統計表", + restoreSummary: "復元統計表", }, diff: { title: "スキーマを比較", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e41e80dd34..20279ee96a 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3874,6 +3874,9 @@ export default withEnglishFallback({ error: "오류", cancelled: "취소됨", }, + fileProgress: "파일 {current}/{total}", + fileColumn: "파일", + totalFiles: "{count}개 파일", }, sqlFileTree: { title: "SQL 파일", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 4868532fd3..61be45f064 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3721,6 +3721,11 @@ export default withEnglishFallback({ error: "Erro", cancelled: "Cancelado", }, + fileProgress: "Arquivo {current} de {total}", + fileColumn: "Arquivo", + totalFiles: "{count} arquivos", + expandSummary: "Expandir Tabela Estatística", + restoreSummary: "Restaurar Tabela Estatística", }, diff: { title: "Comparar Esquemas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 558e0052f8..b9a57d5c96 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3915,6 +3915,9 @@ export default withEnglishFallback({ error: "错误", cancelled: "已取消", }, + fileProgress: "第 {current}/{total} 个文件", + fileColumn: "文件", + totalFiles: "{count} 个文件", }, sqlFileTree: { title: "SQL 文件", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index bf3dc2011b..8cc8abad86 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3391,6 +3391,9 @@ export default withEnglishFallback({ error: "錯誤", cancelled: "已取消", }, + fileProgress: "第 {current}/{total} 個檔案", + fileColumn: "檔案", + totalFiles: "{count} 個檔案", runInBackground: "背景執行", }, diff: { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index d18c092d7c..1ad82a1f34 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3138,6 +3138,8 @@ export interface SqlFileProgress { elapsedMs: number; statementSummary: string; error?: string | null; + fileIndex?: number; + fileName?: string; } export async function previewSqlFile(filePath: string): Promise { diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index b0e9323305..c4c90f829d 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -237,6 +237,12 @@ pub struct SqlFileProgress { pub elapsed_ms: u128, pub statement_summary: String, pub error: Option, + /// When processing multiple files, the 0-based index of the current file. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_index: Option, + /// When processing multiple files, the name of the current file. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, } pub fn decode_sql_file_bytes(bytes: &[u8]) -> Result { diff --git a/crates/dbx-core/src/sql_file_import.rs b/crates/dbx-core/src/sql_file_import.rs index 7db72490e1..01f16b98b9 100644 --- a/crates/dbx-core/src/sql_file_import.rs +++ b/crates/dbx-core/src/sql_file_import.rs @@ -62,8 +62,11 @@ where } pub fn emit(&mut self, progress: SqlFileProgress) { - if sql_file_progress_is_immediate(progress.status) { - // Preserve ordering and final counters before terminal or failure events. + if sql_file_progress_is_immediate(progress.status) || progress.file_index.is_some() { + // Preserve ordering and final counters before terminal or failure + // events. File-boundary events (file_index is Some) are also + // emitted immediately so rapid multi-file runs don't lose per-file + // summaries through throttling. self.flush_pending(); (self.emit)(progress); return; @@ -390,7 +393,32 @@ pub async fn execute_sql_file_paths( return Err(error); } }; - for file_path in file_paths { + let file_count = file_paths.len(); + let mut prev_statement_index = 0usize; + let mut prev_success_count = 0usize; + let mut prev_failure_count = 0usize; + let mut prev_affected_rows = 0u64; + for (file_index, file_path) in file_paths.iter().enumerate() { + let file_name = file_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + + // Emit a file-boundary progress event so the frontend knows which file is + // currently executing and can display a "File N/M" indicator. + if file_count > 1 { + emit(SqlFileProgress { + execution_id: request.execution_id.clone(), + status: SqlFileStatus::Running, + statement_index: progress.statement_index, + success_count: progress.success_count, + failure_count: progress.failure_count, + affected_rows: progress.affected_rows, + elapsed_ms: started_at.elapsed().as_millis(), + statement_summary: String::new(), + error: None, + file_index: Some(file_index), + file_name: Some(file_name.clone()), + }); + } + let mut splitter = StreamingSqlFileSplitter::new(database_type, options); let mut pending_statements = Vec::with_capacity(SQL_FILE_STATEMENT_BATCH_SIZE); let mut decoder = match SqlFileStreamDecoder::open(file_path).await { @@ -457,6 +485,28 @@ pub async fn execute_sql_file_paths( &mut emit, ) .await?; + + // After each file, emit a per-file summary with diff-based counters so + // the frontend can build a per-file breakdown table. + if file_count > 1 { + emit(SqlFileProgress { + execution_id: request.execution_id.clone(), + status: SqlFileStatus::StatementDone, + statement_index: progress.statement_index - prev_statement_index, + success_count: progress.success_count - prev_success_count, + failure_count: progress.failure_count - prev_failure_count, + affected_rows: progress.affected_rows - prev_affected_rows, + elapsed_ms: started_at.elapsed().as_millis(), + statement_summary: String::new(), + error: None, + file_index: Some(file_index), + file_name: Some(file_name), + }); + prev_statement_index = progress.statement_index; + prev_success_count = progress.success_count; + prev_failure_count = progress.failure_count; + prev_affected_rows = progress.affected_rows; + } } emit_sql_file_terminal_progress(request, &token, started_at, &progress, &mut emit); Ok(()) @@ -753,6 +803,8 @@ pub fn sql_file_progress( elapsed_ms: started_at.elapsed().as_millis(), statement_summary: statement_summary.to_string(), error, + file_index: None, + file_name: None, } } @@ -1415,6 +1467,21 @@ mod tests { elapsed_ms: statement_index as u128, statement_summary: format!("statement {statement_index}"), error: None, + file_index: None, + file_name: None, + } + } + + fn test_file_progress( + status: SqlFileStatus, + statement_index: usize, + file_index: usize, + file_name: &str, + ) -> SqlFileProgress { + SqlFileProgress { + file_index: Some(file_index), + file_name: Some(file_name.to_string()), + ..test_progress(status, statement_index) } } @@ -1760,4 +1827,51 @@ mod tests { assert_eq!(mysql_use_database_target("USE"), None); assert_eq!(mysql_use_database_target("USE app_db SELECT 1"), None); } + + #[test] + fn file_boundary_events_bypass_throttling_and_retain_order() { + // Regression: rapid multi-file runs must not lose per-file boundary + // events through the progress emitter's 100 ms throttle window. + let base = Instant::now(); + let elapsed = Cell::new(Duration::ZERO); + let mut emitted = Vec::new(); + { + let mut emitter = + SqlFileProgressEmitter::with_clock(|progress| emitted.push(progress), || base + elapsed.get()); + + // Simulate two small files executing within the same throttle window. + for statement_index in 1..=3 { + emitter.emit(test_progress(SqlFileStatus::StatementDone, statement_index)); + } + // File 0 start + done. + emitter.emit(test_file_progress(SqlFileStatus::Running, 3, 0, "a.sql")); + emitter.emit(test_file_progress(SqlFileStatus::StatementDone, 3, 0, "a.sql")); + + for statement_index in 4..=6 { + emitter.emit(test_progress(SqlFileStatus::StatementDone, statement_index)); + } + // File 1 start + done — still within the same throttle window. + emitter.emit(test_file_progress(SqlFileStatus::Running, 6, 1, "b.sql")); + emitter.emit(test_file_progress(SqlFileStatus::StatementDone, 6, 1, "b.sql")); + + emitter.emit(test_progress(SqlFileStatus::Done, 6)); + } + + let file_boundary_events: Vec<_> = emitted + .iter() + .filter(|p| p.file_index.is_some()) + .map(|p| (p.status, p.file_index, p.file_name.clone())) + .collect(); + assert_eq!( + file_boundary_events, + vec![ + (SqlFileStatus::Running, Some(0), Some("a.sql".to_string())), + (SqlFileStatus::StatementDone, Some(0), Some("a.sql".to_string())), + (SqlFileStatus::Running, Some(1), Some("b.sql".to_string())), + (SqlFileStatus::StatementDone, Some(1), Some("b.sql".to_string())), + ], + "file-boundary events must be emitted immediately, in order, without being dropped by throttling" + ); + assert_eq!(emitted.last().unwrap().status, SqlFileStatus::Done); + } } diff --git a/packages/app-tests/sqlFileExecutionDialog.test.ts b/packages/app-tests/sqlFileExecutionDialog.test.ts index 7b42135eb3..dfaf2d30b5 100644 --- a/packages/app-tests/sqlFileExecutionDialog.test.ts +++ b/packages/app-tests/sqlFileExecutionDialog.test.ts @@ -24,6 +24,17 @@ test("SQL file execution dialog keeps actions visible within narrow viewports", assert.match(dialogSource, //); }); +test("SQL file execution summary reserves space for aggregate columns", () => { + assert.match(dialogSource, /v-if="!running && previews\.length > 1 && perFileResults\.length > 0"/); + assert.doesNotMatch(dialogSource, /terminalStatus === 'done' && previews\.length > 1 && perFileResults\.length > 0/); + assert.match(dialogSource, //); + assert.match(dialogSource, /[\s\S]*[\s\S]*[\s\S]*[\s\S]*<\/colgroup>/); + assert.match(dialogSource, /\{\{ t\("sqlFile\.totalFiles"/); +}); + test("SQL file execution dialog preserves cancel, close, and retry actions", () => { assert.match(dialogSource, /
/); + assert.match(dialogSource, /