Files
ComfyUI_frontend/src/platform/remoteConfig/refreshRemoteConfig.test.ts
Alexander Piskun c8ed15da31 feat: follow --comfy-api-base for staging and preview backends (#13054)
## Summary

Let the running ComfyUI server decide which backend the web UI talks to
(and which Firebase project it signs you into), so launching with
`--comfy-api-base` just works with the regular bundled frontend.

## Changes

- **What**: At startup the frontend reads `/api/features` on every build
(not just cloud) and treats the server's `comfy_api_base_url` /
`comfy_platform_base_url` as authoritative, falling back to the
build-time defaults.
When that api base is a staging-tier host (staging, or a
`*.testenvs.comfy.org` preview env) and the server hasn't supplied its
own Firebase config, the frontend picks the dev Firebase project,
derived from the api base.
Production is left exactly as it is today.
- `main.ts`: load remote config first thing, before Firebase
initializes, so every module sees the right values from the first render
- `config/comfyApi.ts`: the api/platform getters now read the server's
values on all distributions
- `config/firebase.ts`: `getFirebaseConfig()` resolves in order: a
server-provided config first (cloud), then the dev project for a
staging-tier api base, then the build-time default
- `platform/remoteConfig/refreshRemoteConfig.ts`: the startup fetch now
has a 5s timeout, so a slow or wedged `/features` can never keep the app
from mounting; on failure we fall back to the build-time defaults
- **Breaking**: None. With no `/features` overrides (production and
ordinary self-hosting), behavior is unchanged

## Review Focus

- The precedence in `getFirebaseConfig()` (`config/firebase.ts`): server
config first, then the staging-tier dev project, then the build-time
default. The staging-tier check matches `stagingapi.comfy.org` and any
`*.testenvs.comfy.org` host, and falls back to build-time for anything
it can't parse.
- Running `refreshRemoteConfig()` unconditionally and first in
`main.ts`, with the new fetch timeout as the safety net.

## Testing

I tested every case by hand, locally, on top of the automated checks.
Tested both with `pnpm run build` and `USE_PROD_CONFIG=true pnpm build`
and running Comfy from that folder.

Pointed a local ComfyUI at each backend with `--comfy-api-base` and
signed in with Google each time:

- **Production** (default / `https://api.comfy.org`): stays on
production and signs into the production Firebase project, identical to
today.
- **Staging** (`https://stagingapi.comfy.org`): follows it and signs
into the dev project.
- **Ephemeral preview env** (`https://pr-<n>.testenvs.comfy.org`): the
friendly host is accepted as-is, the frontend follows it, lands in the
dev project, and Google sign-in completes.

The only exception where fronted does not respect the `--comfy-api-base`
is when Comfy runs against `prod` and frontend runs with the `pnpm run
dev` - due to overridden config(this is expected behavior).

Supersedes: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12560
Companion Core PR: https://github.com/Comfy-Org/ComfyUI/pull/14569

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
2026-06-30 05:18:24 +00:00

162 lines
4.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { refreshRemoteConfig } from './refreshRemoteConfig'
import { remoteConfig } from './remoteConfig'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
}
}))
vi.stubGlobal('fetch', vi.fn())
describe('refreshRemoteConfig', () => {
const mockConfig = { feature1: true, feature2: 'value' }
function mockSuccessResponse(config = mockConfig) {
return {
ok: true,
json: async () => config
} as Response
}
function mockErrorResponse(status: number, statusText: string) {
return {
ok: false,
status,
statusText
} as Response
}
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
window.__CONFIG__ = {}
})
describe('with auth (default)', () => {
it('uses api.fetchApi when useAuth is true', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
it('uses api.fetchApi by default', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig()
expect(api.fetchApi).toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
})
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: true })
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
expect(init?.signal).toBeUndefined()
})
})
describe('without auth', () => {
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
expect(api.apiURL).toHaveBeenCalledWith('/features')
expect(global.fetch).toHaveBeenCalledWith(
'/ComfyUI/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(401, 'Unauthorized')
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
it('clears config on 403 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(403, 'Forbidden')
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
it('clears config on fetch error', async () => {
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
it('preserves config on 500 response', async () => {
const existingConfig = { subscription_required: true }
remoteConfig.value = existingConfig
window.__CONFIG__ = existingConfig
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(500, 'Internal Server Error')
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual(existingConfig)
expect(window.__CONFIG__).toEqual(existingConfig)
})
})
})