fix(connection): preserve edited name after tests

This commit is contained in:
t8y2 2026-07-19 11:12:04 +08:00
parent 91abfb747b
commit 0fd7b0d41b
3 changed files with 107 additions and 10 deletions

View File

@ -21,7 +21,7 @@ import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stor
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
import { connectionEditDraftSyncAction } from "./connectionEditDraftSync";
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
@ -1299,12 +1299,35 @@ function applySuccessfulConnectionTest(result: ConnectionTestResult, config: Con
testedGeneratedName.value = config.name;
}
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string, runId: number) {
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
const fingerprint = connectionConfigFingerprint(config, sourceName);
if (fingerprint !== savedConnectionConfigFingerprint.value) return;
let currentDraftFingerprint: string;
try {
await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo);
const currentDraft = connectionConfigForSubmit(editingId.value, form.value.name);
currentDraftFingerprint = connectionConfigFingerprint(currentDraft, form.value.name);
} catch {
return;
}
// An in-flight test must not publish its saved snapshot after the user edits,
// switches, or closes the draft that initiated it.
if (
!canPersistConnectionTestResult({
testConfigId: config.id,
activeDraftId: editingId.value,
testRunId: runId,
activeTestRunId: testRunId,
submittedFingerprint: fingerprint,
savedFingerprint: savedConnectionConfigFingerprint.value,
currentDraftFingerprint,
})
) {
return;
}
const persistedDraftId = editingId.value;
try {
await store.updateConnectionDatabaseInfo(persistedDraftId, result.databaseInfo);
if (runId !== testRunId || editingId.value !== persistedDraftId) return;
savedDatabaseInfo.value = { ...result.databaseInfo };
savedDatabaseInfoFingerprint.value = fingerprint;
} catch {
@ -2358,7 +2381,7 @@ async function testConnection() {
successfulConfig = connectionConfigForSubmit(config.id, config.name);
}
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName, runId);
clearEditedConnectionErrorAfterSuccessfulTest();
} catch (e: any) {
if (runId !== testRunId) return;
@ -2372,7 +2395,7 @@ async function testConnection() {
}
if (fallback) {
applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName, runId);
clearEditedConnectionErrorAfterSuccessfulTest();
} else {
clearTestedConnectionInfo();

View File

@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { connectionEditDraftSyncAction } from "../connectionEditDraftSync";
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "../connectionEditDraftSync";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("connectionEditDraftSyncAction", () => {
it("hydrates normal connection edits without reloading an active draft", () => {
@ -11,12 +19,64 @@ describe("connectionEditDraftSyncAction", () => {
});
it("keeps a copied connection draft when its saved snapshot is refreshed", () => {
const draft = { id: "connection-b", host: "edited.example.test", password: "edited-secret" };
const savedCopySnapshot = { id: "connection-b", host: "original.example.test", password: "original-secret" };
const draft = { id: "connection-b", name: "edited copy", host: "edited.example.test", password: "edited-secret" };
const savedCopySnapshot = { id: "connection-b", name: "original copy", host: "original.example.test", password: "original-secret" };
const action = connectionEditDraftSyncAction(savedCopySnapshot.id, true, draft.id);
expect(action).toBe("preserve");
expect(draft).toEqual({ id: "connection-b", host: "edited.example.test", password: "edited-secret" });
expect(draft).toEqual({ id: "connection-b", name: "edited copy", host: "edited.example.test", password: "edited-secret" });
});
it("does not persist an asynchronous test after the user edits the copied name", async () => {
const pendingTest = deferred<string>();
const draft = { id: "connection-b", name: "original copy", fingerprint: "saved-copy" };
const completion = pendingTest.promise.then((submittedFingerprint) =>
canPersistConnectionTestResult({
testConfigId: draft.id,
activeDraftId: draft.id,
testRunId: 7,
activeTestRunId: 7,
submittedFingerprint,
savedFingerprint: "saved-copy",
currentDraftFingerprint: draft.fingerprint,
}),
);
draft.name = "edited copy";
draft.fingerprint = "edited-copy";
pendingTest.resolve("saved-copy");
expect(await completion).toBe(false);
expect(draft.name).toBe("edited copy");
});
it("rejects test side effects after switching targets or closing the dialog", () => {
const baseState = {
testConfigId: "connection-a",
testRunId: 4,
submittedFingerprint: "saved-a",
savedFingerprint: "saved-a",
currentDraftFingerprint: "saved-a",
};
expect(canPersistConnectionTestResult({ ...baseState, activeDraftId: "connection-b", activeTestRunId: 5 })).toBe(false);
expect(canPersistConnectionTestResult({ ...baseState, activeDraftId: null, activeTestRunId: 5 })).toBe(false);
expect(connectionEditDraftSyncAction("connection-b", true, "connection-a")).toBe("hydrate");
expect(connectionEditDraftSyncAction("connection-a", false, "connection-a")).toBe("preserve");
});
it("preserves the normal unchanged test persistence path", () => {
expect(
canPersistConnectionTestResult({
testConfigId: "connection-a",
activeDraftId: "connection-a",
testRunId: 3,
activeTestRunId: 3,
submittedFingerprint: "saved-a",
savedFingerprint: "saved-a",
currentDraftFingerprint: "saved-a",
}),
).toBe(true);
});
});

View File

@ -1,5 +1,15 @@
export type ConnectionEditDraftSyncAction = "hydrate" | "reset" | "preserve";
export interface ConnectionTestPersistenceState {
testConfigId: string;
activeDraftId: string | null;
testRunId: number;
activeTestRunId: number;
submittedFingerprint: string;
savedFingerprint: string;
currentDraftFingerprint: string;
}
export function connectionEditDraftSyncAction(configId: string | null, isOpen: boolean, activeDraftId: string | null): ConnectionEditDraftSyncAction {
if (!isOpen) return "preserve";
// Saved snapshots for the active connection must not overwrite its draft,
@ -7,3 +17,7 @@ export function connectionEditDraftSyncAction(configId: string | null, isOpen: b
if (configId && configId === activeDraftId) return "preserve";
return configId ? "hydrate" : "reset";
}
export function canPersistConnectionTestResult(state: ConnectionTestPersistenceState): boolean {
return state.testConfigId === state.activeDraftId && state.testRunId === state.activeTestRunId && state.submittedFingerprint === state.savedFingerprint && state.currentDraftFingerprint === state.submittedFingerprint;
}