fix(startup): avoid repeated migration success screen

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 17:58:11 +08:00
1 parent c373f322d2
commit 6eda116e10
3 files changed
+76 -21

No files matched your search

+36 -3
View File
@@ -48,6 +48,20 @@ describe("migration store", () => {
await store.initialize();
expect(store.completed.value).toBe(true);
expect(store.blocking.value).toBe(false);
expect(store.state.entered).toBe(false);
});
it("does not block a restart when migration is not required", async () => {
const store = useMigrationStore({
migrationStatus: vi.fn().mockResolvedValue({ ...done(), state: "not_required" }),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
});
await store.initialize();
expect(store.completed.value).toBe(true);
expect(store.blocking.value).toBe(false);
expect(store.state.entered).toBe(false);
});
it("keeps status failures on the status retry path", async () => {
@@ -127,10 +141,10 @@ describe("migration store", () => {
expect(store.state.busy).toBe(true);
await store.cleanup();
expect(cleanup).toHaveBeenCalledTimes(2);
store.enter();
expect(store.state.entered).toBe(true);
finishCleanup();
await cleanupAttempt;
store.enter();
expect(store.state.entered).toBe(true);
expect(store.state.busy).toBe(false);
expect(store.state.step).toBe(3);
expect(store.state.error).toBeNull();
@@ -140,11 +154,30 @@ describe("migration store", () => {
});
it("recovers when a lost action response is followed by verified completion", async () => {
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValueOnce(pending()).mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
await store.start();
expect(store.state.step).toBe(3);
expect(store.state.error).toBeNull();
expect(store.state.busy).toBe(false);
expect(store.state.report).toBeNull();
expect(store.blocking.value).toBe(true);
store.enter();
expect(store.blocking.value).toBe(false);
});
it("keeps the success page after retrying a failed status check in the same session", async () => {
const status = vi.fn().mockResolvedValueOnce(pending()).mockRejectedValueOnce(new Error("status unavailable")).mockResolvedValueOnce(done());
const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() });
await store.initialize();
await store.start();
expect(store.state.error).toBe("statusFailed");
await store.initialize();
expect(store.completed.value).toBe(true);
expect(store.state.report).toBeNull();
expect(store.blocking.value).toBe(true);
store.enter();
expect(store.blocking.value).toBe(false);
});
it("retries a failed migration and completes without losing the error report", async () => {
const failed = { ...pending(), state: "failed" as const, errorCode: "BACKUP_FAILED", errorMessage: "Cannot create backup" };
+10 -4
View File
@@ -1,4 +1,4 @@
import { computed, reactive } from "vue";
import { computed, reactive, ref } from "vue";
import * as api from "@/lib/backend/api";
import type { MigrationPreflight, MigrationReport } from "@/lib/backend/migration";
@@ -17,12 +17,16 @@ export function useMigrationStore(backend: MigrationApi = api) {
entered: boolean;
step: 1 | 2 | 3;
}>({ status: null, report: null, loading: true, busy: false, error: null, errorCode: null, errorMessage: null, entered: false, step: 1 });
const migrationAttempted = ref(false);
const completed = computed(() => state.status !== null && !state.status.needsMigration && ["succeeded", "not_required"].includes(state.status.state));
// A managed data-directory key is intentionally created when migration
// starts. Its absence during the read-only preflight is actionable, not a
// migration failure, as long as the backend explicitly allows creation.
const hasBlockingError = () => Boolean(state.errorCode && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true));
const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && Boolean(state.report || state.status?.backupPath)));
// A retained backup is a recovery asset and must not make the success page
// reappear on later launches. An attempt in this session keeps the wizard
// visible even when its response is lost, until the user enters the app.
const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && (migrationAttempted.value || Boolean(state.report))));
function clearError() {
state.error = null;
state.errorCode = null;
@@ -43,8 +47,9 @@ export function useMigrationStore(backend: MigrationApi = api) {
state.step = completed.value ? 3 : 1;
// A completed migration only needs the success page in the session that
// performed it. On later launches, retained backups are recovery assets,
// not a reason to block the normal application startup.
state.entered = completed.value;
// not a reason to block the normal application startup. `entered` is
// therefore only changed by the explicit success-page action below.
state.entered = false;
if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
@@ -55,6 +60,7 @@ export function useMigrationStore(backend: MigrationApi = api) {
async function run(retry: boolean) {
if (state.busy) return;
state.busy = true;
migrationAttempted.value = true;
state.step = 2;
clearError();
try {