mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(startup): reduce initial loading and unblock app restoration
This commit is contained in:
+32
-7
@@ -13,6 +13,10 @@
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html.dark,
|
||||
html.dark body,
|
||||
html.dark #root {
|
||||
@@ -22,30 +26,47 @@
|
||||
#startup-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font: 13px system-ui, sans-serif;
|
||||
font:
|
||||
14px system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#startup-loading::before {
|
||||
#startup-loading .startup-brand {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
html.dark #startup-loading .startup-brand {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
#startup-loading .startup-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(107, 114, 128, 0.22);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 999px;
|
||||
content: "";
|
||||
animation: dbx-startup-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#startup-loading::before { animation: none; }
|
||||
#startup-loading .startup-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dbx-startup-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script data-dbx-startup-theme>
|
||||
@@ -98,7 +119,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
|
||||
<div id="startup-loading" role="status" aria-live="polite" aria-busy="true">
|
||||
<span class="startup-brand" aria-hidden="true">DBX</span>
|
||||
<span class="startup-spinner" aria-hidden="true"></span>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
+23
-12
@@ -2,6 +2,10 @@
|
||||
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
|
||||
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FileText } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -145,7 +149,7 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
|
||||
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
@@ -215,6 +219,7 @@ type AiAssistantHandle = {
|
||||
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -362,9 +367,9 @@ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, appl
|
||||
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const needsAuth = ref(!isDesktop);
|
||||
const authenticated = ref(isDesktop);
|
||||
const setupRequired = ref(false);
|
||||
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
|
||||
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
|
||||
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
|
||||
|
||||
const showConnectionDialog = ref(false);
|
||||
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
|
||||
@@ -3867,10 +3872,13 @@ async function initApp() {
|
||||
console.log("[STARTUP] initApp begin");
|
||||
const restoreOpenTabs = async () => {
|
||||
await settingsStore.initEditorSettings();
|
||||
markStartupPhase("settings-ready");
|
||||
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
await connectionStore.initFromDisk();
|
||||
markStartupPhase("connections-ready");
|
||||
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) });
|
||||
markStartupPhase("tabs-restored");
|
||||
console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
};
|
||||
|
||||
@@ -3975,6 +3983,8 @@ function runUpdateNotificationChecks() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
clearStartupPreloadRetry();
|
||||
markStartupPhase("app-mounted");
|
||||
console.log("[STARTUP] onMounted begin");
|
||||
const mountStart = performance.now();
|
||||
if (isDetachedWindowContext) {
|
||||
@@ -4024,14 +4034,15 @@ onMounted(async () => {
|
||||
true,
|
||||
);
|
||||
if (!isDesktop) {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/auth/check"));
|
||||
const data = await res.json();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
setupRequired.value = data.setup_required;
|
||||
} catch {
|
||||
/* server unreachable */
|
||||
if (!startupProps.startupAuthentication) {
|
||||
try {
|
||||
const data = await checkStartupAuthentication();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
setupRequired.value = data.setup_required;
|
||||
} catch {
|
||||
/* server unreachable */
|
||||
}
|
||||
}
|
||||
if (needsAuth.value && !authenticated.value) {
|
||||
history.replaceState(null, "", webPath("/login"));
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LoginPage from "@/components/auth/LoginPage.vue";
|
||||
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
|
||||
import StartupLoading from "@/components/layout/StartupLoading.vue";
|
||||
import { useMigrationStore } from "@/stores/migrationStore";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { loadSavedLocale } from "@/i18n";
|
||||
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
|
||||
|
||||
// App setup installs listeners and instantiates business stores, so even its import
|
||||
// is deferred until authentication and the security migration have completed.
|
||||
const App = defineAsyncComponent(() => import("./App.vue"));
|
||||
const startupAsyncOptions = {
|
||||
loadingComponent: StartupLoading,
|
||||
errorComponent: StartupLoading,
|
||||
delay: 0,
|
||||
suspensible: false,
|
||||
onError(error: Error, _retry: () => void, fail: () => void) {
|
||||
retryStartupAfterPreloadFailure(error);
|
||||
fail();
|
||||
},
|
||||
};
|
||||
const App = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("./App.vue") });
|
||||
const LoginPage = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/auth/LoginPage.vue") });
|
||||
const SecurityMigrationWizard = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/migration/SecurityMigrationWizard.vue") });
|
||||
const props = defineProps<{ localeReady?: Promise<void> }>();
|
||||
const { t } = useI18n();
|
||||
const migration = useMigrationStore();
|
||||
const { blocking } = migration;
|
||||
@@ -17,33 +33,42 @@ const checkingAuth = ref(true);
|
||||
const loginRequired = ref(false);
|
||||
const setupRequired = ref(false);
|
||||
const authFailed = ref(false);
|
||||
const enteringApp = ref(false);
|
||||
let launchTransitionTimer: number | undefined;
|
||||
const startupAuthentication = shallowRef<StartupAuthentication>();
|
||||
const checkingLocale = ref(Boolean(props.localeReady));
|
||||
const localeFailed = ref(false);
|
||||
let initialLocaleReady = props.localeReady;
|
||||
let authRequest: AbortController | undefined;
|
||||
|
||||
watch(
|
||||
() => migration.state.entered,
|
||||
(entered) => {
|
||||
if (entered) {
|
||||
enteringApp.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(migration.blocking, (blocked) => {
|
||||
if (!blocked) markStartupPhase("migration-ready");
|
||||
});
|
||||
|
||||
function appReady() {
|
||||
window.clearTimeout(launchTransitionTimer);
|
||||
launchTransitionTimer = window.setTimeout(() => {
|
||||
enteringApp.value = false;
|
||||
}, 650);
|
||||
async function initializeLocale() {
|
||||
if (!initialLocaleReady && !localeFailed.value) return;
|
||||
checkingLocale.value = true;
|
||||
localeFailed.value = false;
|
||||
try {
|
||||
await (initialLocaleReady ?? loadSavedLocale());
|
||||
markStartupPhase("locale-ready");
|
||||
window.dispatchEvent(new Event("dbx:startup-ready"));
|
||||
} catch {
|
||||
localeFailed.value = true;
|
||||
} finally {
|
||||
initialLocaleReady = undefined;
|
||||
checkingLocale.value = false;
|
||||
}
|
||||
}
|
||||
async function initialize() {
|
||||
authRequest?.abort();
|
||||
const request = new AbortController();
|
||||
authRequest = request;
|
||||
checkingAuth.value = true;
|
||||
authFailed.value = false;
|
||||
try {
|
||||
if (!isTauriRuntime()) {
|
||||
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
|
||||
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
|
||||
const result = await response.json();
|
||||
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
|
||||
const result = await checkStartupAuthentication(request.signal);
|
||||
if (request.signal.aborted) return;
|
||||
startupAuthentication.value = result;
|
||||
setupRequired.value = result.setup_required === true;
|
||||
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
|
||||
if (loginRequired.value) {
|
||||
@@ -51,74 +76,27 @@ async function initialize() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
markStartupPhase("auth-ready");
|
||||
await migration.initialize();
|
||||
} catch {
|
||||
authFailed.value = true;
|
||||
if (!request.signal.aborted) authFailed.value = true;
|
||||
} finally {
|
||||
checkingAuth.value = false;
|
||||
if (!request.signal.aborted) checkingAuth.value = false;
|
||||
}
|
||||
}
|
||||
async function authenticated() {
|
||||
history.replaceState(null, "", webPath("/"));
|
||||
await initialize();
|
||||
}
|
||||
onMounted(initialize);
|
||||
onMounted(() => {
|
||||
void initializeLocale();
|
||||
void initialize();
|
||||
});
|
||||
onUnmounted(() => authRequest?.abort());
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
|
||||
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
|
||||
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
|
||||
</div>
|
||||
<StartupLoading v-if="checkingLocale || localeFailed || checkingAuth || authFailed" :label="authFailed ? t('migration.authFailed') : !checkingLocale && !localeFailed ? t('migration.checking') : undefined" :error="localeFailed || authFailed" :retry="localeFailed ? initializeLocale : initialize" />
|
||||
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
|
||||
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
|
||||
<div v-else class="relative min-h-screen">
|
||||
<Suspense @resolve="appReady">
|
||||
<App />
|
||||
</Suspense>
|
||||
<Transition name="startup-fade">
|
||||
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
|
||||
<div class="flex flex-col items-center gap-5 text-center">
|
||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
|
||||
</div>
|
||||
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<App v-else :startup-authentication="startupAuthentication" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-fade-enter-active,
|
||||
.startup-fade-leave-active {
|
||||
transition: opacity 280ms ease;
|
||||
}
|
||||
|
||||
.startup-fade-enter-from,
|
||||
.startup-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.startup-progress {
|
||||
animation: startup-progress 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes startup-progress {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.startup-progress {
|
||||
animation: none;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: vi.fn(),
|
||||
appImported: vi.fn(),
|
||||
reloadLocale: vi.fn(),
|
||||
migrationStatus: vi.fn(),
|
||||
migrationStart: vi.fn(),
|
||||
migrationRetry: vi.fn(),
|
||||
migrationCleanupBackups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.desktop }));
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("@/i18n", () => ({ loadSavedLocale: mocks.reloadLocale }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const readyStatus = { state: "not_required", needsMigration: false };
|
||||
let businessReady: ReturnType<typeof deferred<void>>;
|
||||
let mountedApp: App | undefined;
|
||||
let root: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
sessionStorage.clear();
|
||||
mocks.desktop.mockReturnValue(true);
|
||||
mocks.migrationStatus.mockResolvedValue(readyStatus);
|
||||
mocks.reloadLocale.mockResolvedValue(undefined);
|
||||
businessReady = deferred<void>();
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
vi.doMock("../App.vue", async () => {
|
||||
mocks.appImported();
|
||||
await businessReady.promise;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["startupAuthentication"],
|
||||
setup: (props) => () => h("main", { "data-business-app": "" }, JSON.stringify(props.startupAuthentication ?? null)),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("@/components/auth/LoginPage.vue", () => ({
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["setupMode"],
|
||||
emits: ["authenticated"],
|
||||
setup:
|
||||
(props, { emit }) =>
|
||||
() =>
|
||||
h("button", { "data-login": "", onClick: () => emit("authenticated") }, props.setupMode ? "setup" : "login"),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/components/migration/SecurityMigrationWizard.vue", () => ({
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["store"],
|
||||
setup: (props) => () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
"data-migration": "",
|
||||
onClick: async () => {
|
||||
await props.store.start();
|
||||
props.store.enter();
|
||||
},
|
||||
},
|
||||
"migrate",
|
||||
),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount();
|
||||
mountedApp = undefined;
|
||||
root.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function mountGate(localeReady?: Promise<void>) {
|
||||
const { default: Gate } = await import("../StartupGate.vue");
|
||||
mountedApp = createApp(Gate, { localeReady });
|
||||
mountedApp.config.errorHandler = vi.fn();
|
||||
mountedApp.mount(root);
|
||||
}
|
||||
|
||||
describe("startup boundary", () => {
|
||||
it("checks migration while the locale loads and keeps feedback until the app module resolves", async () => {
|
||||
const locale = deferred<void>();
|
||||
await mountGate(locale.promise);
|
||||
await vi.waitFor(() => expect(mocks.migrationStatus).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
|
||||
locale.resolve();
|
||||
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
|
||||
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
|
||||
expect(root.querySelector("main")).toBeNull();
|
||||
businessReady.resolve();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-business-app]")).not.toBeNull());
|
||||
expect(root.querySelector("[data-startup-loading]")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the checked Web authentication state without an extra request", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const authentication = { required: true, authenticated: true, setup_required: false };
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => authentication });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(JSON.parse(root.querySelector("main")!.textContent!)).toEqual(authentication);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("does not inspect protected data until login/setup completes (setup=%s)", async (setup) => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: false, setup_required: setup }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: true }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-login]")).not.toBeNull());
|
||||
expect(root.querySelector("[data-login]")!.textContent).toBe(setup ? "setup" : "login");
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
(root.querySelector("[data-login]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not load the business app until a required migration is completed and entered", async () => {
|
||||
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
|
||||
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-migration]")).not.toBeNull());
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
(root.querySelector("[data-migration]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.migrationStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed on malformed auth and allows retry", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => null })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: false, authenticated: false }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
root.querySelector("button")!.click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("retries a failed locale load without rerunning successful security checks", async () => {
|
||||
const locale = deferred<void>();
|
||||
businessReady.resolve();
|
||||
await mountGate(locale.promise);
|
||||
locale.reject(new Error("locale download failed"));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
root.querySelector("button")!.click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.reloadLocale).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a safe retry state when the app module fails to load", async () => {
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
|
||||
businessReady.reject(new Error("private module URL"));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(root.textContent).toContain("startup.loadFailed");
|
||||
expect(root.textContent).not.toContain("private module URL");
|
||||
expect(root.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cancels pending authentication when the gate unmounts", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await mountGate();
|
||||
await nextTick();
|
||||
const signal = fetchMock.mock.calls[0]?.[1].signal as AbortSignal;
|
||||
mountedApp!.unmount();
|
||||
mountedApp = undefined;
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { useResultViewUpdateTiming } from "@/composables/useResultViewUpdateTiming";
|
||||
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, ref, shallowRef, toRaw, useSlots, watch, defineAsyncComponent, type Component, type CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
|
||||
@@ -61,7 +61,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
|
||||
@@ -93,6 +92,7 @@ function preloadDataGridComponent() {
|
||||
void loadDataGridComponent();
|
||||
}
|
||||
|
||||
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
|
||||
const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{ label?: string; error?: unknown; retry?: () => void }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function retry() {
|
||||
if (props.retry) props.retry();
|
||||
else window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-startup-loading class="fixed inset-0 z-[1100] flex flex-col items-center justify-center gap-4 bg-background text-foreground" :role="error ? 'alert' : 'status'" aria-live="polite" :aria-busy="!error">
|
||||
<span class="text-xl font-semibold tracking-tight" aria-hidden="true">DBX</span>
|
||||
<span v-if="!error" class="startup-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-muted-foreground">{{ label || t(error ? "startup.loadFailed" : "startup.loading") }}</p>
|
||||
<button v-if="error" class="rounded border border-border px-4 py-2 text-sm hover:bg-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" @click="retry">{{ t("common.retry") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--muted);
|
||||
border-top-color: var(--muted-foreground);
|
||||
border-radius: 50%;
|
||||
animation: startup-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes startup-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.startup-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2932,6 +2932,10 @@ export default {
|
||||
legacy: "Backend returned an error.",
|
||||
unknown: "An unexpected backend error occurred.",
|
||||
},
|
||||
startup: {
|
||||
loading: "Starting DBX…",
|
||||
loadFailed: "Unable to load DBX. Please try again.",
|
||||
},
|
||||
common: {
|
||||
fileNotFound: "File does not exist: {path}",
|
||||
language: "Language",
|
||||
|
||||
@@ -2844,6 +2844,10 @@ export default withEnglishFallback({
|
||||
legacy: "后端返回了错误。",
|
||||
unknown: "发生了未预期的后端错误。",
|
||||
},
|
||||
startup: {
|
||||
loading: "正在启动 DBX…",
|
||||
loadFailed: "无法加载 DBX,请重试。",
|
||||
},
|
||||
common: {
|
||||
fileNotFound: "文件不存在:{path}",
|
||||
language: "语言",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkStartupAuthentication } from "@/lib/startup/startupAuthentication";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("startup authentication", () => {
|
||||
it("preserves authenticated, disabled-auth and setup-required states", async () => {
|
||||
for (const state of [
|
||||
{ required: true, authenticated: true, setup_required: false },
|
||||
{ required: false, authenticated: false, setup_required: false },
|
||||
{ required: true, authenticated: false, setup_required: true },
|
||||
]) {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => state });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await checkStartupAuthentication()).toEqual(state);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/auth/check", expect.objectContaining({ credentials: "same-origin" }));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([null, [], "invalid", {}, { required: "false", authenticated: true }, { required: false, authenticated: "true" }])("fails closed for malformed authentication data: %j", async (state) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => state }));
|
||||
await expect(checkStartupAuthentication()).rejects.toThrow("AUTH_CHECK_FAILED");
|
||||
});
|
||||
|
||||
it("fails closed on unsuccessful responses and forwards cancellation", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const controller = new AbortController();
|
||||
await expect(checkStartupAuthentication(controller.signal)).rejects.toThrow("AUTH_CHECK_FAILED");
|
||||
expect(fetchMock.mock.calls[0]?.[1].signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("startup phase marks", () => {
|
||||
it("records each phase once without confusing gate mount with app readiness", () => {
|
||||
const entries = new Set<string>();
|
||||
const mark = vi.fn((name: string) => entries.add(name));
|
||||
vi.stubGlobal("performance", { mark, getEntriesByName: (name: string) => (entries.has(name) ? [{}] : []) });
|
||||
markStartupPhase("gate-mounted");
|
||||
markStartupPhase("gate-mounted");
|
||||
expect(mark).toHaveBeenCalledTimes(1);
|
||||
expect(entries.has("dbx:startup:app-mounted")).toBe(false);
|
||||
markStartupPhase("app-mounted");
|
||||
expect(mark).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not block startup when performance marks are unavailable", () => {
|
||||
vi.stubGlobal("performance", {});
|
||||
expect(() => markStartupPhase("bootstrap")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,7 @@ interface InitializeOpenTabsOptions {
|
||||
}
|
||||
|
||||
export async function initializeOpenTabs({ initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeOpenTabsOptions): Promise<void> {
|
||||
try {
|
||||
await initializeOptionalState();
|
||||
} catch (error) {
|
||||
onOptionalStateError(error);
|
||||
}
|
||||
void Promise.resolve().then(initializeOptionalState).catch(onOptionalStateError);
|
||||
await restoreOpenTabs();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
settingsImported: vi.fn(),
|
||||
migrationStatus: vi.fn(),
|
||||
listInstalledAgents: vi.fn(),
|
||||
installAgent: vi.fn(),
|
||||
upgradeAllAgents: vi.fn(),
|
||||
reinstallJre: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@/lib/backend/tauri", () => mocks);
|
||||
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
|
||||
|
||||
const settings = { editorSettings: { updateDownloadSource: "github" } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
settings.editorSettings.updateDownloadSource = "github";
|
||||
vi.doMock("@/stores/settingsStore", () => {
|
||||
mocks.settingsImported();
|
||||
return { useSettingsStore: () => settings };
|
||||
});
|
||||
});
|
||||
|
||||
describe("startup API dependency boundary", () => {
|
||||
it("does not import the business settings store for migration preflight", async () => {
|
||||
mocks.migrationStatus.mockResolvedValue({ state: "not_required" });
|
||||
const api = await import("../api");
|
||||
await expect(api.migrationStatus()).resolves.toEqual({ state: "not_required" });
|
||||
expect(mocks.settingsImported).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues using the current configured source for agent operations", async () => {
|
||||
const api = await import("../api");
|
||||
await api.listInstalledAgents();
|
||||
expect(mocks.listInstalledAgents).toHaveBeenCalledWith("github");
|
||||
settings.editorSettings.updateDownloadSource = "cloudflare";
|
||||
await api.installAgent("mysql", "install-1");
|
||||
await api.upgradeAllAgents("upgrade-1");
|
||||
await api.reinstallJre("java-21", "jre-1");
|
||||
expect(mocks.installAgent).toHaveBeenCalledWith("mysql", "cloudflare", "install-1");
|
||||
expect(mocks.upgradeAllAgents).toHaveBeenCalledWith("cloudflare", "upgrade-1");
|
||||
expect(mocks.reinstallJre).toHaveBeenCalledWith("java-21", "cloudflare", "jre-1");
|
||||
expect(mocks.settingsImported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type * as TauriModule from "@/lib/backend/tauri";
|
||||
import { appendDebugLog } from "@/lib/backend/debugLog";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { AiConfigItem } from "@/types/ai";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,6 +134,7 @@ export const uninstallJdbcPlugin = forward("uninstallJdbcPlugin");
|
||||
export const listInstalledAgentsLocal = forward("listInstalledAgentsLocal");
|
||||
export async function listInstalledAgents() {
|
||||
const backend = await getBackend();
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
return backend.listInstalledAgents(useSettingsStore().editorSettings.updateDownloadSource);
|
||||
}
|
||||
export const isAgentInstalled = forward("isAgentInstalled");
|
||||
@@ -145,10 +145,12 @@ export const stopDriverRuntime = forward("stopDriverRuntime");
|
||||
export const restartDriverRuntime = forward("restartDriverRuntime");
|
||||
export async function installAgent(dbType: string, operationId?: string) {
|
||||
const backend = await getBackend();
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource, operationId);
|
||||
}
|
||||
export async function upgradeAllAgents(operationId?: string) {
|
||||
const backend = await getBackend();
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource, operationId);
|
||||
}
|
||||
export const cancelAgentInstall = forward("cancelAgentInstall");
|
||||
@@ -168,6 +170,7 @@ export const importAgentDriver = forward("importAgentDriver");
|
||||
export const importAgentJar = importAgentDriver;
|
||||
export async function reinstallJre(jreKey?: string, operationId?: string) {
|
||||
const backend = await getBackend();
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource, operationId);
|
||||
}
|
||||
export const uninstallJre = forward("uninstallJre");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiUrl } from "@/lib/common/webPath";
|
||||
|
||||
export interface StartupAuthentication {
|
||||
required: boolean;
|
||||
authenticated: boolean;
|
||||
setup_required: boolean;
|
||||
}
|
||||
|
||||
export async function checkStartupAuthentication(signal?: AbortSignal): Promise<StartupAuthentication> {
|
||||
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin", signal });
|
||||
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
|
||||
const result: unknown = await response.json();
|
||||
if (!result || typeof result !== "object" || !("required" in result) || !("authenticated" in result) || typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") {
|
||||
throw new Error("AUTH_CHECK_FAILED");
|
||||
}
|
||||
return {
|
||||
required: result.required,
|
||||
authenticated: result.authenticated,
|
||||
setup_required: "setup_required" in result && result.setup_required === true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type StartupPhase = "bootstrap" | "gate-mounted" | "locale-ready" | "auth-ready" | "migration-ready" | "app-mounted" | "settings-ready" | "connections-ready" | "tabs-restored";
|
||||
|
||||
export function markStartupPhase(phase: StartupPhase): void {
|
||||
const name = `dbx:startup:${phase}`;
|
||||
const clock = globalThis.performance;
|
||||
if (typeof clock?.mark !== "function" || typeof clock.getEntriesByName !== "function") return;
|
||||
if (!clock.getEntriesByName(name, "mark").length) clock.mark(name);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import VueVirtualScroller from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import "./styles/globals.css";
|
||||
import { installDebugLogCapture } from "@/lib/backend/debugLog";
|
||||
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
|
||||
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { applyLegacyWebViewClass } from "@/lib/ui/legacyWebView";
|
||||
|
||||
function startupErrorMessage(error: unknown): string {
|
||||
@@ -76,19 +75,17 @@ function installGlobalInputAttrs() {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
markStartupPhase("bootstrap");
|
||||
console.log("[STARTUP] frontend bootstrap begin");
|
||||
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./StartupGate.vue")]);
|
||||
console.log("[STARTUP] frontend modules loaded");
|
||||
await loadSavedLocale();
|
||||
console.log("[STARTUP] locale ready");
|
||||
|
||||
const app = createApp(App);
|
||||
const localeReady = loadSavedLocale();
|
||||
const app = createApp(App, { localeReady });
|
||||
app.use(createPinia());
|
||||
app.use(i18n);
|
||||
app.use(VueVirtualScroller);
|
||||
app.mount("#root");
|
||||
clearStartupPreloadRetry();
|
||||
window.dispatchEvent(new Event("dbx:startup-ready"));
|
||||
markStartupPhase("gate-mounted");
|
||||
void localeReady.then(() => window.dispatchEvent(new Event("dbx:startup-ready"))).catch(() => {});
|
||||
console.log("[STARTUP] vue mounted");
|
||||
|
||||
installGlobalInputAttrs();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSettingsStore } from "../settingsStore";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
loadAiConfigs: vi.fn(),
|
||||
loadAiChatSelection: vi.fn(),
|
||||
loadAiConfig: vi.fn(),
|
||||
loadAiProviderConfigs: vi.fn(),
|
||||
saveAiChatSelection: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
backend.loadAiChatSelection.mockResolvedValue(null);
|
||||
backend.loadAiConfig.mockResolvedValue(null);
|
||||
backend.loadAiProviderConfigs.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("AI startup initialization", () => {
|
||||
it("shares pending initialization between startup and the AI panel", async () => {
|
||||
let resolveConfigs!: (configs: []) => void;
|
||||
backend.loadAiConfigs.mockImplementation(
|
||||
() =>
|
||||
new Promise<[]>((resolve) => {
|
||||
resolveConfigs = resolve;
|
||||
}),
|
||||
);
|
||||
const store = useSettingsStore();
|
||||
const first = store.initAiConfigs();
|
||||
const second = store.initAiConfigs();
|
||||
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
|
||||
expect(store.isAiConfigLoaded).toBe(false);
|
||||
resolveConfigs([]);
|
||||
await Promise.all([first, second]);
|
||||
expect(store.isAiConfigLoaded).toBe(true);
|
||||
expect(backend.loadAiChatSelection).toHaveBeenCalledTimes(1);
|
||||
await store.initAiConfigs();
|
||||
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("permits retry after a shared initialization failure", async () => {
|
||||
backend.loadAiConfigs.mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue([]);
|
||||
const store = useSettingsStore();
|
||||
const pending = [store.initAiConfigs(), store.initAiConfigs()];
|
||||
const results = await Promise.allSettled(pending);
|
||||
expect(results.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(store.isAiConfigLoaded).toBe(false);
|
||||
await store.initAiConfigs();
|
||||
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(2);
|
||||
expect(store.isAiConfigLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN,
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy";
|
||||
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
|
||||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
|
||||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlSnippetTemplates";
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { canonicalSqlShortcutSql, DEFAULT_SQL_SHORTCUTS, deriveSqlShortcutDatabaseTypes, mergeDefaultSqlShortcuts, normalizeSqlShortcutDatabaseTypes, normalizeSqlShortcutKind, normalizeSqlShortcutLimit, normalizeSqlShortcutSqlByDatabaseType } from "@/lib/sql/sqlShortcutActions";
|
||||
import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax";
|
||||
@@ -1963,6 +1963,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const isMcpGlobalPolicyLoaded = ref(false);
|
||||
const isEditorSettingsLoaded = ref(false);
|
||||
let initEditorSettingsPromise: Promise<void> | null = null;
|
||||
let initAiConfigsPromise: Promise<void> | null = null;
|
||||
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
|
||||
let editorSettingsOperationQueue: Promise<void> | null = null;
|
||||
let editorSettingsPatchRevision = 0;
|
||||
@@ -2168,7 +2169,15 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
async function initAiConfigs(): Promise<void> {
|
||||
if (isAiConfigLoaded.value) return;
|
||||
if (!initAiConfigsPromise) {
|
||||
initAiConfigsPromise = loadAiConfigurationState().finally(() => {
|
||||
initAiConfigsPromise = null;
|
||||
});
|
||||
}
|
||||
await initAiConfigsPromise;
|
||||
}
|
||||
|
||||
async function loadAiConfigurationState(): Promise<void> {
|
||||
// 尝试加载新格式
|
||||
const newConfigs = await api.loadAiConfigs();
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"bench:redis-key-search": "node scripts/bench/redis-key-search.mjs",
|
||||
"bench:mongodb-count": "node scripts/bench/mongodb-count.mjs",
|
||||
"bench:sidebar-large-schema": "vitest bench apps/desktop/src/lib/sidebar/sidebarLargeSchema.bench.ts",
|
||||
"bench:startup": "node --import tsx scripts/bench/startup-bundle.mjs",
|
||||
"bench:table-import": "cargo run -p dbx-core --no-default-features --example table_import_bench --release --",
|
||||
"lint": "oxlint --vue-plugin apps/desktop/src",
|
||||
"fmt": "oxfmt \"apps/desktop/src/**/*.{ts,vue}\"",
|
||||
|
||||
+66
-5
@@ -7,6 +7,10 @@ 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[] = [];
|
||||
let reportError!: () => void;
|
||||
const errorReported = new Promise<void>((resolve) => {
|
||||
reportError = resolve;
|
||||
});
|
||||
|
||||
await initializeOpenTabs({
|
||||
initializeOptionalState: async () => {
|
||||
@@ -18,10 +22,18 @@ test("web startup reports optional initialization failure and continues restorin
|
||||
events.push("initialize-connections");
|
||||
events.push("restore-tabs");
|
||||
},
|
||||
onOptionalStateError: () => events.push("report-ai-config-error"),
|
||||
onOptionalStateError: () => {
|
||||
events.push("report-ai-config-error");
|
||||
reportError();
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(events, ["initialize-ai-configs", "report-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs"]);
|
||||
await errorReported;
|
||||
assert.deepEqual(
|
||||
events.filter((event) => !event.includes("ai-config")),
|
||||
["initialize-editor-settings", "initialize-connections", "restore-tabs"],
|
||||
);
|
||||
assert.ok(events.includes("report-ai-config-error"));
|
||||
});
|
||||
|
||||
test("shared startup initializes optional state once and propagates required restoration failure", async () => {
|
||||
@@ -64,10 +76,11 @@ test("web startup uses the guarded initializer behind the existing authenticatio
|
||||
assert.ok(mountedStart >= 0 && mountedEnd > mountedStart);
|
||||
|
||||
const mountedSource = appSource.slice(mountedStart, mountedEnd);
|
||||
const authCheck = mountedSource.indexOf('fetch(apiUrl("/api/auth/check"))');
|
||||
const cachedAuth = mountedSource.indexOf("if (!startupProps.startupAuthentication)");
|
||||
const authCheck = mountedSource.indexOf("await checkStartupAuthentication()", cachedAuth);
|
||||
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(cachedAuth >= 0 && authCheck > cachedAuth);
|
||||
assert.ok(loginRedirect > authCheck);
|
||||
assert.ok(authenticatedInitialization > loginRedirect);
|
||||
});
|
||||
@@ -96,7 +109,55 @@ test("desktop SQL file opening survives unrelated initialization failure and fol
|
||||
});
|
||||
await fileOpen;
|
||||
|
||||
assert.deepEqual(events, ["initialize-ai-configs", "ignore-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"]);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
events.filter((event) => !event.includes("ai-config")),
|
||||
["initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"],
|
||||
);
|
||||
assert.ok(events.includes("ignore-ai-config-error"));
|
||||
});
|
||||
|
||||
test("pending optional AI initialization does not hold tab restoration or external file opening", async () => {
|
||||
let rejectOptional!: (error: Error) => void;
|
||||
const optional = new Promise<void>((_resolve, reject) => {
|
||||
rejectOptional = reject;
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
const barrier = createOpenTabsRestorationBarrier();
|
||||
const events: string[] = [];
|
||||
const fileOpen = barrier.settled.then(() => events.push("open-file"));
|
||||
await initializeDesktopOpenTabs({
|
||||
barrier,
|
||||
initializeOptionalState: () => optional,
|
||||
restoreOpenTabs: async () => {
|
||||
events.push("restore-tabs");
|
||||
},
|
||||
onOptionalStateError: (error) => errors.push(error),
|
||||
});
|
||||
await fileOpen;
|
||||
assert.deepEqual(events, ["restore-tabs", "open-file"]);
|
||||
const error = new Error("AI config unavailable");
|
||||
rejectOptional(error);
|
||||
await optional.catch(() => {});
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(errors, [error]);
|
||||
});
|
||||
|
||||
test("a synchronous optional initialization failure does not block required restoration", async () => {
|
||||
const events: string[] = [];
|
||||
await initializeOpenTabs({
|
||||
initializeOptionalState: () => {
|
||||
throw new Error("optional failure");
|
||||
},
|
||||
restoreOpenTabs: async () => {
|
||||
events.push("restored");
|
||||
},
|
||||
onOptionalStateError: () => {
|
||||
events.push("reported");
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(events, ["restored", "reported"]);
|
||||
});
|
||||
|
||||
test("desktop SQL file opening is released when persisted tab restoration rejects", async () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Startup dependency benchmark
|
||||
|
||||
Run `pnpm bench:startup` from the repository root. The benchmark uses the current
|
||||
production Vite configuration, checks generated connection descriptors, and builds
|
||||
in memory without replacing `dist` or writing generated source files.
|
||||
|
||||
The JSON report lists cumulative static JavaScript dependencies for the entry,
|
||||
startup gate, and main application, with English and Simplified Chinese variants.
|
||||
Later phases include the earlier phases; do not add their byte counts together.
|
||||
Gzip sizes are calculated per chunk, not measured HTTP transfer sizes. CSS, images,
|
||||
runtime-selected backend transports, and other dynamic imports are not included.
|
||||
|
||||
The startup gate must not statically depend on the settings/connection/query stores
|
||||
or the SQL editor. Check the `includes` fields to detect a broken lazy boundary.
|
||||
Loading a previously saved SQL tab will still load its editor on demand.
|
||||
|
||||
Runtime phase markers are available through
|
||||
`performance.getEntriesByType("mark").filter(entry => entry.name.startsWith("dbx:startup:"))`.
|
||||
They distinguish gate mounting, locale/auth/migration completion, app mounting,
|
||||
settings, connections, and restored tabs. App mounting is not editor interactivity;
|
||||
the legacy `dbx:startup-ready` input-guard event is not a main-screen readiness metric.
|
||||
|
||||
Validate desktop and Web, returning users and first-run migration, login/setup,
|
||||
slow or failed locale/module loading, optional AI failures, and restored/external
|
||||
SQL tabs. Keep authentication before migration inspection, and editor settings
|
||||
before connections before tab restoration. Bundle reductions are not cold-start
|
||||
timing claims: native setup, storage, WebView parsing and rendering still need
|
||||
separate measurement.
|
||||
@@ -0,0 +1,57 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { build } from "vite";
|
||||
import configFactory from "../../apps/desktop/vite.config.ts";
|
||||
|
||||
const repository = fileURLToPath(new URL("../../", import.meta.url));
|
||||
execFileSync(process.execPath, ["scripts/sync-connection-types.mjs", "--check"], { cwd: repository, stdio: ["ignore", "pipe", "inherit"] });
|
||||
const config = await configFactory({ command: "build", mode: "production" });
|
||||
const result = await build({
|
||||
...config,
|
||||
configFile: false,
|
||||
logLevel: "error",
|
||||
plugins: config.plugins.filter((plugin) => plugin?.name !== "dbx-connection-types"),
|
||||
build: { ...config.build, write: false, emptyOutDir: false, reportCompressedSize: false },
|
||||
});
|
||||
const chunks = (Array.isArray(result) ? result : [result]).flatMap((bundle) => bundle.output).filter((output) => output.type === "chunk");
|
||||
const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
|
||||
const sourceChunk = (suffix) => chunks.find((chunk) => Object.keys(chunk.modules).some((id) => id.endsWith(suffix)));
|
||||
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith("/index.html"));
|
||||
const gate = sourceChunk("/src/StartupGate.vue");
|
||||
const locale = sourceChunk("/src/i18n/index.ts");
|
||||
const app = sourceChunk("/src/App.vue");
|
||||
const chinese = sourceChunk("/src/i18n/locales/zh-CN.ts");
|
||||
if (!entry || !gate || !locale || !app || !chinese) throw new Error("Startup bundle entrypoints were not found");
|
||||
|
||||
function summarize(phase, seeds) {
|
||||
const visited = new Set();
|
||||
function visit(name) {
|
||||
if (visited.has(name) || !byName.has(name)) return;
|
||||
visited.add(name);
|
||||
byName.get(name).imports.forEach(visit);
|
||||
}
|
||||
seeds.forEach((chunk) => visit(chunk.fileName));
|
||||
const dependencies = [...visited].map((name) => byName.get(name));
|
||||
const files = dependencies.map((chunk) => ({ file: chunk.fileName, bytes: Buffer.byteLength(chunk.code), gzipBytes: gzipSync(chunk.code).length }));
|
||||
const modules = dependencies.flatMap((chunk) => Object.keys(chunk.modules));
|
||||
return {
|
||||
phase,
|
||||
jsChunks: files.length,
|
||||
bytes: files.reduce((total, file) => total + file.bytes, 0),
|
||||
gzipBytes: files.reduce((total, file) => total + file.gzipBytes, 0),
|
||||
largestFiles: files.sort((left, right) => right.bytes - left.bytes).slice(0, 8),
|
||||
includes: Object.fromEntries(["settingsStore.ts", "connectionStore.ts", "queryStore.ts", "QueryEditor.vue", "/node_modules/@codemirror/"].map((pattern) => [pattern, modules.some((id) => id.includes(pattern))])),
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
kind: "static-js-dependencies-not-startup-timing",
|
||||
phases: [summarize("entry", [entry]), summarize("startup-gate-en", [entry, gate, locale]), summarize("startup-gate-zh-CN", [entry, gate, locale, chinese]), summarize("app-en", [entry, gate, locale, app]), summarize("app-zh-CN", [entry, gate, locale, app, chinese])],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user