feat(sql-file): show all execution failures

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-03 01:12:25 +08:00
1 parent 460b413da8
commit a13a7302fa
6 files changed
+414 -20

No files matched your search

@@ -232,9 +232,17 @@ function toggleFailureDetails(exportId: string) {
}
function failureDetailCount(task: ExportTask) {
if (task.kind === "sql-file") return (task.sqlFileFailures?.length ?? 0) + (task.sqlFileFailuresOmitted ?? 0);
return (task.transferFailures?.length ?? 0) + (task.transferFailuresOmitted ?? 0);
}
function hasUnlistedTaskError(task: ExportTask) {
if (task.status !== "Error" || !task.errorMessage) return false;
if (task.kind === "sql-file") return !(task.sqlFileFailures ?? []).some((failure) => failure.error === task.errorMessage);
if (task.kind === "data-transfer") return !(task.transferFailures ?? []).some((failure) => failure.error === task.errorMessage);
return true;
}
function openTask(task: ExportTask): void {
task.onOpen?.();
}
@@ -288,21 +296,35 @@ function openTask(task: ExportTask): void {
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
<span v-if="taskStatusText(task)" class="ml-1 font-medium text-primary">{{ taskStatusText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage)">
{{ translateBackendError(t, task.errorMessage) }}
<span v-if="hasUnlistedTaskError(task)" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage!)">
{{ translateBackendError(t, task.errorMessage!) }}
</span>
<template v-if="task.kind === 'data-transfer' && failureDetailCount(task) > 0">
<template v-if="(task.kind === 'data-transfer' || task.kind === 'sql-file') && failureDetailCount(task) > 0">
<button class="mt-1.5 flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary" :aria-expanded="failureDetailsExpanded(task.exportId)" @click="toggleFailureDetails(task.exportId)">
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': failureDetailsExpanded(task.exportId) }" />
{{ failureDetailsExpanded(task.exportId) ? t("exportProgress.hideFailureDetails") : t("exportProgress.showFailureDetails", { count: failureDetailCount(task) }) }}
</button>
<div v-if="failureDetailsExpanded(task.exportId)" class="mt-1.5 max-h-44 overflow-y-auto rounded border border-destructive/20 bg-destructive/5">
<div v-for="failure in task.transferFailures" :key="failure.table" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
<div class="break-all font-mono font-medium text-foreground">{{ failure.table }}</div>
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ failure.error }}</div>
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
</div>
<div v-if="task.transferFailuresOmitted" class="px-2.5 py-2 text-muted-foreground">{{ t("exportProgress.failureDetailsOmitted", { count: task.transferFailuresOmitted }) }}</div>
<template v-if="task.kind === 'data-transfer'">
<div v-for="failure in task.transferFailures" :key="failure.table" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
<div class="break-all font-mono font-medium text-foreground">{{ failure.table }}</div>
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ failure.error }}</div>
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
</div>
<div v-if="task.transferFailuresOmitted" class="px-2.5 py-2 text-muted-foreground">{{ t("exportProgress.failureDetailsOmitted", { count: task.transferFailuresOmitted }) }}</div>
</template>
<template v-else>
<div v-for="failure in task.sqlFileFailures" :key="`${failure.fileIndex ?? -1}:${failure.statementIndex}`" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
<div class="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
<span class="shrink-0">#{{ failure.statementIndex }}</span>
<span v-if="failure.fileName" class="truncate text-muted-foreground" :title="failure.fileName">{{ failure.fileName }}</span>
</div>
<div v-if="failure.statementSummary" class="mt-0.5 select-text whitespace-pre-wrap break-words font-mono text-foreground">{{ failure.statementSummary }}</div>
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ translateBackendError(t, failure.error) }}</div>
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
</div>
<div v-if="task.sqlFileFailuresOmitted" class="px-2.5 py-2 text-muted-foreground">{{ t("exportProgress.failureDetailsOmitted", { count: task.sqlFileFailuresOmitted }) }}</div>
</template>
</div>
</template>
</div>
@@ -281,6 +281,45 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).toContain("1 additional failure detail(s) not shown");
expect(document.body.textContent).not.toContain("failure 100");
});
it("keeps SQL-file statement failures inspectable after a successful continue-on-error run", async () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("sql-failure-details", "migration.sql", "/tmp/migration.sql");
tracker.updateSqlFileTask(task.exportId, {
executionId: task.exportId,
status: "statementFailed",
statementIndex: 7,
successCount: 6,
failureCount: 1,
affectedRows: 6,
elapsedMs: 10,
statementSummary: "ALTER TABLE users ADD missing_type",
error: "type missing_type does not exist",
});
tracker.updateSqlFileTask(task.exportId, {
executionId: task.exportId,
status: "done",
statementIndex: 8,
successCount: 7,
failureCount: 1,
affectedRows: 7,
elapsedMs: 12,
statementSummary: "INSERT INTO users VALUES (1)",
error: null,
});
await mountPopover();
expect(document.body.textContent).toContain("7 succeeded, 1 failed");
const detailsButton = document.body.querySelector<HTMLButtonElement>('button[aria-expanded="false"]');
expect(detailsButton?.textContent).toContain("Failure details (1)");
detailsButton?.click();
await nextTick();
expect(document.body.textContent).toContain("#7");
expect(document.body.textContent).toContain("ALTER TABLE users ADD missing_type");
expect(document.body.textContent).toContain("type missing_type does not exist");
});
});
describe("ExportProgressPopover file name and reveal action", () => {
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
toast: vi.fn(),
trackerTask: undefined as any,
unlisten: vi.fn(),
updateSqlFileTask: vi.fn(),
uuid: vi.fn(),
@@ -61,7 +62,7 @@ vi.mock("@/lib/backend/api", () => ({
}));
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 };
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"),
@@ -151,6 +152,34 @@ function deferred() {
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<string, any>, 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.fetchSqlFileTargetOptions.mockResolvedValue([]);
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
@@ -217,4 +246,53 @@ describe("SqlFileExecutionDialog retries", () => {
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");
});
});
@@ -21,8 +21,9 @@ import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
import { useExportTracker } from "@/composables/useExportTracker";
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
import { Check, CheckSquare, ChevronRight, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
const { t } = useI18n();
const { toast } = useToast();
@@ -93,6 +94,8 @@ const executionId = ref("");
const progress = ref<SqlFileProgress | null>(null);
const terminalStatus = ref<SqlFileStatus | "idle">("idle");
const terminalError = ref("");
const activeExecutionTask = ref<ExportTask | null>(null);
const failureDetailsExpanded = ref(false);
const refreshedTarget = ref(false);
const MAX_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024;
@@ -153,6 +156,13 @@ const progressPercent = computed(() => {
if (current <= 0) return running.value ? 8 : 0;
return Math.min(95, Math.max(8, Math.round((attempted / current) * 100)));
});
const sqlFileFailures = computed(() => activeExecutionTask.value?.sqlFileFailures ?? []);
const sqlFileFailureCount = computed(() => sqlFileFailures.value.length + (activeExecutionTask.value?.sqlFileFailuresOmitted ?? 0));
const unlistedTerminalError = computed(() => {
const error = progress.value?.error || terminalError.value;
if (!error || sqlFileFailures.value.some((failure) => failure.error === error)) return "";
return error;
});
function previewLineCount(item: SqlFilePreview) {
return item.preview.split(/\r\n|\r|\n/).length;
}
@@ -220,6 +230,8 @@ function resetExecution() {
progress.value = null;
terminalStatus.value = "idle";
terminalError.value = "";
activeExecutionTask.value = null;
failureDetailsExpanded.value = false;
refreshedTarget.value = false;
resetPerFileState();
}
@@ -391,8 +403,10 @@ async function startExecution() {
terminalStatus.value = "running";
terminalError.value = "";
progress.value = null;
activeExecutionTask.value = null;
failureDetailsExpanded.value = false;
const taskLabel = previews.value.length === 1 ? previews.value[0]!.fileName : `${previews.value[0]!.fileName} (+${previews.value.length - 1})`;
addSqlFileTask(batchId, taskLabel, filePathDisplay.value);
activeExecutionTask.value = addSqlFileTask(batchId, taskLabel, filePathDisplay.value);
try {
await store.ensureConnected(connectionId.value);
@@ -415,7 +429,6 @@ async function startExecution() {
progress.value = next;
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
@@ -445,6 +458,12 @@ async function startExecution() {
}
}
updateSqlFileTask(batchId, next, {
fileIndex: currentFileIndex.value >= 0 ? currentFileIndex.value : previews.value.length === 1 ? 0 : undefined,
fileName: currentFileName.value || (previews.value.length === 1 ? (displayFileNames.value.get(previews.value[0]!.filePath) ?? previews.value[0]!.fileName) : undefined),
});
if (next.status === "statementFailed") failureDetailsExpanded.value = true;
if (isTerminalProgress(next.status)) {
resolveTerminalProgress(next);
}
@@ -778,8 +797,29 @@ watch(
</div>
</div>
<div v-if="progress?.error || terminalError" class="max-w-full overflow-auto rounded-md border bg-destructive/5 p-2 text-xs text-destructive whitespace-pre-wrap">
{{ progress?.error || terminalError }}
<div v-if="sqlFileFailureCount > 0" class="min-w-0 space-y-1.5">
<button type="button" class="flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary" :aria-expanded="failureDetailsExpanded" @click="failureDetailsExpanded = !failureDetailsExpanded">
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': failureDetailsExpanded }" />
{{ failureDetailsExpanded ? t("exportProgress.hideFailureDetails") : t("exportProgress.showFailureDetails", { count: sqlFileFailureCount }) }}
</button>
<div v-if="failureDetailsExpanded" class="max-h-[min(30vh,280px)] min-w-0 overflow-y-auto rounded-md border border-destructive/20 bg-destructive/5 text-xs">
<div v-for="failure in sqlFileFailures" :key="`${failure.fileIndex ?? -1}:${failure.statementIndex}`" class="border-b border-destructive/15 p-2.5 last:border-b-0">
<div class="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
<span class="shrink-0">#{{ failure.statementIndex }}</span>
<span v-if="failure.fileName" class="truncate text-muted-foreground" :title="failure.fileName">{{ failure.fileName }}</span>
</div>
<div v-if="failure.statementSummary" class="mt-1 max-w-full overflow-auto font-mono whitespace-pre-wrap break-words text-foreground">{{ failure.statementSummary }}</div>
<div class="mt-1 select-text whitespace-pre-wrap break-words text-destructive">{{ translateBackendError(t, failure.error) }}</div>
<div v-if="failure.truncated" class="mt-1 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
</div>
<div v-if="activeExecutionTask?.sqlFileFailuresOmitted" class="p-2.5 text-muted-foreground">
{{ t("exportProgress.failureDetailsOmitted", { count: activeExecutionTask.sqlFileFailuresOmitted }) }}
</div>
</div>
</div>
<div v-if="unlistedTerminalError" class="max-w-full overflow-auto rounded-md border bg-destructive/5 p-2 text-xs text-destructive whitespace-pre-wrap">
{{ translateBackendError(t, unlistedTerminalError) }}
</div>
</div>
</div>