diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 99a7e980b5..a4b58c11cc 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -39,7 +39,7 @@ import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChang import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload"; import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups"; import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls"; -import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup"; +import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup"; import { useSaveSqlFolderSelection } from "@/composables/useSaveSqlFolderSelection"; import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -2376,7 +2376,6 @@ async function initApp() { console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); }; - if (!desktopOpenTabsRestorationBarrier) await settingsStore.initAiConfigs(); try { if (desktopOpenTabsRestorationBarrier) { await initializeDesktopOpenTabs({ @@ -2386,7 +2385,11 @@ async function initApp() { onOptionalStateError: (error) => console.error("[STARTUP] settingsStore.initAiConfigs failed", error), }); } else { - await restoreOpenTabs(); + await initializeOpenTabs({ + initializeOptionalState: () => settingsStore.initAiConfigs(), + restoreOpenTabs, + onOptionalStateError: (error) => console.error("[STARTUP] settingsStore.initAiConfigs failed", error), + }); } await settingsStore.initDesktopSettings().catch(() => {}); diff --git a/apps/desktop/src/lib/app/openTabsStartup.ts b/apps/desktop/src/lib/app/openTabsStartup.ts index 4ecd524a42..e41b28b0ed 100644 --- a/apps/desktop/src/lib/app/openTabsStartup.ts +++ b/apps/desktop/src/lib/app/openTabsStartup.ts @@ -20,21 +20,28 @@ export function createOpenTabsRestorationBarrier(): OpenTabsRestorationBarrier { }; } -interface InitializeDesktopOpenTabsOptions { - barrier: OpenTabsRestorationBarrier; +interface InitializeOpenTabsOptions { initializeOptionalState: () => Promise; restoreOpenTabs: () => Promise; onOptionalStateError: (error: unknown) => void; } -export async function initializeDesktopOpenTabs({ barrier, initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeDesktopOpenTabsOptions): Promise { +export async function initializeOpenTabs({ initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeOpenTabsOptions): Promise { try { - try { - await initializeOptionalState(); - } catch (error) { - onOptionalStateError(error); - } - await restoreOpenTabs(); + await initializeOptionalState(); + } catch (error) { + onOptionalStateError(error); + } + await restoreOpenTabs(); +} + +interface InitializeDesktopOpenTabsOptions extends InitializeOpenTabsOptions { + barrier: OpenTabsRestorationBarrier; +} + +export async function initializeDesktopOpenTabs({ barrier, ...options }: InitializeDesktopOpenTabsOptions): Promise { + try { + await initializeOpenTabs(options); } finally { barrier.settle(); } diff --git a/packages/app-tests/externalSqlFileStartup.test.ts b/packages/app-tests/externalSqlFileStartup.test.ts index 61777cd6db..7d28460fe0 100644 --- a/packages/app-tests/externalSqlFileStartup.test.ts +++ b/packages/app-tests/externalSqlFileStartup.test.ts @@ -1,10 +1,77 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "vitest"; -import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs } from "../../apps/desktop/src/lib/app/openTabsStartup.ts"; +import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs } from "../../apps/desktop/src/lib/app/openTabsStartup.ts"; const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); +test("web startup reports optional initialization failure and continues restoring persisted state", async () => { + const events: string[] = []; + + await initializeOpenTabs({ + initializeOptionalState: async () => { + events.push("initialize-ai-configs"); + throw new Error("AI config storage unavailable"); + }, + restoreOpenTabs: async () => { + events.push("initialize-editor-settings"); + events.push("initialize-connections"); + events.push("restore-tabs"); + }, + onOptionalStateError: () => events.push("report-ai-config-error"), + }); + + assert.deepEqual(events, ["initialize-ai-configs", "report-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs"]); +}); + +test("shared startup initializes optional state once and propagates required restoration failure", async () => { + let optionalInitializationCount = 0; + + await assert.rejects( + initializeOpenTabs({ + initializeOptionalState: async () => { + optionalInitializationCount += 1; + }, + restoreOpenTabs: async () => { + throw new Error("persisted connections unavailable"); + }, + onOptionalStateError: () => assert.fail("successful optional initialization must not be reported"), + }), + /persisted connections unavailable/, + ); + + assert.equal(optionalInitializationCount, 1); +}); + +test("web startup uses the guarded initializer behind the existing authentication gate", () => { + const initAppStart = appSource.indexOf("async function initApp()"); + const initAppEnd = appSource.indexOf("function restoreActiveConnectionContext", initAppStart); + assert.ok(initAppStart >= 0 && initAppEnd > initAppStart); + + const initAppSource = appSource.slice(initAppStart, initAppEnd); + const webInitialization = initAppSource.indexOf("await initializeOpenTabs({"); + const optionalInitialization = initAppSource.indexOf("initializeOptionalState: () => settingsStore.initAiConfigs()", webInitialization); + const requiredRestoration = initAppSource.indexOf("restoreOpenTabs,", optionalInitialization); + const savedSqlInitialization = initAppSource.indexOf("savedSqlStore.initFromStorage()", requiredRestoration); + assert.ok(webInitialization >= 0); + assert.ok(optionalInitialization > webInitialization); + assert.ok(requiredRestoration > optionalInitialization); + assert.ok(savedSqlInitialization > requiredRestoration); + assert.equal(initAppSource.includes("if (!desktopOpenTabsRestorationBarrier) await settingsStore.initAiConfigs()"), false); + + const mountedStart = appSource.indexOf("onMounted(async () =>"); + const mountedEnd = appSource.indexOf("onUnmounted(", mountedStart); + assert.ok(mountedStart >= 0 && mountedEnd > mountedStart); + + const mountedSource = appSource.slice(mountedStart, mountedEnd); + const authCheck = mountedSource.indexOf('fetch(apiUrl("/api/auth/check"))'); + const loginRedirect = mountedSource.indexOf('history.replaceState(null, "", webPath("/login"))', authCheck); + const authenticatedInitialization = mountedSource.indexOf("if (!setupRequired.value && (!needsAuth.value || authenticated.value)) void initApp()", loginRedirect); + assert.ok(authCheck >= 0); + assert.ok(loginRedirect > authCheck); + assert.ok(authenticatedInitialization > loginRedirect); +}); + test("desktop SQL file opening survives unrelated initialization failure and follows restored tabs", async () => { const events: string[] = []; const barrier = createOpenTabsRestorationBarrier();