Compare commits

...

8 Commits

Author SHA1 Message Date
ShihChi Huang
2567daada0 Merge branch 'main' into codex/coverage-fuse-util 2026-06-24 16:54:19 -07:00
ShihChi Huang
7ab6cb57c5 test: 1/x fix coverage run (#13086)
## Summary

Fix the two current blockers that prevented `pnpm test:coverage` from
completing on `main`.

Stack order: 1/x

## Changes

- Mock `load3dAdvanced` in the lazy-loader test so coverage does not
import the real Load3DAdvanced UI graph.
- Track the active workflow status in `useWorkflowStatusDismissal` so
terminal statuses arriving after activation are cleared.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:coverage` |  failed, so the stack had no usable coverage
baseline |  passed with 877 test files passed; 11,772 passed / 8
skipped |
| focused tests | `load3dLazy` timed out; `useWorkflowStatusDismissal`
failed its active-workflow status case |  `load3dLazy`: 13 passed;
`useWorkflowStatusDismissal`: 4 passed |

## Coverage

| | before | after |
| -- | -- | -- |
| statements | unavailable | 62.84% |
| branches | unavailable | 53.03% |
| functions | unavailable | 56.94% |
| lines | unavailable | 64.05% |

Screenshots: N/A, no UI change.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 94c4c9bac1. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-06-24 23:08:29 +00:00
Alexis Rolland
3c3a2ab4e2 fix: Load Audio node not caching execution (#12950)
## Summary

This PR fixes a bug where the Load Audio node re-executes everytime.

## Changes

- **What**: Mark `audioUIWidget.options.serialize = false`

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-24 23:04:26 +00:00
Dante
a07854755f fix(billing): restore unified pricing dialog width (Reka renderer regression) (#13092)
## Summary

Restore the unified "Choose a Plan" pricing dialog width — it was
collapsing to the default `md` (576px) frame, so the 1280px table
overflowed and rendered off-center with the right card clipped.

## Changes

- **What**: `showPricingTable` opens the unified dialog
(`SubscriptionRequiredDialogContentUnified`) with PrimeVue-path props
for sizing (`style: 'max-width: 95vw'` + `pt`). Since #12593 (FE-578
Phase 6a) made **Reka the default dialog renderer**, those props are
ignored — Reka sizes via `size`/`contentClass`, so the dialog fell back
to `size: 'md'` (`max-w-xl` = 576px). The content root's
`xl:w-[min(1280px,95vw)]` then overflowed the 576px box and shifted
off-center. Moved the width onto a Reka `contentClass` (`w-fit
max-w-[min(1280px,95vw)]`), matching the sibling subscription dialogs in
the same file.

## Review Focus

- **Regression origin**: the broken config landed when #12666 (FE-934,
UnifiedPricingTable) merged on top of #12593's reka-default flip while
still using the PrimeVue config. No merge conflict — the `style` line is
valid but dead, so it broke silently. FE-991 (#12792) predates #12593,
so it still rendered via PrimeVue and looked correct (matching the
report that it was fine there).
- **`w-fit` vs fixed width**: `w-fit` preserves the original "dialog
hugs its content per step" intent — the content root only sets the
1280px width on the pricing step, so confirm/success steps still shrink
instead of floating in a 1280px box.
- Out of scope: the legacy-team / flag-off paths share a PrimeVue
`style` shell and are likely affected the same way under Reka; left for
a follow-up (flag-off is the lower-priority OSS path).

## Verification

- Unit test `useSubscriptionDialog.test.ts` — red without the fix
(dialog has no `contentClass`), green with it.
- Verified live (cloud dev, viewport 1301px): box centered at 1236px
(95vw), no overflow, all three personal cards visible.

## Screenshots

Personal tab, viewport 1301px:

| Before | After |
| --- | --- |
| <img width="480" alt="before"
src="https://github.com/user-attachments/assets/e233fe00-f754-4e34-837f-cf6630ccbfb9"
/> | <img width="480" alt="after"
src="https://github.com/user-attachments/assets/dedd92b7-8707-4865-b7f3-289919043b48"
/> |
2026-06-24 22:23:00 +00:00
CodeJuggernaut
2adef5d9f6 Create script for pointing at prod and staging backends (#13096)
## Summary

Allows engineers to run their localhost frontend while choosing which
backend to point. This PR adds staging and prod as targets.
## Changes

- **What**: New NPM scripts: `dev:cloud:test`, `dev:cloud:staging`, and
`dev:cloud:prod`. `dev:cloud` points at `dev:cloud:test`
- **Breaking**: None

## Why

Currently, the testcloud environment is broken (backend config issue)
and doesn't allow going through the subscription registration process.
This also allows testing frontend code against backend changes being
staged for release, as well as against actual backend production code.
2026-06-24 21:39:42 +00:00
huang47
0fb8cc194d test: cover fuse search ranking 2026-06-24 09:30:46 -07:00
huang47
94c4c9bac1 fix: track workflow status dismissal updates 2026-06-24 09:30:46 -07:00
huang47
e0fc3fbf94 test: mock load3dAdvanced lazy import 2026-06-24 09:30:46 -07:00
8 changed files with 217 additions and 19 deletions

View File

@@ -19,7 +19,10 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,6 +128,7 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -129,6 +129,21 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true

View File

@@ -129,18 +129,15 @@ export const useSubscriptionDialog = () => {
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
},
dialogComponentProps: {
// The dialog hugs its content so each step sizes itself: the pricing
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
// compact confirm/success steps shrink instead of floating in the big
// pricing modal. Sizes are set on the content root per checkoutStep.
style: 'max-width: 95vw; max-height: 90vh;',
pt: {
root: { class: 'rounded-2xl bg-transparent' },
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is ignored here and collapses the table to the default
// `md` frame. `w-fit` lets each step hug its content — the pricing
// table fills its 1280px content while the compact confirm/success
// steps shrink (the content root sets its own width per checkoutStep).
renderer: 'reka',
size: 'full',
contentClass:
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
})
return

147
src/utils/fuseUtil.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<FuseSearchable> {
name: string
}
interface FilterItem {
options: string[]
}
const makeSearch = <T>(data: T[] = []) =>
new FuseSearch<T>(data, {
fuseOptions: {
keys: ['name'],
includeScore: true,
threshold: 0.6,
shouldSort: false
},
advancedScoring: true
})
describe('FuseSearch', () => {
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
const search = new FuseSearch<string>([], {})
const cases = [
{ query: 'load image', item: 'load image', tier: 0 },
{ query: 'load', item: 'Load Image', tier: 1 },
{ query: 'image', item: 'LoadImage', tier: 2 },
{ query: 'cast', item: 'broadcast', tier: 3 },
{ query: 'batch latent', item: 'LatentBatch', tier: 4 },
{ query: 'ten bat', item: 'LatentBatch', tier: 5 },
{ query: 'vae', item: 'KSampler', tier: 9 }
]
for (const { query, item, tier } of cases) {
expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier)
}
})
it('penalizes deprecated non-exact matches without penalizing exact matches', () => {
const search = makeSearch<SearchItem>()
expect(
search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0]
).toBe(6)
expect(
search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0]
).toBe(0)
})
it('lets searchable entries post-process their auxiliary scores', () => {
const search = makeSearch<SearchItem>()
const entry: SearchItem = {
name: 'Image Loader',
postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)]
}
expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3)
})
it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => {
const exact = { name: 'Image' }
const prefix = { name: 'Image Loader' }
const camelCaseWord = { name: 'LoadImage' }
const substring = { name: 'PreimageNode' }
const deprecated = { name: 'Image Deprecated' }
const search = makeSearch([
substring,
deprecated,
camelCaseWord,
prefix,
exact
])
expect(search.search('image')).toEqual([
exact,
prefix,
camelCaseWord,
substring,
deprecated
])
})
it('returns data in original order for an empty query without calling Fuse', () => {
const data = [{ name: 'B' }, { name: 'A' }]
const search = makeSearch(data)
const fuseSearchSpy = vi.spyOn(search.fuse, 'search')
expect(search.search('')).toEqual(data)
expect(fuseSearchSpy).not.toHaveBeenCalled()
})
it('compares auxiliary scores by the first differing value and then length', () => {
const search = new FuseSearch<string>([], {})
expect(
[
[1, 4],
[1, 2],
[0, 99]
].sort(search.compareAux)
).toEqual([
[0, 99],
[1, 2],
[1, 4]
])
expect(
[
[1, 2, 0],
[1, 2]
].sort(search.compareAux)
).toEqual([
[1, 2],
[1, 2, 0]
])
})
})
describe('FuseFilter', () => {
it('matches single values, comma-separated values, and wildcard fallbacks', () => {
const imageItem = { options: ['IMAGE', 'LATENT'] }
const modelItem = { options: ['MODEL'] }
const filter = new FuseFilter<FilterItem, string>([imageItem, modelItem], {
id: 'type',
name: 'Type',
invokeSequence: 't',
getItemOptions: (item) => item.options
})
expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual(
['IMAGE', 'LATENT', 'MODEL']
)
expect(filter.matches(imageItem, 'IMAGE')).toBe(true)
expect(filter.matches(imageItem, 'MODEL')).toBe(false)
expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true)
expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true)
expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true)
expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(
false
)
})
})