mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-20 05:22:58 +00:00
Compare commits
7 Commits
jaeone/mod
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 | ||
|
|
c8b5589768 | ||
|
|
b4b95980da | ||
|
|
8c0af36c4a | ||
|
|
78a8d6f8fc |
4
.github/workflows/release-draft-create.yaml
vendored
4
.github/workflows/release-draft-create.yaml
vendored
@@ -92,9 +92,7 @@ jobs:
|
||||
make_latest: >-
|
||||
${{ github.event.pull_request.base.ref == 'main' &&
|
||||
needs.build.outputs.is_prerelease == 'false' }}
|
||||
draft: >-
|
||||
${{ github.event.pull_request.base.ref != 'main' ||
|
||||
needs.build.outputs.is_prerelease == 'true' }}
|
||||
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
prerelease: >-
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
}
|
||||
|
||||
async function setLocaleAndWaitForWorkflowReload(
|
||||
comfyPage: ComfyPage,
|
||||
locale: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(async (targetLocale) => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No active workflow while waiting for locale reload')
|
||||
}
|
||||
|
||||
const changeTracker = workflow.changeTracker.constructor as unknown as {
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
let sawLoading = false
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
if (sawLoading && !changeTracker.isLoadingGraph) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (performance.now() > timeoutAt) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
|
||||
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
|
||||
await waitForReload
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
test.describe('Locale-specific documentation', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
@@ -434,9 +382,7 @@ This is English documentation.
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
} finally {
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,11 +151,21 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,7 +15,14 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,7 +28,26 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,10 +8,23 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
interface UseNodePreviewAndDragReturn {
|
||||
previewRef: Ref<HTMLElement | null>
|
||||
isHovered: Ref<boolean>
|
||||
isDragging: Ref<boolean>
|
||||
showPreview: ComputedRef<boolean>
|
||||
nodePreviewStyle: Ref<CSSProperties>
|
||||
sidebarLocation: ComputedRef<'left' | 'right'>
|
||||
handleMouseEnter: (e: MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
handleDragStart: (e: DragEvent) => void
|
||||
handleDragEnd: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
) {
|
||||
): UseNodePreviewAndDragReturn {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
type ResizeDirection =
|
||||
export type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,6 +17,17 @@ type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
@@ -264,17 +275,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
|
||||
|
||||
const allResizeHandles = computed<ResizeHandle[]>(() => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
/**
|
||||
* Cloud-only extension that enforces active subscription requirement
|
||||
*/
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.Subscription',
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { requireActiveSubscription } = useBillingContext()
|
||||
|
||||
const checkSubscriptionStatus = () => {
|
||||
if (!isLoggedIn.value) return
|
||||
void requireActiveSubscription()
|
||||
}
|
||||
|
||||
watch(() => isLoggedIn.value, checkSubscriptionStatus, {
|
||||
immediate: true
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -36,10 +36,6 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
|
||||
if (window.__CONFIG__?.subscription_required) {
|
||||
await import('./cloudSubscription')
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -154,8 +154,10 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
export const t: (typeof i18n.global)['t'] = i18n.global.t
|
||||
export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const displayImageDimensions = computed(() =>
|
||||
resolveDisplayImageDimensions(asset, imageDimensions.value)
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -223,8 +223,18 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
['film', 'FILM VFI', 'ckpt_name'],
|
||||
|
||||
// ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
|
||||
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
// Intentionally NOT mapped to the asset-picker. The cloud asset-ingestion
|
||||
// metadata for nested model folders (`ultralytics/bbox`, `ultralytics/segm`)
|
||||
// still has the two known half-bugs described in #12075:
|
||||
// 1. Tag lookup mismatch (cloud stores combined tags, picker queries split).
|
||||
// 2. Submitted value mismatch (picker returns basenames, ingest expects
|
||||
// subdirectory-prefixed `bbox/<file>` / `segm/<file>`).
|
||||
// PR #12151 re-added the bbox/segm entries before either half was fixed,
|
||||
// reintroducing the FaceDetailer breakage. Until BE-689 lands the cloud-side
|
||||
// fixes, leave these disabled so the node falls back to the static combo
|
||||
// populated from `/api/object_info`.
|
||||
// ['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
// ['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
|
||||
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -417,4 +419,124 @@ describe('assetMetadataUtils', () => {
|
||||
expect(getAssetCardTitle(asset)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetMetadataDimensions', () => {
|
||||
it('returns dimensions when width/height are positive integers', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
|
||||
expect(getAssetMetadataDimensions(asset)).toEqual({
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'NaN width', width: Number.NaN, height: 768 },
|
||||
{
|
||||
name: 'Infinity height',
|
||||
width: 1024,
|
||||
height: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{ name: 'zero width', width: 0, height: 768 },
|
||||
{ name: 'negative height', width: 1024, height: -1 },
|
||||
{ name: 'fractional width', width: 1024.5, height: 768 },
|
||||
{ name: 'string width', width: '1024', height: 768 },
|
||||
{ name: 'missing width', width: undefined, height: 768 }
|
||||
])('returns undefined for invalid shape: $name', ({ width, height }) => {
|
||||
const asset = { ...mockAsset, metadata: { width, height } }
|
||||
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when metadata is absent', () => {
|
||||
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when asset itself is undefined', () => {
|
||||
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDisplayImageDimensions', () => {
|
||||
const rendered = { width: 512, height: 288 }
|
||||
|
||||
it('prefers server metadata dimensions over the rendered natural size', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 1920, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
|
||||
const asset = { ...mockAsset }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
|
||||
const fullResUrl =
|
||||
'http://localhost:8188/view?filename=output.png&type=output'
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: fullResUrl,
|
||||
preview_url: fullResUrl
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 0, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined when neither metadata nor a rendered size is available', () => {
|
||||
expect(
|
||||
resolveDisplayImageDimensions(mockAsset, undefined)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
|
||||
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
|
||||
rendered
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +216,64 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
|
||||
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
|
||||
* NaN, Infinity, 0, negatives, and fractional values.
|
||||
*/
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original image dimensions from `asset.metadata.{width,height}`
|
||||
* when both pass shape validation, otherwise `undefined`. Callers should fall
|
||||
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
|
||||
* on runtimes that serve the original file but reports preview size on
|
||||
* runtimes that serve a downscaled preview.
|
||||
*/
|
||||
export function getAssetMetadataDimensions(
|
||||
asset: AssetItem | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const w = asset?.metadata?.width
|
||||
const h = asset?.metadata?.height
|
||||
if (isValidDimension(w) && isValidDimension(h)) {
|
||||
return { width: w, height: h }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image dimensions an asset card should display.
|
||||
*
|
||||
* Prefers the server-provided original dimensions from
|
||||
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
|
||||
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
|
||||
* actually rendered — and only when that rendered image was the original file.
|
||||
*
|
||||
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
|
||||
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
|
||||
* preview's dimensions rather than the asset's. In that case this returns
|
||||
* `undefined` so the card shows no label rather than a wrong resolution.
|
||||
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
|
||||
* so the guard correctly passes through `renderedNaturalSize`.
|
||||
*/
|
||||
export function resolveDisplayImageDimensions(
|
||||
asset: AssetItem | undefined,
|
||||
renderedNaturalSize: ImageDimensions | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const fromMetadata = getAssetMetadataDimensions(asset)
|
||||
if (fromMetadata) return fromMetadata
|
||||
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
|
||||
return undefined
|
||||
return renderedNaturalSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,10 @@ const { mockHandles } = vi.hoisted(() => {
|
||||
executionErrorStore: {
|
||||
surfaceMissingModels: vi.fn()
|
||||
},
|
||||
modelStore: {
|
||||
loadModelFolders: vi.fn(),
|
||||
getLoadedModelFolder: vi.fn()
|
||||
},
|
||||
modelToNodeStore: {
|
||||
getCategoryForNodeType: vi.fn()
|
||||
},
|
||||
@@ -45,9 +49,14 @@ const { mockHandles } = vi.hoisted(() => {
|
||||
): MissingModelCandidate[] => []
|
||||
),
|
||||
enrichWithEmbeddedMetadata: vi.fn(
|
||||
(
|
||||
async (
|
||||
_candidates: readonly MissingModelCandidate[],
|
||||
_graphData: ComfyWorkflowJSON
|
||||
_graphData: ComfyWorkflowJSON,
|
||||
_checkModelInstalled: (
|
||||
name: string,
|
||||
directory: string
|
||||
) => Promise<boolean>,
|
||||
_isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
) => state.enrichedCandidates
|
||||
),
|
||||
verifyAssetSupportedCandidates: vi.fn(
|
||||
@@ -95,6 +104,10 @@ vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => mockHandles.executionErrorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
useModelStore: () => mockHandles.modelStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => mockHandles.modelToNodeStore
|
||||
}))
|
||||
@@ -108,8 +121,16 @@ vi.mock('@/platform/missingModel/missingModelScan', () => ({
|
||||
mockHandles.scanAllModelCandidates(graph, isAssetSupported, getDirectory),
|
||||
enrichWithEmbeddedMetadata: (
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: ComfyWorkflowJSON
|
||||
) => mockHandles.enrichWithEmbeddedMetadata(candidates, graphData),
|
||||
graphData: ComfyWorkflowJSON,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
) =>
|
||||
mockHandles.enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
checkModelInstalled,
|
||||
isAssetSupported
|
||||
),
|
||||
verifyAssetSupportedCandidates: (
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
signal: AbortSignal
|
||||
@@ -165,6 +186,8 @@ describe('missingModelPipeline', () => {
|
||||
mockHandles.missingModelStore.createVerificationAbortController.mockImplementation(
|
||||
() => new AbortController()
|
||||
)
|
||||
mockHandles.modelStore.loadModelFolders.mockResolvedValue(undefined)
|
||||
mockHandles.modelStore.getLoadedModelFolder.mockResolvedValue(undefined)
|
||||
mockHandles.modelToNodeStore.getCategoryForNodeType.mockReturnValue(
|
||||
undefined
|
||||
)
|
||||
@@ -230,7 +253,9 @@ describe('missingModelPipeline', () => {
|
||||
|
||||
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ models: activeModels })
|
||||
expect.objectContaining({ models: activeModels }),
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
expect(
|
||||
mockHandles.executionErrorStore.surfaceMissingModels
|
||||
@@ -280,7 +305,9 @@ describe('missingModelPipeline', () => {
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
})
|
||||
}),
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
expect(
|
||||
mockHandles.executionErrorStore.surfaceMissingModels
|
||||
@@ -298,7 +325,9 @@ describe('missingModelPipeline', () => {
|
||||
|
||||
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
graphData
|
||||
graphData,
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
|
||||
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -120,7 +121,20 @@ export async function runMissingModelPipeline({
|
||||
getDirectory
|
||||
)
|
||||
|
||||
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
const enrichedAll = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
async (name, directory) => {
|
||||
const folder = await modelStore.getLoadedModelFolder(directory)
|
||||
const models = folder?.models
|
||||
return !!(
|
||||
models && Object.values(models).some((m) => m.file_name === name)
|
||||
)
|
||||
},
|
||||
isCloud ? isAssetBrowserWidget : undefined
|
||||
)
|
||||
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
|
||||
// scans only checked each node's own mode; the cascade from an
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
verifyAssetSupportedCandidates,
|
||||
MODEL_FILE_EXTENSIONS
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
@@ -669,8 +671,11 @@ function makeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysMissing = async () => false
|
||||
const alwaysInstalled = async () => true
|
||||
|
||||
describe('enrichWithEmbeddedMetadata', () => {
|
||||
it('enriches existing candidate with url and directory from embedded metadata', () => {
|
||||
it('enriches existing candidate with url and directory from embedded metadata', async () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -704,14 +709,18 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result[0].url).toBe('https://example.com/model_a')
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
expect(result[0].hash).toBe('abc123')
|
||||
})
|
||||
|
||||
it('does not overwrite existing fields on candidate', () => {
|
||||
it('does not overwrite existing fields on candidate', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('model_a.safetensors', {
|
||||
directory: 'existing_dir',
|
||||
@@ -748,13 +757,18 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
// ??= should not overwrite existing values
|
||||
expect(result[0].url).toBe('https://existing.com')
|
||||
expect(result[0].directory).toBe('existing_dir')
|
||||
})
|
||||
|
||||
it('does not mutate the original candidates array', () => {
|
||||
it('does not mutate the original candidates array', async () => {
|
||||
const candidates = [makeCandidate('model_a.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -787,12 +801,12 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
})
|
||||
|
||||
const originalUrl = candidates[0].url
|
||||
enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
|
||||
|
||||
expect(candidates[0].url).toBe(originalUrl)
|
||||
})
|
||||
|
||||
it('does not add a candidate for embedded metadata without a live candidate', () => {
|
||||
it('adds new candidate for embedded model not found by COMBO scan', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
@@ -824,12 +838,18 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('model_a.safetensors')
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add a candidate from root metadata without live references', () => {
|
||||
it('does not add candidate when model is already installed', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 0,
|
||||
@@ -849,12 +869,117 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysInstalled
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enriches existing candidates from node-sourced metadata', () => {
|
||||
it('skips embedded models from muted nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 2, // NEVER (muted)
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
|
||||
// Regression: a previous `hasActiveNodes` check kept workflow-level
|
||||
// models in a mixed graph if ANY active node existed, even when every
|
||||
// node that actually referenced the model was bypassed. The correct
|
||||
// check drops unmatched workflow-level entries since candidates are
|
||||
// derived from active-node widgets.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4, // BYPASS — only node referencing the model
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'KSampler',
|
||||
pos: [200, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0, // ALWAYS — unrelated active node
|
||||
properties: {},
|
||||
widgets_values: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
|
||||
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
|
||||
// the workflow-level filter. This ensures the simplification does not
|
||||
// over-filter legitimate per-node missing models.
|
||||
const candidates = [
|
||||
makeCandidate('node_model.safetensors', { nodeId: '1' })
|
||||
]
|
||||
@@ -890,14 +1015,18 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBe('https://example.com/node_model')
|
||||
expect(result[0].name).toBe('node_model.safetensors')
|
||||
})
|
||||
|
||||
it('does not enrich from muted node metadata', () => {
|
||||
const candidates = [makeCandidate('model.safetensors')]
|
||||
it('skips embedded models from bypassed nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -909,16 +1038,8 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 2,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
mode: 4, // BYPASS
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
@@ -927,152 +1048,58 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not enrich from bypassed node metadata', () => {
|
||||
const candidates = [makeCandidate('model.safetensors')]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
models: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ state: 'muted', ancestorMode: 2 },
|
||||
{ state: 'bypassed', ancestorMode: 4 }
|
||||
])(
|
||||
'does not enrich from metadata inside a $state ancestor subgraph',
|
||||
({ ancestorMode }) => {
|
||||
const candidates = [
|
||||
makeCandidate('shared_model.safetensors', {
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
})
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'shared_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'InactiveSubgraph',
|
||||
pos: [200, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: ancestorMode,
|
||||
properties: {},
|
||||
widgets_values: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'InactiveSubgraph',
|
||||
name: 'InactiveSubgraph',
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'shared_model.safetensors',
|
||||
url: 'https://example.com/inactive-branch',
|
||||
directory: 'checkpoints',
|
||||
hash: 'inactive-hash',
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: {
|
||||
ckpt_name: 'shared_model.safetensors'
|
||||
}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
inputNode: {},
|
||||
outputNode: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result[0].url).toBeUndefined()
|
||||
expect(result[0].hash).toBeUndefined()
|
||||
expect(result[0].hashType).toBeUndefined()
|
||||
}
|
||||
)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not enrich candidates from different-directory metadata', () => {
|
||||
const candidates = [
|
||||
makeCandidate('collide_model.safetensors', {
|
||||
directory: 'checkpoints'
|
||||
})
|
||||
]
|
||||
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
|
||||
// Interior properties.models references the workflow-level model
|
||||
// but its widget value does not — forcing the workflow-level entry
|
||||
// down the unmatched path where isModelReferencedByActiveNode
|
||||
// decides. Previously the helper ignored the bypassed container.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
|
||||
// Positive control for the bypassed case above: identical fixture
|
||||
// with container mode=0 must still surface the unmatched workflow-
|
||||
// level model. Guards against a regression where the ancestor gate
|
||||
// drops every workflow-level entry regardless of context.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('rare_model.safetensors')
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when interior reference is under a different directory', async () => {
|
||||
// Same name, different directory: the interior's properties.models
|
||||
// entry is not the same asset as the workflow-level entry, so the
|
||||
// fallback helper must not treat it as a reference that keeps the
|
||||
// workflow-level model alive.
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -1105,19 +1132,43 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
url: 'https://example.com/collide',
|
||||
directory: 'loras'
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result[0].url).toBeUndefined()
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
it('does not detect embedded models without prior candidate scan', () => {
|
||||
it('scanAllModelCandidates returns empty array when not called (simulating isCloud === false guard)', () => {
|
||||
// In the app, when isCloud is false, scanAllModelCandidates is not called
|
||||
// and an empty array is used instead. This test verifies the OSS path
|
||||
// starts with an empty candidates list.
|
||||
const isCloud = false
|
||||
const graph = makeGraph([
|
||||
makeNode(1, 'CheckpointLoaderSimple', [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [])
|
||||
])
|
||||
])
|
||||
|
||||
const modelCandidates = isCloud
|
||||
? scanAllModelCandidates(graph, noAssetSupport)
|
||||
: []
|
||||
|
||||
expect(modelCandidates).toEqual([])
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata detects missing embedded models without prior COMBO scan (OSS dialog path)', async () => {
|
||||
// OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
|
||||
// missing embedded models so the dialog can show them.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
@@ -1165,15 +1216,67 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((c) => c.isMissing === true)).toBe(true)
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'detail_enhancer.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('enriches live OSS candidates for dialog filtering', () => {
|
||||
const candidates: MissingModelCandidate[] = [
|
||||
makeCandidate('missing_model.safetensors')
|
||||
]
|
||||
it('enrichWithEmbeddedMetadata sets isMissing=true when isAssetSupported is not provided (OSS)', async () => {
|
||||
// When isAssetSupported is omitted (OSS), unmatched embedded models
|
||||
// should have isMissing=true (not undefined), enabling the dialog.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'missing_model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
expect(result[0].isAssetSupported).toBe(false)
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
@@ -1209,13 +1312,64 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const result = enrichWithEmbeddedMetadata(candidates, graphData)
|
||||
const selectiveInstallCheck = async (name: string) =>
|
||||
name === 'installed_model.safetensors'
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
selectiveInstallCheck
|
||||
)
|
||||
|
||||
const dialogModels = result.filter((c) => c.isMissing === true && c.url)
|
||||
expect(dialogModels).toHaveLength(1)
|
||||
expect(dialogModels[0].name).toBe('missing_model.safetensors')
|
||||
expect(dialogModels[0].url).toBe('https://example.com/model')
|
||||
})
|
||||
|
||||
it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing,
|
||||
() => true
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBeUndefined()
|
||||
expect(result[0].isAssetSupported).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
|
||||
import type { MissingModelCandidate, MissingModelViewModel } from './types'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel,
|
||||
EmbeddedModelWithSource
|
||||
} from './types'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
@@ -13,13 +17,13 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -66,10 +70,6 @@ function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
|
||||
function isInactiveMode(mode: number | undefined): boolean {
|
||||
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
||||
}
|
||||
|
||||
// Full set of model file extensions used for scanning candidate widgets.
|
||||
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
|
||||
// which restricts which files are eligible for download.
|
||||
@@ -111,7 +111,11 @@ export function scanAllModelCandidates(
|
||||
// Skip subgraph container nodes: their promoted widgets are synthetic
|
||||
// views of interior widgets, which are already scanned via recursion.
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
if (isInactiveMode(node.mode)) continue
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
candidates.push(
|
||||
...scanNodeModelCandidates(
|
||||
@@ -213,12 +217,14 @@ function scanComboWidget(
|
||||
}
|
||||
}
|
||||
|
||||
export function enrichWithEmbeddedMetadata(
|
||||
export async function enrichWithEmbeddedMetadata(
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: MissingModelWorkflowData
|
||||
): MissingModelCandidate[] {
|
||||
graphData: MissingModelWorkflowData,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
): Promise<MissingModelCandidate[]> {
|
||||
const allNodes = flattenWorkflowNodes(graphData)
|
||||
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
|
||||
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
|
||||
|
||||
const enriched = candidates.map((c) => ({ ...c }))
|
||||
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
||||
@@ -234,7 +240,7 @@ export function enrichWithEmbeddedMetadata(
|
||||
else candidatesByKey.set(nameKey, [c])
|
||||
}
|
||||
|
||||
const deduped: ModelFile[] = []
|
||||
const deduped: EmbeddedModelWithSource[] = []
|
||||
const enrichedKeys = new Set<string>()
|
||||
for (const model of embeddedModels) {
|
||||
const dedupeKey = `${model.name}::${model.directory}`
|
||||
@@ -243,60 +249,195 @@ export function enrichWithEmbeddedMetadata(
|
||||
deduped.push(model)
|
||||
}
|
||||
|
||||
const unmatched: EmbeddedModelWithSource[] = []
|
||||
for (const model of deduped) {
|
||||
const dirKey = `${model.name}::${model.directory}`
|
||||
const exact = candidatesByKey.get(dirKey)
|
||||
const fallback = candidatesByKey.get(model.name)
|
||||
const existing = exact?.length ? exact : fallback
|
||||
if (!existing) continue
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
if (existing) {
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
}
|
||||
} else {
|
||||
unmatched.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow-level entries (sourceNodeType === '') survive only when
|
||||
// some active (non-muted, non-bypassed) node actually references the
|
||||
// model — not merely because any unrelated active node exists. A
|
||||
// reference is any widget value (or node.properties.models entry)
|
||||
// that matches the model name on an active node.
|
||||
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
||||
// otherwise rebuild it on every unmatched entry.
|
||||
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
||||
const activeUnmatched = unmatched.filter(
|
||||
(m) =>
|
||||
m.sourceNodeType !== '' ||
|
||||
isModelReferencedByActiveNode(
|
||||
m.name,
|
||||
m.directory,
|
||||
allNodes,
|
||||
flattenedNodeById
|
||||
)
|
||||
)
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
activeUnmatched.map(async (model) => {
|
||||
const installed = await checkModelInstalled(model.name, model.directory)
|
||||
if (installed) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported
|
||||
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
||||
: false
|
||||
|
||||
return {
|
||||
nodeId: model.sourceNodeId,
|
||||
nodeType: model.sourceNodeType,
|
||||
widgetName: model.sourceWidgetName,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
hash: model.hash,
|
||||
hashType: model.hash_type,
|
||||
isMissing: nodeIsAssetSupported ? undefined : true
|
||||
} satisfies MissingModelCandidate
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] checkModelInstalled failed:',
|
||||
r.reason
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (r.value) enriched.push(r.value)
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
function collectEmbeddedModels(
|
||||
function isModelReferencedByActiveNode(
|
||||
modelName: string,
|
||||
modelDirectory: string | undefined,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
||||
|
||||
// Require directory agreement when both sides specify one, so a
|
||||
// same-name entry under a different folder does not keep an
|
||||
// unrelated workflow-level model alive as missing.
|
||||
const embeddedModels = (
|
||||
node.properties as
|
||||
| { models?: Array<{ name: string; directory?: string }> }
|
||||
| undefined
|
||||
)?.models
|
||||
if (
|
||||
embeddedModels?.some(
|
||||
(m) =>
|
||||
m.name === modelName &&
|
||||
(modelDirectory === undefined ||
|
||||
m.directory === undefined ||
|
||||
m.directory === modelDirectory)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// widgets_values carries only the name, so directory cannot be
|
||||
// checked here — fall back to filename matching.
|
||||
const values = node.widgets_values
|
||||
if (!values) continue
|
||||
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
||||
for (const v of valueArray) {
|
||||
if (typeof v === 'string' && v === modelName) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isAncestorPathActiveInFlattened(
|
||||
executionId: string,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const ancestorId of getParentExecutionIds(executionId)) {
|
||||
const ancestor = nodeById.get(ancestorId)
|
||||
if (!ancestor) continue
|
||||
if (
|
||||
ancestor.mode === LGraphEventMode.NEVER ||
|
||||
ancestor.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: MissingModelWorkflowData
|
||||
): ModelFile[] {
|
||||
const result: ModelFile[] = []
|
||||
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
|
||||
): EmbeddedModelWithSource[] {
|
||||
const result: EmbeddedModelWithSource[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (!isNodeAndAncestorsActive(node, nodesById)) continue
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const selected = getSelectedModelsMetadata(node)
|
||||
if (!selected?.length) continue
|
||||
|
||||
result.push(...selected)
|
||||
for (const model of selected) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeId: node.id,
|
||||
sourceNodeType: node.type,
|
||||
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (graphData.models?.length) result.push(...graphData.models)
|
||||
// Workflow-level model entries have no originating node; sourceNodeId
|
||||
// remains undefined and empty-string node type/widget are handled by
|
||||
// groupCandidatesByName (no nodeId → no referencing node entry).
|
||||
if (graphData.models?.length) {
|
||||
for (const model of graphData.models) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeType: '',
|
||||
sourceWidgetName: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isNodeAndAncestorsActive(
|
||||
function findWidgetNameForModel(
|
||||
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
||||
nodesById: ReadonlyMap<
|
||||
string,
|
||||
ReturnType<typeof flattenWorkflowNodes>[number]
|
||||
>
|
||||
): boolean {
|
||||
if (isInactiveMode(node.mode)) return false
|
||||
|
||||
for (const ancestorId of getParentExecutionIds(String(node.id))) {
|
||||
const ancestor = nodesById.get(ancestorId)
|
||||
if (isInactiveMode(ancestor?.mode)) return false
|
||||
modelName: string
|
||||
): string {
|
||||
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
||||
for (const [key, val] of Object.entries(node.widgets_values)) {
|
||||
if (val === modelName) return key
|
||||
}
|
||||
|
||||
return true
|
||||
return ''
|
||||
}
|
||||
|
||||
interface AssetVerifier {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
/**
|
||||
* A single (node, widget, model) binding detected by the missing model pipeline.
|
||||
@@ -25,6 +28,13 @@ export interface MissingModelCandidate {
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
export interface EmbeddedModelWithSource extends ModelFile {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
sourceNodeId?: NodeId
|
||||
sourceNodeType: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single model name. */
|
||||
export interface MissingModelViewModel {
|
||||
name: string
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface Member {
|
||||
email: string
|
||||
joined_at: string
|
||||
role: WorkspaceRole
|
||||
// True when this member is the workspace's original owner/creator
|
||||
// (member.id == workspace.created_by_user_id). Gates the creator-only
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
|
||||
@@ -113,7 +113,11 @@
|
||||
button-variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
v-if="
|
||||
showSubscribeAction &&
|
||||
!isPersonalWorkspace &&
|
||||
(!isCancelled || permissions.canManageSubscriptionLifecycle)
|
||||
"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
|
||||
@@ -128,9 +128,10 @@
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@@ -161,7 +162,7 @@
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan"
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -513,15 +514,23 @@ const subscriptionTierName = computed(() => {
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
|
||||
// and the "more options" button is hidden (see template).
|
||||
const planMenuItems = computed(() =>
|
||||
permissions.value.canManageSubscriptionLifecycle
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
|
||||
@@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
@@ -153,6 +154,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useMembersPanel() {
|
||||
name: userDisplayName.value ?? '',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
}))
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
activeWorkspace: null as WorkspaceWithRole | null,
|
||||
isCurrentUserOriginalOwner: false,
|
||||
ensureMembersLoaded: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
return mockStore.activeWorkspace
|
||||
},
|
||||
get isCurrentUserOriginalOwner() {
|
||||
return mockStore.isCurrentUserOriginalOwner
|
||||
},
|
||||
ensureMembersLoaded: mockStore.ensureMembersLoaded
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -46,14 +52,20 @@ async function loadComposable() {
|
||||
return module.useWorkspaceUI()
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
mockStore.activeWorkspace = null
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
mockStore.ensureMembersLoaded.mockReset()
|
||||
}
|
||||
|
||||
describe('useWorkspaceUI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('when no active workspace', () => {
|
||||
@@ -71,7 +83,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = personalWorkspace
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
})
|
||||
|
||||
it('grants billing access but disables team management', async () => {
|
||||
@@ -119,7 +131,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as owner', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
})
|
||||
|
||||
it('grants full management permissions', async () => {
|
||||
@@ -159,7 +171,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as member', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamMemberWorkspace
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
})
|
||||
|
||||
it('restricts management actions while allowing leave', async () => {
|
||||
@@ -195,9 +207,60 @@ describe('useWorkspaceUI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Drives off the members-list self-row original-owner signal, surfaced by the
|
||||
// store getter `isCurrentUserOriginalOwner`.
|
||||
describe('subscription lifecycle (creator-only)', () => {
|
||||
it('grants lifecycle to the personal-workspace sole owner', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('grants lifecycle to a team owner who is the original owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = true
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed while the members list is still loading', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from members', async () => {
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates member loading to the store when a team workspace becomes active', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared instance', () => {
|
||||
it('returns the same composable state for multiple callers within a test', async () => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
const first = await loadComposable()
|
||||
const second = await loadComposable()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -14,6 +14,10 @@ interface WorkspacePermissions {
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
|
||||
// Any owner has `canManageSubscription` (manage payment, top-up, change
|
||||
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
@@ -34,7 +38,8 @@ interface WorkspaceUIConfig {
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
@@ -46,6 +51,8 @@ function getPermissions(
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -60,6 +67,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -74,6 +82,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
}
|
||||
@@ -145,8 +154,26 @@ function useWorkspaceUIInternal() {
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
// The original-owner signal lives on the members-list self-row, so a team
|
||||
// workspace's members must be loaded before its lifecycle gate can resolve.
|
||||
// The store dedupes in-flight/already-loaded requests and logs failures;
|
||||
// until members arrive the getter fails closed.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
() => {
|
||||
if (store.activeWorkspace?.type === 'team') {
|
||||
void store.ensureMembersLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
getPermissions(
|
||||
workspaceType.value,
|
||||
workspaceRole.value,
|
||||
store.isCurrentUserOriginalOwner
|
||||
)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
|
||||
@@ -29,6 +29,15 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock current user (drives the original-owner self-row match by email)
|
||||
const mockCurrentUser = vi.hoisted(() => ({
|
||||
userEmail: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
|
||||
}))
|
||||
|
||||
// Mock workspaceApi
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
sessionStorage.clear()
|
||||
mockCurrentUser.userEmail.value = null
|
||||
|
||||
// Reset workspaceAuthStore mock state
|
||||
mockWorkspaceAuthStore.currentWorkspace = null
|
||||
@@ -680,6 +690,193 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureMembersLoaded', () => {
|
||||
const memberRow = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function mockMembersResponse() {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [memberRow],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTeamWorkspace() {
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
return store
|
||||
}
|
||||
|
||||
it('loads members for a team workspace that is not yet loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
expect(store.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not load members again once loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls into a single request', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await Promise.all([
|
||||
store.ensureMembersLoaded(),
|
||||
store.ensureMembersLoaded()
|
||||
])
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failed request and retries on the next call', async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
expect(store.members).toHaveLength(0)
|
||||
|
||||
mockMembersResponse()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
|
||||
expect(store.members).toHaveLength(1)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCurrentUserOriginalOwner', () => {
|
||||
async function loadTeamWithMembers(
|
||||
members: Array<{
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
is_original_owner?: boolean
|
||||
}>
|
||||
) {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
await store.fetchMembers()
|
||||
return store
|
||||
}
|
||||
|
||||
const ownerSelf = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z',
|
||||
role: 'owner' as const,
|
||||
is_original_owner: true
|
||||
}
|
||||
const promotedSelf = { ...ownerSelf, is_original_owner: false }
|
||||
|
||||
it('is true when the self-row is the original owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the self-row by email case-insensitively', async () => {
|
||||
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the self-row is a promoted (non-creator) owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([promotedSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the self-row omits is_original_owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
|
||||
const store = await loadTeamWithMembers([selfWithoutFlag])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when no member row matches the current user', async () => {
|
||||
mockCurrentUser.userEmail.value = 'someone-else@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when members are not loaded', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the current user email is unknown', async () => {
|
||||
mockCurrentUser.userEmail.value = null
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('recomputes reactively when the self-row arrives after an empty read', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [ownerSelf],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
|
||||
await store.fetchMembers()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite actions', () => {
|
||||
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||
const mockInvites = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
@@ -21,6 +22,7 @@ export interface WorkspaceMember {
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,18 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
() => activeWorkspace.value?.members ?? []
|
||||
)
|
||||
|
||||
// True when the current user is the active workspace's original owner,
|
||||
// resolved from the self-row of the loaded members list. Matches by email
|
||||
// (the stable current-user join key; member.id is a cloud user id, not the
|
||||
// Firebase uid). Fails closed when members are not loaded or no self-row
|
||||
// matches, so lifecycle gating stays hidden until the real signal arrives.
|
||||
const isCurrentUserOriginalOwner = computed(() => {
|
||||
const email = useCurrentUser().userEmail.value?.toLowerCase()
|
||||
if (!email) return false
|
||||
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
|
||||
return selfRow?.isOriginalOwner ?? false
|
||||
})
|
||||
|
||||
const pendingInvites = computed<PendingInvite[]>(
|
||||
() => activeWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
@@ -507,6 +522,36 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
return members
|
||||
}
|
||||
|
||||
// Tracks which team workspaces have already loaded their members so the
|
||||
// lifecycle gate resolves without redundant or duplicate fetches.
|
||||
const loadedMemberWorkspaceIds = new Set<string>()
|
||||
let inFlightMembersWorkspaceId: string | null = null
|
||||
|
||||
/**
|
||||
* Load the active team workspace's members once. No-ops for personal or
|
||||
* already-loaded workspaces and dedupes concurrent calls. A failed request is
|
||||
* logged and leaves the workspace unloaded so a later call retries.
|
||||
*/
|
||||
async function ensureMembersLoaded(): Promise<void> {
|
||||
const workspaceId = activeWorkspaceId.value
|
||||
if (!workspaceId) return
|
||||
if (activeWorkspace.value?.type === 'personal') return
|
||||
if (loadedMemberWorkspaceIds.has(workspaceId)) return
|
||||
if (inFlightMembersWorkspaceId === workspaceId) return
|
||||
|
||||
inFlightMembersWorkspaceId = workspaceId
|
||||
try {
|
||||
await fetchMembers()
|
||||
loadedMemberWorkspaceIds.add(workspaceId)
|
||||
} catch (e) {
|
||||
console.error('Failed to load workspace members', e)
|
||||
} finally {
|
||||
if (inFlightMembersWorkspaceId === workspaceId) {
|
||||
inFlightMembersWorkspaceId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
*/
|
||||
@@ -652,6 +697,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
isCurrentUserOriginalOwner,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
@@ -675,6 +721,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
ensureMembersLoaded,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
|
||||
Reference in New Issue
Block a user