feat(sql-file): show all execution failures
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user