Compare commits

..

5 Commits

Author SHA1 Message Date
Alexander Brown
b223d8f607 Merge branch 'main' into revert-10247-fix/subgraph-viewport-first-visit 2026-03-31 21:12:53 -07:00
Dante
c77c8a9476 test: migrate fromAny to fromPartial for type-checked test mocks (#10788)
## Summary
- Convert `fromAny` → `fromPartial` in 7 test files where object
literals or interfaces are passed
- `fromPartial` type-checks the provided fields, unlike `fromAny` which
bypasses all checking (same as `as unknown as`)
- Class-based types (`LGraphNode`, `LGraph`) remain `fromAny` due to
shoehorn's `PartialDeep` incompatibility with class constructors

## Changes
- **Pure conversions** (all `fromAny` → `fromPartial`):
`domWidgetZIndex`, `matchPromotedInput`, `promotionUtils`,
`subgraphNavigationStore`
- **Mixed** (some converted, some kept): `promotedWidgetView`,
`widgetUtil`
- **Cleanup**: `nodeOutputStore` type param normalization

Follows up on #10761.

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm vitest run` on all 7 changed files — 169 tests pass
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10788-test-migrate-fromAny-to-fromPartial-for-type-checked-test-mocks-3356d73d365081f7bf61d48a47af530c)
by [Unito](https://www.unito.io)
2026-03-31 21:11:50 -07:00
Alexander Brown
a75e4ddbc8 Revert "fix: persist subgraph viewport across navigation and tab switches (#1…"
This reverts commit 7e7e2d5647.
2026-03-31 21:08:18 -07:00
Dante
380fae9a0d chore(test): remove dead QueueHelper from browser tests (#10771)
## Summary
- Remove unused `QueueHelper` class and its `comfyPage.queue` property
- `QueueHelper` mocks the legacy `/api/queue` tuple format which the app
no longer uses (now `/api/jobs` via `fetchQueue()`)
- `comfyPage.queue.*` is never called in any test

Fixes #10670

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10771-chore-test-remove-dead-QueueHelper-from-browser-tests-3346d73d36508117bb19db9492bcbed3)
by [Unito](https://www.unito.io)
2026-03-31 19:55:52 +09:00
pythongosssss
515f234143 fix: Ensure all save/save as buttons are the same width (#10681)
## Summary

Makes the save/save as buttons in the builder footer toolbar all a fixed
size so when switching states the elements dont jump

## Changes

- **What**: 
- Apply widths from design to the buttons
- Add tests that measure the sizes of the buttons

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10681-fix-Ensure-all-save-save-as-buttons-are-the-same-width-3316d73d36508187bb74c5a977ea876f)
by [Unito](https://www.unito.io)
2026-03-31 02:47:27 -07:00
35 changed files with 533 additions and 769 deletions

View File

@@ -33,7 +33,6 @@ import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
import { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper'
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
@@ -200,7 +199,6 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -248,7 +246,6 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.queue = new QueueHelper(page)
}
get visibleToasts() {

View File

@@ -30,6 +30,10 @@ export class BuilderFooterHelper {
return this.page.getByTestId(TestIds.builder.saveButton)
}
get saveGroup(): Locator {
return this.page.getByTestId(TestIds.builder.saveGroup)
}
get saveAsButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveAsButton)
}

View File

@@ -1,79 +0,0 @@
import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
constructor(private readonly page: Page) {}
/**
* Mock the /api/queue endpoint to return specific queue state.
*/
async mockQueueState(
running: number = 0,
pending: number = 0
): Promise<void> {
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: Array.from({ length: running }, (_, i) => [
i,
`running-${i}`,
{},
{},
[]
]),
queue_pending: Array.from({ length: pending }, (_, i) => [
i,
`pending-${i}`,
{},
{},
[]
])
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
}
/**
* Mock the /api/history endpoint with completed/failed job entries.
*/
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
prompt: [0, job.promptId, {}, {}, []],
outputs: {},
status: {
status_str: job.status === 'success' ? 'success' : 'error',
completed: true
}
}
}
this.historyRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
}
/**
* Clear all route mocks set by this helper.
*/
async clearMocks(): Promise<void> {
if (this.queueRouteHandler) {
await this.page.unroute('**/api/queue', this.queueRouteHandler)
this.queueRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
}
}

View File

@@ -82,6 +82,7 @@ export const TestIds = {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveGroup: 'builder-save-group',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',

View File

@@ -61,7 +61,10 @@ async function addNode(page: Page, nodeType: string): Promise<string> {
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.__setServerCapability!('linear_toggle_enabled', true)
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -10,7 +10,10 @@ import {
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.__setServerCapability!('linear_toggle_enabled', true)
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(

View File

@@ -58,7 +58,10 @@ async function reSaveAs(
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.__setServerCapability!('linear_toggle_enabled', true)
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
@@ -186,6 +189,41 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(saveAs.nameInput).toBeVisible()
})
test('Save button width is consistent across all states', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// State 1: Disabled "Save as" (no outputs selected)
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(disabledBox).toBeTruthy()
// Select I/O to enable the button
await appMode.steps.goToInputs()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await appMode.select.selectInputWidget(ksampler)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(enabledBox).toBeTruthy()
expect(enabledBox!.width).toBe(disabledBox!.width)
// Save the workflow to transition to the Save + chevron state
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
// State 3: Save + chevron button group (saved workflow)
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
expect(saveButtonGroupBox).toBeTruthy()
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {

View File

@@ -37,7 +37,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
const flags = window.app?.api?.serverFeatureFlags
const flags = window.app?.api?.serverFeatureFlags?.value
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
clearInterval(checkInterval)
@@ -93,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags
return window.app!.api.serverFeatureFlags.value
})
// Verify we received real feature flags from the backend
@@ -126,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window.app!.api.serverFeatureFlags
window.app!.api.serverFeatureFlags = {
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -144,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
// Restore original
window.app!.api.serverFeatureFlags = original
window.app!.api.serverFeatureFlags.value = original
return results
})
@@ -279,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
) {
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
@@ -317,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
{
timeout: 10000
}
@@ -328,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags
currentFlags: window.app!.api.serverFeatureFlags.value
}
})

View File

@@ -7,9 +7,14 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Enable the essentials feature flag via runtime capability override.
// Enable the essentials feature flag via the reactive serverFeatureFlags ref.
// In production, this flag comes via WebSocket or remoteConfig (cloud only).
// The localhost test server has neither, so we set it directly.
await comfyPage.page.evaluate(() => {
window.__setServerCapability!('node_library_essentials_enabled', true)
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
node_library_essentials_enabled: true
}
})
// Register a mock essential node so the essentials tab has content.

View File

@@ -38,9 +38,6 @@ declare global {
changeCount?: number
widgetValue?: unknown
// Server capabilities runtime override (exposed from main.ts)
__setServerCapability?: (key: string, value: unknown) => void
// Feature flags test globals
__capturedMessages?: CapturedMessages
__appReadiness?: AppReadiness

View File

@@ -33,76 +33,91 @@
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<div class="relative min-w-24">
<!--
Invisible sizers: both labels rendered with matching button padding
so the container's intrinsic width equals the wider label.
height:0 + overflow:hidden keeps them invisible without affecting height.
-->
<div class="max-h-0 overflow-y-hidden" aria-hidden="true">
<div class="px-4 py-2 text-sm">{{ t('g.save') }}</div>
<div class="px-4 py-2 text-sm">{{ t('builderToolbar.saveAs') }}</div>
</div>
<ConnectOutputPopover
v-if="!hasOutputs"
class="w-full"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button
size="lg"
class="w-full"
:class="disabledSaveClasses"
data-testid="builder-save-as-button"
>
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
data-testid="builder-save-group"
class="w-full rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
data-testid="builder-save-button"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
data-testid="builder-save-as-chevron"
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button
v-else
size="lg"
:class="cn('w-24', disabledSaveClasses)"
class="w-full"
:class="activeSaveClasses"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
{{ t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
data-testid="builder-save-button"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
data-testid="builder-save-as-chevron"
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button
v-else
size="lg"
:class="activeSaveClasses"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</div>
</nav>
</div>
</template>
@@ -126,8 +141,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import ConnectOutputPopover from './ConnectOutputPopover.vue'

View File

@@ -1,4 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
first.order = 0
second.order = 1
const nodes = fromAny<{ _nodes: LGraphNode[] }, unknown>(graph)._nodes
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
nodes.splice(nodes.indexOf(first), 1)
nodes.push(first)

View File

@@ -6,12 +6,16 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import * as serverCapabilities from '@/services/serverCapabilities'
import { api } from '@/scripts/api'
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
// Mock the distribution types module
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isNightly: false
@@ -31,7 +35,7 @@ describe('useFeatureFlags', () => {
})
it('should access supportsPreviewMetadata', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) return true
return defaultValue
@@ -40,29 +44,28 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA,
false
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
it('should access maxUploadSize', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200 // 200MB
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})
it('should access supportsManagerV4', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true
return defaultValue
@@ -71,27 +74,26 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4,
false
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
)
})
it('should return defaults when features are not available', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(false)
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.maxUploadSize).toBeUndefined()
expect(flags.supportsManagerV4).toBe(false)
expect(flags.supportsManagerV4).toBeUndefined()
})
})
describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value'
return defaultValue
@@ -102,14 +104,14 @@ describe('useFeatureFlags', () => {
const customFlag = featureFlag('custom.feature', 'default')
expect(customFlag.value).toBe('custom-value')
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
expect(api.getServerFeature).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})
it('should handle nested paths', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true
return defaultValue
@@ -123,7 +125,7 @@ describe('useFeatureFlags', () => {
})
it('should work with ServerFeatureFlag enum', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 104857600
return defaultValue
@@ -143,12 +145,12 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(serverCapabilities.getServerCapability).not.toHaveBeenCalled()
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('should check remote config and server capability when isNightly is false', () => {
it('should check remote config and server feature when isNightly is false', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.LINEAR_TOGGLE_ENABLED) return true
return defaultValue
@@ -157,7 +159,7 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
false
)
@@ -165,7 +167,7 @@ describe('useFeatureFlags', () => {
it('should return false when isNightly is false and flag is disabled', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
@@ -180,7 +182,7 @@ describe('useFeatureFlags', () => {
})
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(false)
vi.mocked(api.getServerFeature).mockReturnValue(false)
localStorage.setItem('ff:model_upload_button_enabled', 'true')
const { flags } = useFeatureFlags()
@@ -188,7 +190,7 @@ describe('useFeatureFlags', () => {
})
it('resolveFlag falls through to server when no override is set', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
return defaultValue
@@ -199,14 +201,12 @@ describe('useFeatureFlags', () => {
expect(flags.assetRenameEnabled).toBe(true)
})
it('direct server flags use getServerCapability which handles override', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
}
)
it('direct server flags delegate override to api.getServerFeature', () => {
vi.mocked(api.getServerFeature).mockImplementation((path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
})
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe('overridden')

View File

@@ -5,7 +5,7 @@ import {
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { getServerCapability } from '@/services/serverCapabilities'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
/**
@@ -30,7 +30,7 @@ export enum ServerFeatureFlag {
}
/**
* Resolves a feature flag value with dev override > remoteConfig > serverCapability priority.
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
*/
function resolveFlag<T>(
flagKey: string,
@@ -39,29 +39,23 @@ function resolveFlag<T>(
): T {
const override = getDevOverride<T>(flagKey)
if (override !== undefined) return override
return remoteConfigValue ?? getServerCapability(flagKey, defaultValue)
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Composable for reactive access to feature flags
* Composable for reactive access to server-side feature flags
*/
export function useFeatureFlags() {
const flags = reactive({
// Direct server-only flags — resolved via getServerCapability() only
get supportsPreviewMetadata() {
return getServerCapability(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA,
false
)
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
},
get maxUploadSize() {
return getServerCapability(ServerFeatureFlag.MAX_UPLOAD_SIZE)
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
},
get supportsManagerV4() {
return getServerCapability(ServerFeatureFlag.MANAGER_SUPPORTS_V4, false)
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
// Flags with remoteConfig override — resolved via resolveFlag()
get modelUploadButtonEnabled() {
return resolveFlag(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
@@ -90,7 +84,6 @@ export function useFeatureFlags() {
false
)
},
// Flags with extra conditional logic (isNightly/isCloud guards)
get linearToggleEnabled() {
if (isNightly) return true
@@ -117,7 +110,7 @@ export function useFeatureFlags() {
return (
remoteConfig.value.team_workspaces_enabled ??
getServerCapability(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
get userSecretsEnabled() {
@@ -127,16 +120,15 @@ export function useFeatureFlags() {
false
)
},
// Direct server-only flags with defaults
get nodeReplacementsEnabled() {
return getServerCapability(ServerFeatureFlag.NODE_REPLACEMENTS, false)
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true
return (
remoteConfig.value.node_library_essentials_enabled ??
getServerCapability(
api.getServerFeature(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
@@ -168,7 +160,7 @@ export function useFeatureFlags() {
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => getServerCapability(featurePath, defaultValue))
computed(() => api.getServerFeature(featurePath, defaultValue))
return {
flags: readonly(flags),

View File

@@ -1,4 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -31,12 +31,11 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromAny<
fromPartial<
Array<{
name: string
_widget?: IBaseWidget
}>,
unknown
}>
>([aliasInput, exactInput]),
targetWidget
)
@@ -51,9 +50,7 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromAny<Array<{ name: string; _widget?: IBaseWidget }>, unknown>([
aliasInput
]),
fromPartial<Array<{ name: string; _widget?: IBaseWidget }>>([aliasInput]),
targetWidget
)
@@ -70,12 +67,11 @@ describe(matchPromotedInput, () => {
}
const matched = matchPromotedInput(
fromAny<
fromPartial<
Array<{
name: string
_widget?: IBaseWidget
}>,
unknown
}>
>([firstAliasInput, secondAliasInput]),
targetWidget
)

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -293,7 +293,7 @@ describe(createPromotedWidgetView, () => {
value: 'initial',
options: {}
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
const fallbackWidget = fromAny<IBaseWidget, unknown>(fallbackWidgetShape)
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,7 +30,7 @@ function widget(
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromAny<IBaseWidget, unknown>({ name: 'widget', ...overrides })
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
describe('isPreviewPseudoWidget', () => {

View File

@@ -18,10 +18,6 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import {
initServerCapabilities,
setServerCapability
} from '@/services/serverCapabilities'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -29,9 +25,6 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
await initServerCapabilities()
window.__setServerCapability = setServerCapability
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization

View File

@@ -16,10 +16,6 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
export const isDesktop = DISTRIBUTION === 'desktop'
export const isCloud = DISTRIBUTION === 'cloud'
export function getApiBase(): string {
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
}
/**
* Whether this is a nightly build (from main branch).
* Nightly builds may show experimental features and surveys.

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import * as serverCapabilities from '@/services/serverCapabilities'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
import { useNodeReplacementStore } from './nodeReplacementStore'
@@ -17,8 +17,10 @@ vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
function mockSettingStore(enabled: boolean) {
@@ -36,7 +38,7 @@ function mockSettingStore(enabled: boolean) {
function createStore(settingEnabled = true, serverFeatureEnabled = true) {
setActivePinia(createPinia())
mockSettingStore(settingEnabled)
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
vi.mocked(api.getServerFeature).mockImplementation(
(flag: string, defaultValue?: unknown) => {
if (flag === ServerFeatureFlag.NODE_REPLACEMENTS) {
return serverFeatureEnabled

View File

@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getServerCapability } from '@/services/serverCapabilities'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
@@ -18,7 +18,8 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
async function load() {
if (!isEnabled.value || isLoaded.value) return
if (!getServerCapability(ServerFeatureFlag.NODE_REPLACEMENTS, false)) return
if (!api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false))
return
try {
replacements.value = await fetchNodeReplacements()

View File

@@ -23,7 +23,6 @@ import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
@@ -392,9 +391,6 @@ export const useWorkflowService = () => {
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
// Save subgraph viewport before the canvas gets overwritten
useSubgraphNavigationStore().saveCurrentViewport()
}
}

View File

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { api } from '@/scripts/api'
@@ -38,7 +39,7 @@ describe('API Feature Flags', () => {
})
// Reset API state
api.serverFeatureFlags = {}
api.serverFeatureFlags.value = {}
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
@@ -102,7 +103,7 @@ describe('API Feature Flags', () => {
await initPromise
// Check that server features were stored
expect(api.serverFeatureFlags).toEqual({
expect(api.serverFeatureFlags.value).toEqual({
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
@@ -144,14 +145,14 @@ describe('API Feature Flags', () => {
await initPromise
// Server features should remain empty
expect(api.serverFeatureFlags).toEqual({})
expect(api.serverFeatureFlags.value).toEqual({})
})
})
describe('Feature checking methods', () => {
beforeEach(() => {
// Set up some test features
api.serverFeatureFlags = {
api.serverFeatureFlags.value = {
supports_preview_metadata: true,
async_execution: false,
capabilities: ['isolated_nodes', 'dynamic_models']
@@ -208,12 +209,61 @@ describe('API Feature Flags', () => {
describe('Integration with preview messages', () => {
it('should affect preview message handling based on feature support', () => {
// Test with metadata support
api.serverFeatureFlags = { supports_preview_metadata: true }
api.serverFeatureFlags.value = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Test without metadata support
api.serverFeatureFlags = {}
api.serverFeatureFlags.value = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
})
describe('Reactivity', () => {
it('should trigger computed updates when serverFeatureFlags changes', async () => {
api.serverFeatureFlags.value = {}
const flag = computed(() =>
api.getServerFeature('supports_preview_metadata', false)
)
expect(flag.value).toBe(false)
api.serverFeatureFlags.value = { supports_preview_metadata: true }
await nextTick()
expect(flag.value).toBe(true)
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('getServerFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.getServerFeature('some_flag')).toBe(true)
})
it('serverSupportsFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.serverSupportsFeature('some_flag')).toBe(true)
})
it('getServerFeature falls through when no override is set', () => {
api.serverFeatureFlags.value = { some_flag: 'server_value' }
expect(api.getServerFeature('some_flag')).toBe('server_value')
})
it('getServerFeature override works with numeric values', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }
localStorage.setItem('ff:max_upload_size', '999')
expect(api.getServerFeature('max_upload_size')).toBe(999)
})
})
})

View File

@@ -3,8 +3,8 @@ import axios from 'axios'
import { storeToRefs } from 'pinia'
import { get } from 'es-toolkit/compat'
import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import { getApiBase } from '@/platform/distribution/types'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import type {
@@ -345,12 +345,9 @@ export class ComfyApi extends EventTarget {
}
/**
* @deprecated Use `getServerCapability()` from `@/services/serverCapabilities`.
* Note: This field was previously a Vue `Ref`; accessing `.value` or using it as
* a reactive source no longer works. Migrate to `getServerCapability()` for
* both value access and capability observation.
* Feature flags received from the backend server.
*/
serverFeatureFlags: Record<string, unknown> = {}
serverFeatureFlags = ref<Record<string, unknown>>({})
/**
* The auth token for the comfy org account if the user is logged in.
@@ -374,7 +371,9 @@ export class ComfyApi extends EventTarget {
super()
this.user = ''
this.api_host = location.host
this.api_base = getApiBase()
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
this.initialClientId = sessionStorage.getItem('clientId')
}
@@ -750,7 +749,12 @@ export class ComfyApi extends EventTarget {
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':
this.serverFeatureFlags = msg.data
// Store server feature flags
this.serverFeatureFlags.value = msg.data
console.log(
'Server feature flags received:',
this.serverFeatureFlags.value
)
this.dispatchCustomEvent('feature_flags', msg.data)
break
default:
@@ -1365,23 +1369,35 @@ export class ComfyApi extends EventTarget {
return (await axios.get(this.apiURL('/i18n'))).data
}
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
/**
* Checks if the server supports a specific feature.
* @param featureName The name of the feature to check (supports dot notation for nested values)
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags, featureName) === true
return get(this.serverFeatureFlags.value, featureName) === true
}
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
/**
* Gets a server feature flag value.
* @param featureName The name of the feature to get (supports dot notation for nested values)
* @param defaultValue The default value if the feature is not found
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags, featureName, defaultValue) as T
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
}
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
/**
* Gets all server feature flags.
* @returns Copy of all server feature flags
*/
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
return { ...this.serverFeatureFlags.value }
}
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {

View File

@@ -782,7 +782,9 @@ export class ComfyApp {
releaseSharedObjectUrl(blobUrl)
})
void useNodeReplacementStore().load()
api.addEventListener('feature_flags', () => {
void useNodeReplacementStore().load()
})
api.init()
}

View File

@@ -1,156 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getServerCapability,
initServerCapabilities
} from '@/services/serverCapabilities'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
getApiBase: () => ''
}))
describe('serverCapabilities', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
supports_preview_metadata: true,
max_upload_size: 104857600,
node_replacements: false,
extension: { manager: { supports_v4: true } }
})
})
)
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('initServerCapabilities', () => {
it('fetches and freezes capabilities on success', async () => {
await initServerCapabilities()
expect(getServerCapability('supports_preview_metadata')).toBe(true)
expect(getServerCapability('max_upload_size')).toBe(104857600)
})
it('retries and falls back to empty object on persistent failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'Failed to fetch server capabilities after retries'
)
})
it('succeeds on retry after initial failure', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ supports_preview_metadata: true })
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(2)
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('stops retrying on JSON parse error (SyntaxError)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.reject(new SyntaxError('Unexpected token'))
} as unknown as Response)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[serverCapabilities] Invalid JSON response, skipping retries'
)
})
it('falls back to empty object on persistent non-ok response', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
json: () => Promise.resolve({})
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
})
})
it('returns default value when called before init', async () => {
vi.resetModules()
const { getServerCapability: freshGet } =
await import('@/services/serverCapabilities')
expect(freshGet('supports_preview_metadata', 'fallback')).toBe('fallback')
})
describe('getServerCapability', () => {
beforeEach(async () => {
await initServerCapabilities()
})
it('returns value for existing key', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('returns default value for missing key', () => {
expect(getServerCapability('non_existent', 'fallback')).toBe('fallback')
})
it('supports dot notation for nested values', () => {
expect(getServerCapability('extension.manager.supports_v4')).toBe(true)
})
it('returns undefined for missing key with no default', () => {
expect(getServerCapability('missing_key')).toBeUndefined()
})
it('returns undefined for deeply nested non-existent path', () => {
expect(
getServerCapability('extension.non_existent.deep.path')
).toBeUndefined()
})
})
describe('dev override via localStorage', () => {
beforeEach(async () => {
await initServerCapabilities()
})
afterEach(() => {
localStorage.clear()
})
it('returns localStorage override over server value', () => {
localStorage.setItem('ff:supports_preview_metadata', 'false')
expect(getServerCapability('supports_preview_metadata')).toBe(false)
})
it('falls through to server value when no override is set', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('override works with numeric values', () => {
localStorage.setItem('ff:max_upload_size', '999')
expect(getServerCapability('max_upload_size')).toBe(999)
})
})
})

View File

@@ -1,56 +0,0 @@
import { get } from 'es-toolkit/compat'
import { getApiBase } from '@/platform/distribution/types'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
const EMPTY: Readonly<Record<string, unknown>> = Object.freeze({})
const MAX_RETRIES = 2
let capabilities: Readonly<Record<string, unknown>> = EMPTY
export async function initServerCapabilities(): Promise<void> {
const url = `${getApiBase()}/api/features`
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const res = await fetch(url, {
cache: 'no-store',
signal: controller.signal
})
clearTimeout(timeoutId)
if (res.ok) {
capabilities = Object.freeze(await res.json())
return
}
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(
'[serverCapabilities] Invalid JSON response, skipping retries'
)
break
}
}
}
console.warn('Failed to fetch server capabilities after retries')
capabilities = EMPTY
}
/**
* Override a single capability at runtime.
* Used by E2E tests to enable features not returned by the CI backend.
*/
export function setServerCapability(key: string, value: unknown): void {
capabilities = Object.freeze({ ...capabilities, [key]: value })
}
export function getServerCapability<T = unknown>(
key: string,
defaultValue?: T
): T {
const override = getDevOverride<T>(key)
if (override !== undefined) return override
return get(capabilities, key, defaultValue) as T
}

View File

@@ -32,7 +32,7 @@ vi.mock('@/scripts/app', () => ({
}))
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
fromAny<LGraphNode, Record<string, unknown>>({
fromAny<LGraphNode, unknown>({
id: 1,
type: 'TestNode',
...overrides

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -20,7 +20,7 @@ function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
nodes: []
} satisfies MockSubgraph
return fromAny<Subgraph, unknown>(mockSubgraph)
return fromPartial<Subgraph>(mockSubgraph)
}
vi.mock('@/scripts/app', () => {

View File

@@ -9,7 +9,6 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
@@ -35,44 +34,20 @@ export const useSubgraphNavigationStore = defineStore(
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = ref<string[]>([])
/** LRU cache for viewport states. Key: `workflowPath:graphId` */
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
const viewportCache = new QuickLRU<string, DragAndScaleState>({
maxSize: VIEWPORT_CACHE_MAX_SIZE
})
/** Get the ID of the root graph for the currently active workflow. */
/**
* Get the ID of the root graph for the currently active workflow.
* @returns The ID of the root graph for the currently active workflow.
*/
const getCurrentRootGraphId = () => {
const canvas = canvasStore.getCanvas()
return canvas.graph?.rootGraph?.id ?? 'root'
}
/**
* Set by saveCurrentViewport() (called from beforeLoadNewGraph) to
* prevent onNavigated from re-saving a stale viewport during the
* workflow switch transition. Uses setTimeout instead of rAF so the
* flag resets even when the tab is backgrounded.
*/
let isWorkflowSwitching = false
// ── Helpers ──────────────────────────────────────────────────────
/** Build a workflow-scoped cache key. */
function buildCacheKey(
graphId: string,
workflowRef?: { path?: string } | null
): string {
const wf = workflowRef ?? workflowStore.activeWorkflow
const prefix = wf?.path ?? ''
return `${prefix}:${graphId}`
}
/** ID of the graph currently shown on the canvas. */
function getActiveGraphId(): string {
const canvas = canvasStore.getCanvas()
return canvas?.subgraph?.id ?? getCurrentRootGraphId()
}
// ── Navigation stack ─────────────────────────────────────────────
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
@@ -85,6 +60,7 @@ export const useSubgraphNavigationStore = defineStore(
/**
* Restore the navigation stack from a list of subgraph IDs.
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
@@ -94,74 +70,69 @@ export const useSubgraphNavigationStore = defineStore(
/**
* Export the navigation stack as a list of subgraph IDs.
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack.value]
// ── Viewport save / restore ──────────────────────────────────────
/** Get the current viewport state, or null if the canvas is not available. */
/**
* Get the current viewport state.
* @returns The current viewport state, or null if the canvas is not available.
*/
const getCurrentViewport = (): DragAndScaleState | null => {
const canvas = canvasStore.getCanvas()
if (!canvas) return null
return {
scale: canvas.ds.state.scale,
offset: [...canvas.ds.state.offset]
}
}
/** Save the current viewport state for a graph. */
function saveViewport(graphId: string, workflowRef?: object | null): void {
/**
* Save the current viewport state.
* @param graphId The graph ID to save for. Use 'root' for root graph, or omit to use current context.
*/
const saveViewport = (graphId: string) => {
const viewport = getCurrentViewport()
if (!viewport) return
viewportCache.set(buildCacheKey(graphId, workflowRef), viewport)
viewportCache.set(graphId, viewport)
}
/** Apply a viewport state to the canvas. */
function applyViewport(viewport: DragAndScaleState): void {
/**
* Restore viewport state for a graph.
* @param graphId The graph ID to restore. Use 'root' for root graph, or omit to use current context.
*/
const restoreViewport = (graphId: string) => {
const viewport = viewportCache.get(graphId)
if (!viewport) return
const canvas = app.canvas
if (!canvas) return
canvas.ds.scale = viewport.scale
canvas.ds.offset[0] = viewport.offset[0]
canvas.ds.offset[1] = viewport.offset[1]
canvas.setDirty(true, true)
}
function restoreViewport(graphId: string): void {
const canvas = app.canvas
if (!canvas) return
const expectedKey = buildCacheKey(graphId)
const viewport = viewportCache.get(expectedKey)
if (viewport) {
applyViewport(viewport)
return
}
// Cache miss — fit to content after the canvas has the new graph.
// rAF fires after layout + paint, when nodes are positioned.
const expectedGraphId = graphId
requestAnimationFrame(() => {
if (getActiveGraphId() !== expectedGraphId) return
useLitegraphService().fitView()
})
}
// ── Navigation handler ───────────────────────────────────────────
function onNavigated(
/**
* Update the navigation stack when the active subgraph changes.
* @param subgraph The new active subgraph.
* @param prevSubgraph The previous active subgraph.
*/
const onNavigated = (
subgraph: Subgraph | undefined,
prevSubgraph: Subgraph | undefined
): void {
// During a workflow switch, beforeLoadNewGraph already saved the
// outgoing viewport — skip the save here to avoid caching stale
// canvas state from the transition.
if (!isWorkflowSwitching) {
if (prevSubgraph) {
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
saveViewport(getCurrentRootGraphId())
}
) => {
// Save viewport state for the graph we're leaving
if (prevSubgraph) {
// Leaving a subgraph
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
// Leaving root graph to enter a subgraph
saveViewport(getCurrentRootGraphId())
}
const isInRootGraph = !subgraph
@@ -176,22 +147,20 @@ export const useSubgraphNavigationStore = defineStore(
if (isInReachableSubgraph) {
idStack.value = [...path]
} else {
// Treat as if opening a new subgraph
idStack.value = [subgraph.id]
}
// Always try to restore viewport for the target subgraph
restoreViewport(subgraph.id)
}
// ── Watchers ─────────────────────────────────────────────────────
// Sync flush ensures we capture the outgoing viewport before any other
// watchers or DOM updates from the same state change mutate the canvas.
// Update navigation stack when opened subgraph changes (also triggers when switching workflows)
watch(
() => workflowStore.activeSubgraph,
(newValue, oldValue) => {
onNavigated(newValue, oldValue)
},
{ flush: 'sync' }
}
)
//Allow navigation with forward/back buttons
@@ -260,16 +229,6 @@ export const useSubgraphNavigationStore = defineStore(
watch(() => canvasStore.currentGraph, updateHash)
watch(routeHash, () => navigateToHash(String(routeHash.value)))
/** Save the current viewport for the active graph/workflow. Called by
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */
function saveCurrentViewport(): void {
saveViewport(getActiveGraphId())
isWorkflowSwitching = true
setTimeout(() => {
isWorkflowSwitching = false
}, 0)
}
return {
activeSubgraph,
navigationStack,
@@ -277,9 +236,7 @@ export const useSubgraphNavigationStore = defineStore(
exportState,
saveViewport,
restoreViewport,
saveCurrentViewport,
updateHash,
/** @internal Exposed for test assertions only. */
viewportCache
}
}

View File

@@ -18,39 +18,32 @@ const { mockSetDirty } = vi.hoisted(() => ({
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: undefined as unknown,
graph: undefined as unknown,
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] },
fitToBounds: vi.fn()
state: {
scale: 1,
offset: [0, 0]
}
},
setDirty: mockSetDirty,
get empty() {
return true
}
setDirty: mockSetDirty
}
const mockGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn(),
id: 'root'
}
mockCanvas.graph = mockGraph
return {
app: {
graph: mockGraph,
rootGraph: mockGraph,
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
// Mock canvasStore
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => app.canvas
@@ -58,165 +51,141 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
}))
vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
const { mockFitView } = vi.hoisted(() => ({
mockFitView: vi.fn()
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: mockFitView })
}))
// Get reference to mock canvas
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []
describe('useSubgraphNavigationStore - Viewport Persistence', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
rafCallbacks = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
mockCanvas.subgraph = undefined
mockCanvas.graph = app.graph
// Reset canvas state
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
mockCanvas.ds.state.offset = [0, 0]
mockSetDirty.mockClear()
mockFitView.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('cache key isolation', () => {
it('isolates viewport by workflow — same graphId returns different values', () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Save viewport under workflow A
workflowStore.activeWorkflow = {
path: 'wfA.json'
} as typeof workflowStore.activeWorkflow
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [10, 20]
store.saveViewport('root')
// Save different viewport under workflow B
workflowStore.activeWorkflow = {
path: 'wfB.json'
} as typeof workflowStore.activeWorkflow
mockCanvas.ds.state.scale = 5
mockCanvas.ds.state.offset = [99, 88]
store.saveViewport('root')
// Restore under A — should get A's values
workflowStore.activeWorkflow = {
path: 'wfA.json'
} as typeof workflowStore.activeWorkflow
store.restoreViewport('root')
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([10, 20])
})
})
describe('saveViewport', () => {
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
it('should save viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 200]
store.saveViewport('root')
// Save viewport for root
navigationStore.saveViewport('root')
expect(store.viewportCache.get(':root')).toEqual({
// Check it was saved
const saved = navigationStore.viewportCache.get('root')
expect(saved).toEqual({
scale: 2,
offset: [100, 200]
})
})
it('saves viewport state for subgraph', () => {
const store = useSubgraphNavigationStore()
it('should save viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 1.5
mockCanvas.ds.state.offset = [50, 75]
store.saveViewport('subgraph-123')
// Save viewport for subgraph
navigationStore.saveViewport('subgraph-123')
expect(store.viewportCache.get(':subgraph-123')).toEqual({
// Check it was saved
const saved = navigationStore.viewportCache.get('subgraph-123')
expect(saved).toEqual({
scale: 1.5,
offset: [50, 75]
})
})
it('should save viewport for current context when no ID provided', () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock being in a subgraph
const mockSubgraph = { id: 'sub-456' }
workflowStore.activeSubgraph = mockSubgraph as Subgraph
// Set viewport state
mockCanvas.ds.state.scale = 3
mockCanvas.ds.state.offset = [10, 20]
// Save viewport without ID (should default to root since activeSubgraph is not tracked by navigation store)
navigationStore.saveViewport('sub-456')
// Should save for the specified subgraph
const saved = navigationStore.viewportCache.get('sub-456')
expect(saved).toEqual({
scale: 3,
offset: [10, 20]
})
})
})
describe('restoreViewport', () => {
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
it('should restore viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
store.restoreViewport('root')
// Save a viewport state
navigationStore.viewportCache.set('root', {
scale: 2.5,
offset: [150, 250]
})
// Restore it
navigationStore.restoreViewport('root')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(2.5)
expect(mockCanvas.ds.offset).toEqual([150, 250])
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('does not mutate canvas synchronously on cache miss', () => {
const store = useSubgraphNavigationStore()
it('should restore viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('sub-789', {
scale: 0.75,
offset: [-50, -100]
})
// Restore it
navigationStore.restoreViewport('sub-789')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(0.75)
expect(mockCanvas.ds.offset).toEqual([-50, -100])
})
it('should do nothing if no saved viewport exists', () => {
const navigationStore = useSubgraphNavigationStore()
// Reset canvas
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockSetDirty.mockClear()
store.restoreViewport('non-existent')
// Try to restore non-existent viewport
navigationStore.restoreViewport('non-existent')
// Should not change canvas synchronously
// Canvas should not change
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockSetDirty).not.toHaveBeenCalled()
// But should have scheduled a rAF
expect(rafCallbacks).toHaveLength(1)
})
it('calls fitView on cache miss after rAF fires', () => {
const store = useSubgraphNavigationStore()
// Ensure no cached entry
store.viewportCache.delete(':root')
// Use the root graph ID so the stale-guard passes
store.restoreViewport('root')
expect(mockFitView).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(1)
// Simulate rAF firing — active graph still matches
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
})
it('skips fitView if active graph changed before rAF fires', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
store.restoreViewport('root')
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[0](performance.now())
expect(mockFitView).not.toHaveBeenCalled()
})
})
describe('navigation integration', () => {
it('saves and restores viewport when navigating between subgraphs', async () => {
const store = useSubgraphNavigationStore()
it('should save and restore viewport when navigating between subgraphs', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Create mock subgraph with both _nodes and nodes properties
const mockRootGraph = {
_nodes: [],
nodes: [],
@@ -230,72 +199,84 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
nodes: []
}
// Start at root with custom viewport
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 100]
// Enter subgraph
// Navigate to subgraph
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
await nextTick()
// Root viewport saved
expect(store.viewportCache.get(':root')).toEqual({
scale: 2,
offset: [100, 100]
})
// Root viewport should have been saved automatically
const rootViewport = navigationStore.viewportCache.get('root')
expect(rootViewport).toBeDefined()
expect(rootViewport?.scale).toBe(2)
expect(rootViewport?.offset).toEqual([100, 100])
// Change viewport in subgraph
mockCanvas.ds.state.scale = 0.5
mockCanvas.ds.state.offset = [-50, -50]
// Exit subgraph
// Navigate back to root
workflowStore.activeSubgraph = undefined
await nextTick()
// Subgraph viewport saved
expect(store.viewportCache.get(':sub1')).toEqual({
scale: 0.5,
offset: [-50, -50]
})
// Subgraph viewport should have been saved automatically
const sub1Viewport = navigationStore.viewportCache.get('sub1')
expect(sub1Viewport).toBeDefined()
expect(sub1Viewport?.scale).toBe(0.5)
expect(sub1Viewport?.offset).toEqual([-50, -50])
// Root viewport restored
// Root viewport should be restored automatically
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('preserves pre-existing cache entries across workflow switches', async () => {
const store = useSubgraphNavigationStore()
it('should preserve viewport cache when switching workflows', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
store.viewportCache.set(':root', { scale: 2, offset: [0, 0] })
store.viewportCache.set(':sub1', { scale: 1.5, offset: [10, 10] })
expect(store.viewportCache.size).toBe(2)
// Add some viewport states
navigationStore.viewportCache.set('root', { scale: 2, offset: [0, 0] })
navigationStore.viewportCache.set('sub1', {
scale: 1.5,
offset: [10, 10]
})
const wf1 = { path: 'wf1.json' } as ComfyWorkflow
const wf2 = { path: 'wf2.json' } as ComfyWorkflow
expect(navigationStore.viewportCache.size).toBe(2)
workflowStore.activeWorkflow = wf1 as typeof workflowStore.activeWorkflow
// Switch workflows
const workflow1 = { path: 'workflow1.json' } as ComfyWorkflow
const workflow2 = { path: 'workflow2.json' } as ComfyWorkflow
workflowStore.activeWorkflow = workflow1 as ReturnType<
typeof useWorkflowStore
>['activeWorkflow']
await nextTick()
workflowStore.activeWorkflow = wf2 as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = workflow2 as ReturnType<
typeof useWorkflowStore
>['activeWorkflow']
await nextTick()
// Pre-existing entries still in cache
expect(store.viewportCache.has(':root')).toBe(true)
expect(store.viewportCache.has(':sub1')).toBe(true)
// Cache should be preserved (LRU will manage memory)
expect(navigationStore.viewportCache.size).toBe(2)
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
it('should save/restore viewports correctly across multiple subgraphs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.viewportCache.set(':root', {
navigationStore.viewportCache.set('root', {
scale: 1,
offset: [0, 0]
})
navigationStore.viewportCache.set(':sub-1', {
navigationStore.viewportCache.set('sub-1', {
scale: 2,
offset: [100, 200]
})
navigationStore.viewportCache.set(':sub-2', {
navigationStore.viewportCache.set('sub-2', {
scale: 0.5,
offset: [-50, -75]
})
@@ -319,18 +300,17 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
// QuickLRU uses double-buffering: effective capacity is up to 2 * maxSize.
// Fill enough entries so the earliest ones are fully evicted.
// Keys use the workflow-scoped format (`:graphId`) matching production.
for (let i = 0; i < overflowEntryCount; i++) {
navigationStore.viewportCache.set(`:sub-${i}`, {
navigationStore.viewportCache.set(`sub-${i}`, {
scale: i + 1,
offset: [i * 10, i * 20]
})
}
expect(navigationStore.viewportCache.has(':sub-0')).toBe(false)
expect(navigationStore.viewportCache.has('sub-0')).toBe(false)
expect(
navigationStore.viewportCache.has(`:sub-${overflowEntryCount - 1}`)
navigationStore.viewportCache.has(`sub-${overflowEntryCount - 1}`)
).toBe(true)
mockCanvas.ds.scale = 99

View File

@@ -1,4 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -50,7 +50,7 @@ describe('getWidgetDefaultValue', () => {
})
function makeWidget(overrides: Record<string, unknown> = {}): IBaseWidget {
return fromAny<IBaseWidget, unknown>({
return fromPartial<IBaseWidget>({
name: 'myWidget',
type: 'number',
value: 0,

1
src/vite-env.d.ts vendored
View File

@@ -15,7 +15,6 @@ declare module '~icons/*' {
declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
__setServerCapability?: (key: string, value: unknown) => void
}
interface ImportMetaEnv {

View File

@@ -3,24 +3,21 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import * as serverCapabilities from '@/services/serverCapabilities'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import {
ManagerUIState,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
// Mock dependencies that are not stores
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn(),
getSystemStats: vi.fn()
}
}))
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
}))
vi.mock('@/composables/useFeatureFlags', () => {
const featureFlag = vi.fn()
return {
@@ -69,6 +66,7 @@ describe('useManagerState', () => {
let systemStatsStore: ReturnType<typeof useSystemStatsStore>
beforeEach(() => {
// Create a fresh testing pinia and activate it for each test
setActivePinia(
createTestingPinia({
stubActions: false,
@@ -76,16 +74,20 @@ describe('useManagerState', () => {
})
)
// Initialize stores
systemStatsStore = useSystemStatsStore()
// Reset all mocks
vi.resetAllMocks()
// Set default mock returns
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(undefined)
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
})
describe('managerUIState property', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -94,7 +96,7 @@ describe('useManagerState', () => {
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: ['python', 'main.py'],
argv: ['python', 'main.py'], // No --enable-manager flag
ram_total: 16000000000,
ram_free: 8000000000
},
@@ -108,6 +110,7 @@ describe('useManagerState', () => {
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -135,6 +138,7 @@ describe('useManagerState', () => {
})
it('should return NEW_UI state when client and server both support v4', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -155,13 +159,14 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -182,13 +187,14 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when server does not support v4', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -207,13 +213,14 @@ describe('useManagerState', () => {
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(false)
vi.mocked(api.getServerFeature).mockReturnValue(false)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when server capability is undefined', () => {
it('should return NEW_UI state when server feature flags are undefined', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -232,15 +239,15 @@ describe('useManagerState', () => {
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(
undefined
)
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
const managerState = useManagerState()
// When server feature flags haven't loaded yet, default to NEW_UI
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should handle null systemStats gracefully', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: null,
isInitialized: true
@@ -249,15 +256,17 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
// When systemStats is null, we can't check for --enable-manager flag, so manager is disabled
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('helper properties', () => {
it('isManagerEnabled should return true when state is not DISABLED', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -278,13 +287,14 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(true)
})
it('isManagerEnabled should return false when state is DISABLED', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -293,7 +303,7 @@ describe('useManagerState', () => {
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: ['python', 'main.py'],
argv: ['python', 'main.py'], // No --enable-manager flag
ram_total: 16000000000,
ram_free: 8000000000
},
@@ -307,6 +317,7 @@ describe('useManagerState', () => {
})
it('isNewManagerUI should return true when state is NEW_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -327,13 +338,14 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.isNewManagerUI.value).toBe(true)
})
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -361,6 +373,7 @@ describe('useManagerState', () => {
})
it('shouldShowInstallButton should return true only for NEW_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -381,13 +394,14 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.shouldShowInstallButton.value).toBe(true)
})
it('shouldShowManagerButtons should return true when not DISABLED', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -408,7 +422,7 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
vi.mocked(api.getServerFeature).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.shouldShowManagerButtons.value).toBe(true)

View File

@@ -4,7 +4,6 @@ import { computed, readonly } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { getServerCapability } from '@/services/serverCapabilities'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -40,7 +39,7 @@ export function useManagerState() {
const clientSupportsV4 =
api.getClientFeatureFlags().supports_manager_v4_ui ?? false
const serverSupportsV4 = getServerCapability(
const serverSupportsV4 = api.getServerFeature(
'extension.manager.supports_v4'
)
@@ -75,7 +74,8 @@ export function useManagerState() {
return ManagerUIState.LEGACY_UI
}
// If server capability is not set, default to NEW_UI
// If server feature flags haven't loaded yet, default to NEW_UI
// This is a temporary state - feature flags are exchanged immediately on WebSocket connection
// NEW_UI is the safest default since v2 API is the current standard
// If the server doesn't support v2, API calls will fail with 404 and be handled gracefully
if (serverSupportsV4 === undefined) {