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
@@ -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