mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
Compare commits
5 Commits
jaewon/fe-
...
jaewon/hot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb9af1323 | ||
|
|
9dd5bbb025 | ||
|
|
db969dd50e | ||
|
|
4c20d44e6b | ||
|
|
a3e8f8625d |
@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -77,8 +76,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
@@ -90,7 +87,6 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -106,13 +102,6 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
? RootCategory.Essentials
|
||||
: null
|
||||
})
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -137,6 +126,7 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
|
||||
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply defaultRootFilter when provided and category is available', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'FrequentNode',
|
||||
display_name: 'Frequent Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -141,9 +141,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
const { filters } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -194,12 +193,8 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -238,7 +238,6 @@ import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContex
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useFlatOutputAssetsGrouped } from '@/platform/assets/composables/media/useFlatOutputAssetsGrouped'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -312,7 +311,7 @@ const formattedExecutionTime = computed(() => {
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useAssetsApi('input')
|
||||
const outputAssets = useFlatOutputAssetsGrouped()
|
||||
const outputAssets = useAssetsApi('output')
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useFlatOutputAssetsGrouped } from './useFlatOutputAssetsGrouped'
|
||||
|
||||
const mediaRef: Ref<AssetItem[]> = ref([])
|
||||
|
||||
vi.mock('./useFlatOutputAssets', () => ({
|
||||
useFlatOutputAssets: () => ({
|
||||
media: mediaRef,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false),
|
||||
fetchMediaList: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
loadMore: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-id',
|
||||
name: 'output.png',
|
||||
tags: ['output'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFlatOutputAssetsGrouped', () => {
|
||||
it('collapses rows with the same job_id into a single representative', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', name: 'out1.png', job_id: 'job-1' }),
|
||||
asset({ id: 'b', name: 'out2.png', job_id: 'job-1' }),
|
||||
asset({ id: 'c', name: 'out3.png', job_id: 'job-1' }),
|
||||
asset({ id: 'd', name: 'solo.png', job_id: 'job-2' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['a', 'd'])
|
||||
})
|
||||
|
||||
it('exposes the group size as user_metadata.outputCount', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', job_id: 'job-1' }),
|
||||
asset({ id: 'b', job_id: 'job-1' }),
|
||||
asset({ id: 'c', job_id: 'job-1' }),
|
||||
asset({ id: 'd', job_id: 'job-2' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value[0].user_metadata?.outputCount).toBe(3)
|
||||
expect(media.value[0].user_metadata?.jobId).toBe('job-1')
|
||||
expect(media.value[1].user_metadata?.outputCount).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to prompt_id when job_id is absent (legacy)', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', prompt_id: 'job-legacy' }),
|
||||
asset({ id: 'b', prompt_id: 'job-legacy' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value).toHaveLength(1)
|
||||
expect(media.value[0].user_metadata?.jobId).toBe('job-legacy')
|
||||
expect(media.value[0].user_metadata?.outputCount).toBe(2)
|
||||
})
|
||||
|
||||
it('passes through rows that have neither job_id nor prompt_id', () => {
|
||||
mediaRef.value = [asset({ id: 'orphan-a' }), asset({ id: 'orphan-b' })]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['orphan-a', 'orphan-b'])
|
||||
})
|
||||
|
||||
it('preserves the order of the first occurrence per job_id', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', job_id: 'job-A' }),
|
||||
asset({ id: 'b', job_id: 'job-B' }),
|
||||
asset({ id: 'c', job_id: 'job-A' }),
|
||||
asset({ id: 'd', job_id: 'job-C' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['a', 'b', 'd'])
|
||||
})
|
||||
|
||||
it('does not mutate the underlying assets', () => {
|
||||
const original = asset({ id: 'a', job_id: 'job-1' })
|
||||
mediaRef.value = [original, asset({ id: 'b', job_id: 'job-1' })]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
void media.value
|
||||
|
||||
expect(original.user_metadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useFlatOutputAssets } from './useFlatOutputAssets'
|
||||
|
||||
/**
|
||||
* Cloud `/api/assets?include_tags=output` returns one row per individual output
|
||||
* file. The asset sidebar's stack UX expects one card per job with an
|
||||
* `outputCount` badge, so collapse rows that share a `job_id` into a single
|
||||
* representative (the first occurrence — assets are returned newest-first).
|
||||
*
|
||||
* The siblings remain reachable through the existing stack-expand path via
|
||||
* `resolveOutputAssetItems(metadata)`.
|
||||
*/
|
||||
export function useFlatOutputAssetsGrouped(): IAssetsProvider {
|
||||
const inner = useFlatOutputAssets()
|
||||
|
||||
const media = computed(() => groupByJobId(inner.media.value))
|
||||
|
||||
return {
|
||||
...inner,
|
||||
media
|
||||
}
|
||||
}
|
||||
|
||||
function groupByJobId(assets: AssetItem[]): AssetItem[] {
|
||||
const countsByJobId = new Map<string, number>()
|
||||
for (const asset of assets) {
|
||||
const jobId = asset.job_id ?? asset.prompt_id
|
||||
if (!jobId) continue
|
||||
countsByJobId.set(jobId, (countsByJobId.get(jobId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const seenJobIds = new Set<string>()
|
||||
const grouped: AssetItem[] = []
|
||||
for (const asset of assets) {
|
||||
const jobId = asset.job_id ?? asset.prompt_id ?? null
|
||||
if (!jobId) {
|
||||
grouped.push(asset)
|
||||
continue
|
||||
}
|
||||
if (seenJobIds.has(jobId)) continue
|
||||
seenJobIds.add(jobId)
|
||||
|
||||
const outputCount = countsByJobId.get(jobId) ?? 1
|
||||
grouped.push({
|
||||
...asset,
|
||||
user_metadata: {
|
||||
...asset.user_metadata,
|
||||
jobId,
|
||||
outputCount
|
||||
}
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
|
||||
function getStackJobId(asset: AssetItem): string | null {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.jobId ?? asset.job_id ?? null
|
||||
return metadata?.jobId ?? null
|
||||
}
|
||||
|
||||
function isStackExpanded(asset: AssetItem): boolean {
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Metadata for output assets. Originates from the queue/history mapping but
|
||||
* also surfaces on assets sourced directly from `/api/assets?include_tags=output`,
|
||||
* which carry `jobId` only (no per-output `nodeId` / `subfolder`).
|
||||
* Metadata for output assets from queue store
|
||||
* Extends Record<string, unknown> for compatibility with AssetItem schema
|
||||
*/
|
||||
export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
jobId: string
|
||||
nodeId?: string | number
|
||||
subfolder?: string
|
||||
nodeId: string | number
|
||||
subfolder: string
|
||||
executionTimeInSeconds?: number
|
||||
format?: string
|
||||
workflow?: ComfyWorkflowJSON
|
||||
@@ -17,11 +16,17 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
allOutputs?: ResultItemImpl[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if metadata is OutputAssetMetadata
|
||||
*/
|
||||
function isOutputAssetMetadata(
|
||||
metadata: Record<string, unknown> | undefined
|
||||
): metadata is OutputAssetMetadata {
|
||||
if (!metadata) return false
|
||||
return typeof metadata.jobId === 'string'
|
||||
return (
|
||||
typeof metadata.jobId === 'string' &&
|
||||
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,10 +18,7 @@ const zAsset = z.object({
|
||||
is_immutable: z.boolean().optional(),
|
||||
last_access_time: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
job_id: z.string().nullish(),
|
||||
// Deprecated alias of job_id. See ingest-types Asset schema; both backends emit this during the L6 transition.
|
||||
prompt_id: z.string().nullish()
|
||||
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
|
||||
})
|
||||
|
||||
const zAssetResponse = zListAssetsResponse
|
||||
|
||||
@@ -16,14 +16,6 @@ const mockGetShareableAssets = vi.fn()
|
||||
const mockFetchApi = vi.fn()
|
||||
const mockInvalidateInputAssetsIncludingPublic = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/validation/schemas/workflowSchema',
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
validateComfyWorkflow: vi.fn(async (json: unknown) => json)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getShareableAssets: (...args: unknown[]) => mockGetShareableAssets(...args),
|
||||
@@ -408,6 +400,36 @@ describe(useWorkflowShareService, () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw workflow_json when it does not match ComfyWorkflowJSON schema', async () => {
|
||||
const rawWorkflowJson = {
|
||||
extra: {
|
||||
linearData: {
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[2, 'seed', 'invalid-third-element']
|
||||
],
|
||||
outputs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
share_id: 'share-raw',
|
||||
workflow_id: 'wf-raw',
|
||||
name: 'Raw',
|
||||
listed: false,
|
||||
publish_time: null,
|
||||
workflow_json: rawWorkflowJson,
|
||||
assets: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const shared = await service.getSharedWorkflow('share-raw')
|
||||
|
||||
expect(shared.workflowJson).toEqual(rawWorkflowJson)
|
||||
})
|
||||
|
||||
it('treats malformed publish-status payload as unpublished', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({ is_published: true }))
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zHubWorkflowPrefillResponse,
|
||||
@@ -249,12 +248,6 @@ export function useWorkflowShareService() {
|
||||
throw new Error('Failed to load shared workflow: invalid response')
|
||||
}
|
||||
|
||||
const validated = await validateComfyWorkflow(workflow.workflowJson)
|
||||
if (!validated) {
|
||||
throw new Error('Failed to load shared workflow: invalid workflow data')
|
||||
}
|
||||
workflow.workflowJson = validated
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user