Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions browser_tests/tests/cloud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,25 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
})

test('unknown paths redirect to cloud login instead of hanging on splash screen', async ({
page
}) => {
// Load the SPA at root (the backend serves index.html for /).
// Then push an unknown path client-side so Vue Router handles it.
// The backend serves ComfyUI file responses for arbitrary paths, so a
// direct page.goto('/woiadawd') would trigger a download rather than the SPA.
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })

// Navigate client-side to an unknown path. The catch-all route redirects to /,
// then the auth guard sends unauthenticated users back to /cloud/login.
// Regression: before the catch-all was added, the SPA found no route and
// startStoreBootstrap hung indefinitely on the splash screen.
await page.evaluate(() => {
window.history.pushState(null, '', '/woiadawd')
window.dispatchEvent(new PopStateEvent('popstate'))
})
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
})
6 changes: 5 additions & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ const router = createRouter({
component: () => import('@/views/UserSelectView.vue')
}
]
}
},
// Catch-all: unknown paths redirect to root rather than hanging on the
// splash screen with no route match. The global auth guard then routes
// unauthenticated users to /cloud/login as normal.
{ path: '/:pathMatch(.*)*', redirect: '/' }
],

scrollBehavior(_to, _from, savedPosition) {
Expand Down
76 changes: 66 additions & 10 deletions src/stores/bootstrapStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AxiosResponse } from 'axios'
import { AxiosError } from 'axios'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'

import { mergeCustomNodesI18n } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
Expand Down Expand Up @@ -68,6 +68,16 @@ const mockDistributionTypes = vi.hoisted(() => ({
}))
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)

const mockCaptureException = vi.hoisted(() => vi.fn())
vi.mock('@sentry/vue', () => ({
captureException: mockCaptureException
}))

const mockAddError = vi.hoisted(() => vi.fn())
vi.mock('@datadog/browser-rum', () => ({
datadogRum: { addError: mockAddError }
}))

function requestFailure(status: number) {
const error = new AxiosError(`Request failed with status code ${status}`)
error.response = { status } as AxiosResponse
Expand Down Expand Up @@ -135,31 +145,77 @@ describe('bootstrapStore', () => {
describe('cloud mode', () => {
beforeEach(() => {
mockDistributionTypes.isCloud = true
mockCaptureException.mockReset()
mockAddError.mockReset()
})

it('waits for Firebase auth before loading stores', async () => {
it('waits for Firebase init before loading stores, then proceeds regardless of auth state', async () => {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const bootstrapPromise = store.startStoreBootstrap()

expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)

// Firebase initialized but user not yet authenticated
// Firebase resolves with no user (signed-out) — bootstrap must unblock.
// Previously it also waited for isAuthenticated, which made every
// signed-out load wait 35s and fire a false Sentry timeout.
mockIsAuthInitialized.value = true
await nextTick()

expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)

// User authenticates (e.g. signs in on login page)
mockIsAuthAuthenticated.value = true
await bootstrapPromise

await vi.waitFor(() => {
expect(store.isI18nReady).toBe(true)
expect(settingStore.isReady).toBe(true)
})
})

it('retries once and proceeds if Firebase init resolves during the backoff', async () => {
vi.useFakeTimers()
try {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const bootstrapPromise = store.startStoreBootstrap()

// First wait times out with Firebase still not initialized.
await vi.advanceTimersByTimeAsync(16_001)
expect(settingStore.isReady).toBe(false)

// Firebase resolves during the retry backoff.
mockIsAuthInitialized.value = true
await vi.advanceTimersByTimeAsync(3_001)
await bootstrapPromise

expect(settingStore.isReady).toBe(true)
expect(mockCaptureException).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})

it('gives up after a second timeout, reports it, and continues bootstrap unauthenticated', async () => {
vi.useFakeTimers()
try {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const bootstrapPromise = store.startStoreBootstrap()

// Firebase never resolves through the initial wait, the backoff, or the retry.
await vi.advanceTimersByTimeAsync(16_000 + 3_000 + 16_001)
await bootstrapPromise

expect(mockCaptureException).toHaveBeenCalledOnce()
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), {
tags: { error_type: 'bootstrap_auth_wait_timeout' }
})
expect(mockAddError).toHaveBeenCalledOnce()
expect(mockAddError).toHaveBeenCalledWith(expect.any(Error), {
error_type: 'bootstrap_auth_wait_timeout'
})
// Bootstrap must not stay stuck: stores load even when Firebase never fires.
expect(settingStore.isReady).toBe(true)
} finally {
vi.useRealTimers()
}
})
})
})
62 changes: 59 additions & 3 deletions src/stores/bootstrapStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { datadogRum } from '@datadog/browser-rum'
import { captureException } from '@sentry/vue'
import { until, useAsyncState } from '@vueuse/core'
import axios from 'axios'
import { defineStore, storeToRefs } from 'pinia'
Expand All @@ -23,6 +25,62 @@ async function fetchCustomNodesI18n(): Promise<CustomNodesI18n | undefined> {
}
}

// Matches the Firebase-auth-wait timeout used elsewhere (router.ts,
// WorkspaceAuthGate.vue) so a broken/stale session fails this bounded wait
// on the same schedule those already fail theirs.
const AUTH_WAIT_TIMEOUT_MS = 16_000
const AUTH_WAIT_RETRY_DELAY_MS = 3_000

/**
* Waits for Firebase auth initialization to complete, bounded so a stale
* token or a broken auth response can never hang bootstrap forever.
*
* Only isInitialized is awaited — onAuthStateChanged fires with null for
* signed-out users, which sets isInitialized but not isAuthenticated.
* Awaiting isAuthenticated here would make every signed-out page load wait
* 35s and fire a false Sentry timeout. The router guard handles the
* login redirect for unauthenticated users separately.
*
* Retries once after a short delay; if auth is still unresolved, reports it
* to Sentry and lets bootstrap continue rather than leaving the caller stuck.
*/
async function waitForCloudAuth(): Promise<void> {
const { isInitialized } = storeToRefs(useAuthStore())
const waitForResolution = () =>
until(isInitialized).toBe(true, {
timeout: AUTH_WAIT_TIMEOUT_MS,
throwOnTimeout: true
})

try {
await waitForResolution()
} catch (error) {
console.warn(
'[bootstrapStore] Auth did not resolve in time, retrying once',
error
)
await new Promise((resolve) =>
setTimeout(resolve, AUTH_WAIT_RETRY_DELAY_MS)
)
try {
await waitForResolution()
} catch (retryError) {
console.error(
'[bootstrapStore] Auth still unresolved after retry; continuing bootstrap without confirmed auth',
retryError
)
const err =
retryError instanceof Error ? retryError : new Error(String(retryError))
// Report to both Datadog RUM and Sentry so the error surfaces in
// whichever observability platform is being monitored.
datadogRum.addError(err, { error_type: 'bootstrap_auth_wait_timeout' })
captureException(err, {
tags: { error_type: 'bootstrap_auth_wait_timeout' }
})
}
}
}

export const useBootstrapStore = defineStore('bootstrap', () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
Expand Down Expand Up @@ -52,9 +110,7 @@ export const useBootstrapStore = defineStore('bootstrap', () => {

async function startStoreBootstrap() {
if (isCloud) {
const { isInitialized, isAuthenticated } = storeToRefs(useAuthStore())
await until(isInitialized).toBe(true)
await until(isAuthenticated).toBe(true)
await waitForCloudAuth()
}

const userStore = useUserStore()
Expand Down
Loading