// @vitest-environment happy-dom import { createApp, defineComponent, h, nextTick, reactive } 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(), inspectSqlFileTables: vi.fn(), progressHandler: undefined as undefined | ((progress: Record) => void), refreshDatabaseTreeNode: vi.fn(), requestConfirmation: vi.fn(), toast: vi.fn(), trackerTask: undefined as any, 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, supportsConnectionLevelDatabaseBootstrap: (connection: any) => connection?.db_type === "mysql" })); 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, inspectSqlFileTables: mocks.inspectSqlFileTables, })); vi.mock("@lucide/vue", () => { const Icon = passthrough("span"); return { Check: Icon, CheckSquare: Icon, ChevronRight: 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: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], setup(props, { emit }) { return () => h("input", { value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }); }, }), })); 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.trackerTask = undefined; mocks.addSqlFileTask.mockImplementation((exportId: string, tableName: string, filePath: string) => { mocks.trackerTask = reactive({ exportId, kind: "sql-file", tableName, format: "sql", filePath, rowsExported: 0, totalRows: null, status: "Running", errorMessage: null, sqlFileFailures: [], sqlFileFailuresOmitted: 0, }); return mocks.trackerTask; }); mocks.updateSqlFileTask.mockImplementation((_executionId: string, next: Record, context?: { fileIndex?: number; fileName?: string }) => { if (next.status === "statementFailed" && next.error) { mocks.trackerTask.sqlFileFailures.push({ statementIndex: next.statementIndex, statementSummary: next.statementSummary, error: next.error, ...(context?.fileIndex === undefined ? {} : { fileIndex: context.fileIndex }), ...(context?.fileName ? { fileName: context.fileName } : {}), }); } }); mocks.ensureConnected.mockResolvedValue(undefined); mocks.inspectSqlFileTables.mockResolvedValue([ { database: "app", name: "users" }, { database: "app", name: "orders" }, { database: "archive", name: "users" }, ]); 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 auto-select the first connection for an unassociated external file", async () => { root = document.createElement("div"); document.body.append(root); app = createApp(SqlFileExecutionDialog, { open: true, prefillFilePath: "/tmp/unassociated.sql" }); app.mount(root); await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledWith("/tmp/unassociated.sql")); expect(mocks.ensureConnected).not.toHaveBeenCalled(); expect(mocks.fetchSqlFileTargetOptions).not.toHaveBeenCalled(); expect(findButton("sqlFile.execute").disabled).toBe(true); }); 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); }); it("shows every statement failure in order after continue-on-error completion", async () => { await mountReadyDialog(); mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => { mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT bad_one", error: "unknown column one" })); mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 2, failureCount: 2, statementSummary: "INSERT bad_two", error: "unknown column two" })); mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 3, failureCount: 3, statementSummary: "INSERT bad_three", error: "syntax error three" })); mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 4, successCount: 1, failureCount: 3 })); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(root!.textContent).toContain("syntax error three")); const text = root!.textContent ?? ""; expect(text).toContain("unknown column one"); expect(text).toContain("unknown column two"); expect(text.indexOf("INSERT bad_one")).toBeLessThan(text.indexOf("INSERT bad_two")); expect(text.indexOf("INSERT bad_two")).toBeLessThan(text.indexOf("INSERT bad_three")); }); it("shows the first statement failure when stop-on-error becomes terminal", async () => { await mountReadyDialog(); mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => { mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stop_here", error: "stop-on-error failure" })); mocks.progressHandler?.(progress(request.executionId, "error", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stop_here", error: "stop-on-error failure" })); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(root!.textContent).toContain("stop-on-error failure")); expect(root!.textContent?.match(/stop-on-error failure/g)).toHaveLength(1); expect(root!.textContent).toContain("INSERT stop_here"); }); it("clears statement failure details before an early retry failure", async () => { await mountReadyDialog(); mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => { mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stale", error: "stale statement failure" })); mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 2, successCount: 1, failureCount: 1 })); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(root!.textContent).toContain("stale statement failure")); mocks.ensureConnected.mockRejectedValueOnce(new Error("retry connection failed")); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(root!.textContent).toContain("retry connection failed")); expect(root!.textContent).not.toContain("stale statement failure"); }); }); describe("SqlFileExecutionDialog selected-table restore", () => { async function mountBackup() { root = document.createElement("div"); document.body.append(root); app = createApp(SqlFileExecutionDialog, { open: true, prefillConnectionId: "mysql-1", prefillDatabase: "app", prefillFilePath: "/tmp/backup.sql.gz" }); app.mount(root); await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false)); root.querySelectorAll('input[type="radio"]')[1]!.click(); await nextTick(); } it("scans the backup and sends only checked table identities", async () => { await mountBackup(); await vi.waitFor(() => expect(root!.textContent).toContain("archive.users")); expect(mocks.inspectSqlFileTables).toHaveBeenCalledWith("/tmp/backup.sql.gz"); expect(findButton("sqlFile.execute").disabled).toBe(true); const label = Array.from(root!.querySelectorAll("label")).find((label) => label.textContent === "app.users")!; label.querySelector("input")!.click(); await nextTick(); mocks.executeSqlFiles.mockImplementationOnce(async (request) => { mocks.progressHandler?.(progress(request.executionId, "done", { successCount: 2 })); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ selectedTables: [{ database: "app", name: "users" }] }), ["/tmp/backup.sql.gz"])); }); it("selects only search matches and preserves selections outside the search", async () => { await mountBackup(); await vi.waitFor(() => expect(root!.textContent).toContain("archive.users")); const search = root!.querySelector('[aria-label="sqlFile.searchBackupTables"]')!; search.value = "app."; search.dispatchEvent(new Event("input")); await nextTick(); root!.querySelector('[data-table-restore] input[type="checkbox"]')!.click(); await nextTick(); search.value = "archive"; search.dispatchEvent(new Event("input")); await nextTick(); expect(root!.querySelector('[data-table-restore] input[type="checkbox"]')!.checked).toBe(false); mocks.executeSqlFiles.mockImplementationOnce(async (request) => { mocks.progressHandler?.(progress(request.executionId, "done")); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith( expect.objectContaining({ selectedTables: [ { database: "app", name: "users" }, { database: "app", name: "orders" }, ], }), expect.any(Array), ), ); }); it("blocks execution on scan failure and allows switching back to full restore", async () => { mocks.inspectSqlFileTables.mockRejectedValueOnce(new Error("unsupported dump statement")); await mountBackup(); await vi.waitFor(() => expect(root!.querySelector('[role="alert"]')?.textContent).toContain("unsupported dump statement")); expect(findButton("sqlFile.execute").disabled).toBe(true); root!.querySelector('input[type="radio"]')!.click(); await nextTick(); expect(findButton("sqlFile.execute").disabled).toBe(false); }); it("ignores a scan result after switching back to all contents", async () => { let finish!: (tables: any[]) => void; mocks.inspectSqlFileTables.mockImplementationOnce( () => new Promise((resolve) => { finish = resolve; }), ); await mountBackup(); expect(findButton("sqlFile.execute").disabled).toBe(true); root!.querySelector('input[type="radio"]')!.click(); await nextTick(); finish([{ database: null, name: "stale" }]); await nextTick(); expect(root!.textContent).not.toContain("stale"); mocks.executeSqlFiles.mockImplementationOnce(async (request) => { mocks.progressHandler?.(progress(request.executionId, "done")); }); findButton("sqlFile.execute").click(); await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled()); expect(mocks.executeSqlFiles.mock.calls[0]![0]).not.toHaveProperty("selectedTables"); }); });