mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
18 Commits
refactor/i
...
codex/back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52828c25d | ||
|
|
e6a751f42f | ||
|
|
f8a3f462b7 | ||
|
|
81229466e1 | ||
|
|
db2d381c89 | ||
|
|
13894beeb2 | ||
|
|
4c4b85bd49 | ||
|
|
1dffc948d7 | ||
|
|
b30454ac4f | ||
|
|
b1b6394345 | ||
|
|
efde624926 | ||
|
|
2f48694192 | ||
|
|
c6d9a84aac | ||
|
|
4a30b51bee | ||
|
|
d891fafc3d | ||
|
|
b585afcd9c | ||
|
|
5cb36fea91 | ||
|
|
6d1221bc2f |
@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -217,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -20,43 +85,20 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -75,7 +76,15 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
28
browser_tests/fixtures/utils/selectionToolboxMoreOptions.ts
Normal file
28
browser_tests/fixtures/utils/selectionToolboxMoreOptions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export async function openMoreOptionsMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(`No "${nodeTitle}" nodes found`)
|
||||
}
|
||||
|
||||
await nodes[0].centerOnNode()
|
||||
await nodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
47
browser_tests/tests/i18nLocaleFallback.spec.ts
Normal file
47
browser_tests/tests/i18nLocaleFallback.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
|
||||
//
|
||||
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
|
||||
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
|
||||
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
|
||||
// must render translated strings, not literal i18n keys like
|
||||
// 'sideToolbar.labels.assets'.
|
||||
test.describe('i18n locale fallback', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de-DE', 'de'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
// Default sidebar size on small viewports hides labels; force normal so
|
||||
// .side-bar-button-label is rendered for the assertion.
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('sidebar labels render translated strings, not raw i18n keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
|
||||
const labelTexts = await page
|
||||
.getByTestId('side-toolbar')
|
||||
.locator('.side-bar-button-label')
|
||||
.allTextContents()
|
||||
|
||||
expect(labelTexts.length).toBeGreaterThan(0)
|
||||
for (const text of labelTexts) {
|
||||
expect(text).not.toContain('sideToolbar.labels')
|
||||
}
|
||||
})
|
||||
})
|
||||
65
browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts
Normal file
65
browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.describe(
|
||||
'Node context menu shape submenu (FE-570)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
|
||||
const popover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover).toContainText('Box')
|
||||
await expect(popover).toContainText('Card')
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(popoverBox!.width).toBeGreaterThan(0)
|
||||
expect(popoverBox!.height).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('Shape popover opens when the menu fits in the viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
|
||||
.toBe('visible')
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Shape' }).click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
await shapeItem.click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -18,70 +19,19 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
const openMoreOptions = (comfyPage: ComfyPage) =>
|
||||
openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) {
|
||||
throw new Error(
|
||||
'Viewport size is null - page may not be properly initialized'
|
||||
)
|
||||
}
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
})
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
@@ -90,11 +40,14 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
// Shape now opens via body-appended popover (FE-570); a hover no
|
||||
// longer reveals the submenu — match the Color flow and click.
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||
const shapePopover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
|
||||
await shapePopover.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
148
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
148
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.page.goto(
|
||||
new URL(
|
||||
`/?share=${sharedWorkflowImportScenario.shareId}`,
|
||||
comfyPage.url
|
||||
).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,54 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, comfyPage.id)
|
||||
await comfyPage.page.goto(
|
||||
new URL('/?share=test-share-id', comfyPage.url).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
@@ -131,48 +179,51 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
// Pick a shipped LTR locale and simulate its template index returning 404.
|
||||
// (Previously this test used 'de', but unsupported locales are now
|
||||
// clamped to 'en' at boot so they never hit the template fallback path.
|
||||
// 'fa' would also work but flips document.dir to rtl, which can leak
|
||||
// into adjacent specs in the same worker.)
|
||||
const locale = 'tr'
|
||||
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
await comfyPage.page.route(
|
||||
`**/templates/index.${locale}.json`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
||||
|
||||
const localeRequestPromise = comfyPage.page.waitForRequest(
|
||||
`**/templates/index.${locale}.json`
|
||||
)
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const localeRequest = await localeRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
// Assert on rendered content, not just the container — the container
|
||||
// testid is present even when the dialog body is empty, which would let
|
||||
// a regression where the fallback fetch succeeds but no cards render
|
||||
// pass silently.
|
||||
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
|
||||
@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -22,6 +25,61 @@ const ERROR_CLASS = /ring-destructive-background/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -191,6 +249,74 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
13
packages/ingest-types/src/types.gen.ts
generated
13
packages/ingest-types/src/types.gen.ts
generated
@@ -524,9 +524,18 @@ export type ImportPublishedAssetsRequest = {
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
* Optional. Share ID of the published workflow these assets belong to.
|
||||
* When provided (non-null, non-empty): all published_asset_ids must
|
||||
* belong to this share's workflow version; returns
|
||||
* 400/CodeInvalidAssets if the share is not found or any asset does
|
||||
* not belong to it.
|
||||
* When omitted, null, or empty string: no share-scoped validation is
|
||||
* performed and the assets are validated only against global rules
|
||||
* (legacy behaviour, preserved for clients that have not yet adopted
|
||||
* share_id).
|
||||
*
|
||||
*/
|
||||
share_id: string
|
||||
share_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
packages/ingest-types/src/zod.gen.ts
generated
4
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
published_asset_ids: z.array(z.string()),
|
||||
share_id: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
20
src/base/wheelGestures.ts
Normal file
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -10,7 +10,7 @@
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
|
||||
@click="onItemClick($event, item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
@@ -21,20 +21,27 @@
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasSubmenu || item.isColorSubmenu"
|
||||
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<!-- Color picker menu (custom with color circles) -->
|
||||
<ColorPickerMenu
|
||||
<SubmenuPopover
|
||||
v-if="colorOption"
|
||||
ref="colorPickerMenu"
|
||||
key="color-picker-menu"
|
||||
ref="colorSubmenu"
|
||||
key="color-submenu"
|
||||
:option="colorOption"
|
||||
@submenu-click="handleColorSelect"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
|
||||
<SubmenuPopover
|
||||
v-if="shapeOption"
|
||||
ref="shapeSubmenu"
|
||||
key="shape-submenu"
|
||||
:option="shapeOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -54,16 +61,18 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
|
||||
|
||||
interface ExtendedMenuItem extends MenuItem {
|
||||
isColorSubmenu?: boolean
|
||||
isShapeSubmenu?: boolean
|
||||
shortcut?: string
|
||||
originalOption?: MenuOption
|
||||
}
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
@@ -150,21 +159,20 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
// Find color picker option
|
||||
const colorOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isColorPicker)
|
||||
)
|
||||
|
||||
// Check if option is the color picker
|
||||
function isColorOption(option: MenuOption): boolean {
|
||||
return Boolean(option.isColorPicker)
|
||||
}
|
||||
const shapeOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isShapePicker)
|
||||
)
|
||||
|
||||
// Convert MenuOption to PrimeVue MenuItem
|
||||
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
if (option.type === 'divider') return { separator: true }
|
||||
|
||||
const isColor = isColorOption(option)
|
||||
const isColor = Boolean(option.isColorPicker)
|
||||
const isShape = Boolean(option.isShapePicker)
|
||||
const usesPopover = isColor || isShape
|
||||
|
||||
const item: ExtendedMenuItem = {
|
||||
label: option.label,
|
||||
@@ -172,11 +180,14 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
disabled: option.disabled,
|
||||
shortcut: option.shortcut,
|
||||
isColorSubmenu: isColor,
|
||||
isShapeSubmenu: isShape,
|
||||
originalOption: option
|
||||
}
|
||||
|
||||
// Native submenus for non-color options
|
||||
if (option.hasSubmenu && option.submenu && !isColor) {
|
||||
// Submenus opened via popover (color, shape) deliberately omit `items` so
|
||||
// PrimeVue does not render a nested <ul> inside the scrollable root list,
|
||||
// which would be clipped when the menu overflows the viewport (FE-570).
|
||||
if (option.hasSubmenu && option.submenu && !usesPopover) {
|
||||
item.items = option.submenu.map((sub) => ({
|
||||
label: sub.label,
|
||||
icon: sub.icon,
|
||||
@@ -188,7 +199,6 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
}))
|
||||
}
|
||||
|
||||
// Regular action items
|
||||
if (!option.hasSubmenu && option.action) {
|
||||
item.command = () => {
|
||||
option.action?.()
|
||||
@@ -245,17 +255,30 @@ function toggle(event: Event) {
|
||||
|
||||
defineExpose({ toggle, hide, isOpen, show })
|
||||
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
|
||||
if (item.isColorSubmenu) {
|
||||
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
|
||||
} else if (item.isShapeSubmenu) {
|
||||
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle color selection
|
||||
function handleColorSelect(subOption: SubMenuOption) {
|
||||
function openSubmenuPopover(
|
||||
event: MouseEvent,
|
||||
target: InstanceType<typeof SubmenuPopover> | undefined,
|
||||
other: InstanceType<typeof SubmenuPopover> | undefined
|
||||
) {
|
||||
if (!target) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
other?.hide()
|
||||
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
target.toggle(event, anchor)
|
||||
}
|
||||
|
||||
function handleSubmenuSelect(subOption: SubMenuOption) {
|
||||
subOption.action()
|
||||
hide()
|
||||
}
|
||||
@@ -270,11 +293,17 @@ function constrainMenuHeight() {
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
const availableHeight = window.innerHeight - rect.top - 8
|
||||
if (availableHeight <= 0) return
|
||||
|
||||
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
|
||||
// to a non-visible value too (CSS spec), which clips horizontally-opening
|
||||
// submenus like Shape. Only apply the constraint when content truly
|
||||
// overflows so the common case keeps overflow visible.
|
||||
if (rootList.scrollHeight <= availableHeight) return
|
||||
|
||||
rootList.style.maxHeight = `${availableHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -29,6 +30,26 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
>
|
||||
}
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
const defaultSettingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': true
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingValues = {
|
||||
...defaultSettingValues,
|
||||
...overrides
|
||||
}
|
||||
settingGetMock.mockImplementation(
|
||||
(key: string): unknown => settingValues[key] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
@@ -79,10 +100,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||
return null
|
||||
})
|
||||
get: settingGetMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -128,7 +146,7 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
@@ -139,6 +157,7 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSettingValues()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
@@ -231,6 +250,42 @@ describe('SelectionToolbox', () => {
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the new node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show info button when new menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
@@ -105,9 +105,8 @@ const {
|
||||
isSingleImageNode,
|
||||
hasAny3DNodeSelected,
|
||||
hasOutputNodesSelected,
|
||||
nodeDef
|
||||
canOpenNodeInfo
|
||||
} = useSelectionState()
|
||||
const showInfoButton = computed(() => !!nodeDef.value)
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -9,19 +8,20 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
|
||||
openNodeInfoMock: vi.fn(),
|
||||
trackUiButtonClickedMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
openNodeInfo: openNodeInfoMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
trackUiButtonClicked: trackUiButtonClickedMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
openNodeInfoMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -53,12 +53,29 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('should open the info panel on click', async () => {
|
||||
const clickNodeInfoButton = async () => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
}
|
||||
|
||||
it('should open the node info panel on click', async () => {
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track the click when the node info panel is unavailable', async () => {
|
||||
openNodeInfoMock.mockReturnValue(false)
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,18 +15,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { openNodeInfo } = useSelectionState()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-60'
|
||||
class: 'p-popover absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
@@ -90,8 +90,12 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event, target?: HTMLElement) => {
|
||||
popoverRef.value?.toggle(event, target)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
defineExpose({
|
||||
toggle
|
||||
toggle,
|
||||
hide
|
||||
})
|
||||
|
||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
data-testid="node-preview-card"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div
|
||||
|
||||
@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('credits help icon (FE-617)', () => {
|
||||
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
|
||||
renderComponent()
|
||||
|
||||
const helpButton = screen.getByTestId('credits-info-button')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
expect(helpButton.tagName).toBe('BUTTON')
|
||||
expect(helpButton).toHaveAttribute(
|
||||
'aria-label',
|
||||
enMessages.credits.unified.tooltip
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
|
||||
|
||||
@@ -41,10 +41,16 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<i
|
||||
<Button
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
|
||||
@@ -21,6 +21,12 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
@@ -205,6 +211,47 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing media when an upload emits onWidgetChanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
node.type = 'LoadImage'
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
'image',
|
||||
'missing.png',
|
||||
() => undefined,
|
||||
{ values: [] }
|
||||
)
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
||||
mediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'missing.png',
|
||||
isMissing: true
|
||||
} satisfies MissingMediaCandidate
|
||||
])
|
||||
|
||||
node.onWidgetChanged!.call(
|
||||
node,
|
||||
'image',
|
||||
'uploaded.png',
|
||||
'missing.png',
|
||||
widget
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
@@ -347,6 +394,90 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
widget.value = 'fake_model.safetensors'
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
})
|
||||
|
||||
it('scans added-node missing models before the deferred media scan', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockImplementation((_rootGraph, node) => [
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: node.type,
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
const mediaScan = vi
|
||||
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
.mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
graph.add(node)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).toHaveBeenCalledOnce()
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mediaScan).toHaveBeenCalledTimes(1)
|
||||
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mediaScan.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('LoadVideo')
|
||||
node.type = 'LoadVideo'
|
||||
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
node.isUploading = true
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
expect(mediaScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
@@ -543,7 +674,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
@@ -611,7 +742,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
@@ -686,7 +816,7 @@ describe('realtime verification staleness guards', () => {
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
@@ -771,7 +901,6 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
verifyMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -155,25 +155,26 @@ function isNodeInactive(mode: number): boolean {
|
||||
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
||||
}
|
||||
|
||||
/** Scan a single node and add confirmed missing model/media to stores.
|
||||
* For subgraph containers, also scans all active interior nodes. */
|
||||
function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
function scanNodeErrorTargets(
|
||||
node: LGraphNode,
|
||||
scanNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (innerNode.isSubgraphNode?.()) continue
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanSingleNodeErrors(innerNode)
|
||||
scanNode(innerNode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scanSingleNodeErrors(node)
|
||||
scanNode(node)
|
||||
}
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
function getActiveExecutionId(node: LGraphNode): string | null {
|
||||
if (!app.rootGraph) return null
|
||||
// Skip when any enclosing subgraph is muted/bypassed. Callers only
|
||||
// verify each node's own mode; entering a bypassed subgraph (via
|
||||
// useGraphNodeManager replaying onNodeAdded for existing interior
|
||||
@@ -181,7 +182,25 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
|
||||
return execId
|
||||
}
|
||||
|
||||
/** Scan a single node and add confirmed missing model/media to stores.
|
||||
* For subgraph containers, also scans all active interior nodes. */
|
||||
function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
scanNodeErrorTargets(node, scanSingleNodeErrors)
|
||||
}
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
scanSingleNodeModelsAndTypes(node)
|
||||
scanSingleNodeMedia(node)
|
||||
}
|
||||
|
||||
function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
const execId = getActiveExecutionId(node)
|
||||
if (!execId) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
@@ -204,39 +223,40 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
void verifyAndAddPendingModels(pendingModels)
|
||||
}
|
||||
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const store = useMissingNodesErrorStore()
|
||||
const existing = store.missingNodesError?.nodeTypes ?? []
|
||||
store.surfaceMissingNodes([
|
||||
...existing,
|
||||
{
|
||||
type: originalType,
|
||||
nodeId: execId,
|
||||
cnrId: getCnrIdFromNode(node),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function scanSingleNodeMedia(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
if (!getActiveExecutionId(node)) return
|
||||
|
||||
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
|
||||
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
// Cloud media scans return pending for asset verification. OSS scans only
|
||||
// return pending for generated output media.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
}
|
||||
|
||||
// Check for missing node type
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (execId) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const store = useMissingNodesErrorStore()
|
||||
const existing = store.missingNodesError?.nodeTypes ?? []
|
||||
store.surfaceMissingNodes([
|
||||
...existing,
|
||||
{
|
||||
type: originalType,
|
||||
nodeId: execId,
|
||||
cnrId: getCnrIdFromNode(node),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,7 +302,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
@@ -293,10 +313,23 @@ async function verifyAndAddPendingMedia(
|
||||
}
|
||||
}
|
||||
|
||||
function scanAddedNode(node: LGraphNode): void {
|
||||
function scanAddedNode(
|
||||
node: LGraphNode,
|
||||
scanNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
|
||||
if (isNodeInactive(node.mode)) return
|
||||
scanAndAddNodeErrors(node)
|
||||
scanNodeErrorTargets(node, scanNode)
|
||||
}
|
||||
|
||||
function scheduleAddedNodeScan(node: LGraphNode): void {
|
||||
queueMicrotask(() => {
|
||||
scanAddedNode(node, scanSingleNodeModelsAndTypes)
|
||||
// Paste/drop upload handlers run immediately after graph.add and must set
|
||||
// node.isUploading synchronously before their first await. This second
|
||||
// microtask lets that upload state settle before media widgets are scanned.
|
||||
queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
|
||||
})
|
||||
}
|
||||
|
||||
function handleNodeModeChange(
|
||||
@@ -368,10 +401,12 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
// Scan pasted/duplicated nodes for missing models/media.
|
||||
// Skip during loadGraphData (undo/redo/tab switch) — those are
|
||||
// handled by the full pipeline or cache restore.
|
||||
// Deferred to microtask because onNodeAdded fires before
|
||||
// node.configure() restores widget values.
|
||||
// Model and node scans use the original one-microtask deferral so pasted
|
||||
// missing-model errors appear before selection-scoped tabs recalculate.
|
||||
// Media gets one extra microtask so drag/drop upload handlers can mark
|
||||
// transient upload state before media detection reads the widget value.
|
||||
if (!ChangeTracker.isLoadingGraph) {
|
||||
queueMicrotask(() => scanAddedNode(node))
|
||||
scheduleAddedNodeScan(node)
|
||||
}
|
||||
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MenuOption {
|
||||
disabled?: boolean
|
||||
source?: 'litegraph' | 'vue'
|
||||
isColorPicker?: boolean
|
||||
isShapePicker?: boolean
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
@@ -124,8 +125,8 @@ export function useMoreOptionsMenu() {
|
||||
const {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
canOpenNodeInfo,
|
||||
openNodeInfo,
|
||||
hasSubgraphs: hasSubgraphsComputed,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
@@ -243,8 +244,8 @@ export function useMoreOptionsMenu() {
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 4: Node properties (Node Info, Shape, Color)
|
||||
if (nodeDef.value) {
|
||||
options.push(getNodeInfoOption(showNodeHelp))
|
||||
if (canOpenNodeInfo.value) {
|
||||
options.push(getNodeInfoOption(openNodeInfo))
|
||||
}
|
||||
if (groupContext) {
|
||||
options.push(getGroupColorOptions(groupContext, bump))
|
||||
|
||||
@@ -66,6 +66,7 @@ export function useNodeMenuOptions() {
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeSubmenu.value,
|
||||
isShapePicker: true,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
@@ -111,10 +112,10 @@ export function useNodeMenuOptions() {
|
||||
action: runBranch
|
||||
})
|
||||
|
||||
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
|
||||
const getNodeInfoOption = (openNodeInfo: () => boolean): MenuOption => ({
|
||||
label: t('contextMenu.Node Info'),
|
||||
icon: 'icon-[lucide--info]',
|
||||
action: showNodeHelp
|
||||
action: openNodeInfo
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,9 +3,11 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
@@ -13,11 +15,6 @@ import {
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock composables
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn()
|
||||
@@ -39,6 +36,45 @@ const mockConnection = {
|
||||
isNode: false
|
||||
}
|
||||
|
||||
function createMockNodeDef() {
|
||||
return new ComfyNodeDefImpl({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test',
|
||||
input: {},
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
|
||||
function selectSingleNodeWithNodeDef(id: number) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
canvasStore.$state.selectedItems = [
|
||||
createMockLGraphNode({ id, type: 'TestNode' })
|
||||
]
|
||||
vi.mocked(nodeDefStore.fromLGraphNode).mockReturnValue(createMockNodeDef())
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingStore = useSettingStore()
|
||||
const settingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': false,
|
||||
...overrides
|
||||
}
|
||||
|
||||
vi.mocked(settingStore.get).mockImplementation(
|
||||
(key: string): unknown => settingValues[key]
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSelectionState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -49,14 +85,7 @@ describe('useSelectionState', () => {
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
|
||||
// Setup mock composables
|
||||
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||
id: 'node-library-tab',
|
||||
title: 'Node Library',
|
||||
type: 'custom',
|
||||
render: () => null
|
||||
} as ReturnType<typeof useNodeLibrarySidebarTab>)
|
||||
mockSettingValues()
|
||||
|
||||
// Setup mock utility functions
|
||||
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||
@@ -187,4 +216,83 @@ describe('useSelectionState', () => {
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info', () => {
|
||||
test('should open the right side info panel for a selected node', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
selectSingleNodeWithNodeDef(8)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(true)
|
||||
openNodeInfo()
|
||||
|
||||
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
|
||||
})
|
||||
|
||||
test('should not open the right side panel for multiple selected nodes', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.$state.selectedItems = [
|
||||
createMockLGraphNode({ id: 9, type: 'TestNode' }),
|
||||
createMockLGraphNode({ id: 10, type: 'TestNode' })
|
||||
]
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
openNodeInfo()
|
||||
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should open the right side info panel when new menu uses the legacy node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
selectSingleNodeWithNodeDef(11)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(true)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(true)
|
||||
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
|
||||
})
|
||||
|
||||
test('should not open node info when legacy menu uses the new node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
selectSingleNodeWithNodeDef(12)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(false)
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should not open node info when legacy menu uses the legacy node library', () => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
selectSingleNodeWithNodeDef(13)
|
||||
|
||||
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
|
||||
expect(canOpenNodeInfo.value).toBe(false)
|
||||
|
||||
const didOpen = openNodeInfo()
|
||||
|
||||
expect(didOpen).toBe(false)
|
||||
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
@@ -25,9 +23,8 @@ export interface NodeSelectionState {
|
||||
export function useSelectionState() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
@@ -64,7 +61,7 @@ export function useSelectionState() {
|
||||
)
|
||||
|
||||
const hasAny3DNodeSelected = computed(() => {
|
||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
const enable3DViewer = settingStore.get('Comfy.Load3D.3DViewerEnable')
|
||||
return (
|
||||
selectedNodes.value.length === 1 &&
|
||||
selectedNodes.value.some(isLoad3dNode) &&
|
||||
@@ -98,34 +95,24 @@ export function useSelectionState() {
|
||||
const computeSelectionFlags = (): NodeSelectionState =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
|
||||
/** Toggle node help sidebar/panel for the single selected node (if any). */
|
||||
const showNodeHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
const canOpenNodeInfo = computed(
|
||||
() =>
|
||||
Boolean(nodeDef.value) &&
|
||||
settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const isSidebarActive =
|
||||
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
|
||||
const currentHelpNode = nodeHelpStore.currentHelpNode
|
||||
const isSameNodeHelpOpen =
|
||||
isSidebarActive &&
|
||||
nodeHelpStore.isHelpOpen &&
|
||||
currentHelpNode?.nodePath === def.nodePath
|
||||
|
||||
if (isSameNodeHelpOpen) {
|
||||
nodeHelpStore.closeHelp()
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
nodeHelpStore.openHelp(def)
|
||||
const openNodeInfo = () => {
|
||||
if (!canOpenNodeInfo.value) return false
|
||||
rightSidePanelStore.openPanel('info')
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
canOpenNodeInfo,
|
||||
openNodeInfo,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasSingleSelection,
|
||||
|
||||
@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
|
||||
})
|
||||
}
|
||||
|
||||
function createFile(name = 'test.png'): File {
|
||||
return new File(['data'], name, { type: 'image/png' })
|
||||
function createFile(name = 'test.png', type = 'image/png'): File {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
function successResponse(name: string, subfolder?: string) {
|
||||
@@ -95,15 +95,21 @@ describe('useNodeImageUpload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets isUploading true during upload and false after', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
it.for([
|
||||
{ mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
|
||||
{ mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
|
||||
])(
|
||||
'sets isUploading true during $mediaType upload and false after',
|
||||
async ({ filename, mimeType }) => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse(filename))
|
||||
|
||||
const promise = capturedDragOnDrop([createFile()])
|
||||
expect(node.isUploading).toBe(true)
|
||||
const promise = capturedDragOnDrop([createFile(filename, mimeType)])
|
||||
expect(node.isUploading).toBe(true)
|
||||
|
||||
await promise
|
||||
expect(node.isUploading).toBe(false)
|
||||
})
|
||||
await promise
|
||||
expect(node.isUploading).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('clears node.imgs on upload start', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
248
src/extensions/core/uploadAudio.test.ts
Normal file
248
src/extensions/core/uploadAudio.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { mockAddAlert, mockApiURL, mockFetchApi, mockRegisterExtension } =
|
||||
vi.hoisted(() => ({
|
||||
mockAddAlert: vi.fn(),
|
||||
mockApiURL: vi.fn((url: string) => `api:${url}`),
|
||||
mockFetchApi: vi.fn(),
|
||||
mockRegisterExtension: vi.fn()
|
||||
}))
|
||||
|
||||
let capturedDragDrop: ((files: File[]) => Promise<File[] | never[]>) | undefined
|
||||
let capturedFileSelect:
|
||||
| ((files: File[]) => Promise<File[] | never[]>)
|
||||
| undefined
|
||||
let capturedPaste: ((files: File[]) => Promise<File[] | never[]>) | undefined
|
||||
|
||||
type AudioUploadWidget = (node: LGraphNode, inputName: string) => unknown
|
||||
|
||||
vi.mock('extendable-media-recorder', () => ({
|
||||
MediaRecorder: class MockMediaRecorder {}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
|
||||
useNodeDragAndDrop: (
|
||||
_node: LGraphNode,
|
||||
options: { onDrop: typeof capturedDragDrop }
|
||||
) => {
|
||||
capturedDragDrop = options.onDrop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeFileInput', () => ({
|
||||
useNodeFileInput: (
|
||||
_node: LGraphNode,
|
||||
options: { onSelect: typeof capturedFileSelect }
|
||||
) => {
|
||||
capturedFileSelect = options.onSelect
|
||||
return { openFileSelection: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePaste', () => ({
|
||||
useNodePaste: (
|
||||
_node: LGraphNode,
|
||||
options: { onPaste: typeof capturedPaste }
|
||||
) => {
|
||||
capturedPaste = options.onPaste
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: mockAddAlert })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/widgets/utils/audioUtils', () => ({
|
||||
getResourceURL: (subfolder = '', filename = '', type = 'input') =>
|
||||
`/view?filename=${filename}&subfolder=${subfolder}&type=${type}`,
|
||||
splitFilePath: (path: string) => ['', path, 'input']
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: mockApiURL,
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: mockRegisterExtension,
|
||||
rootGraph: { id: 'root' }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByLocatorId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/audioService', () => ({
|
||||
useAudioService: () => ({})
|
||||
}))
|
||||
|
||||
function createFile(name = 'clip.mp3'): File {
|
||||
return new File(['audio'], name, { type: 'audio/mpeg' })
|
||||
}
|
||||
|
||||
function successResponse(name: string, subfolder?: string) {
|
||||
return {
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name, subfolder })
|
||||
}
|
||||
}
|
||||
|
||||
function failResponse(status = 500) {
|
||||
return {
|
||||
status,
|
||||
statusText: 'Server Error'
|
||||
}
|
||||
}
|
||||
|
||||
function createAudioNode() {
|
||||
const audioWidget = {
|
||||
name: 'audio',
|
||||
value: 'previous.mp3',
|
||||
options: { values: ['previous.mp3'] },
|
||||
callback: vi.fn()
|
||||
}
|
||||
const audioUIWidget = {
|
||||
name: 'audioUI',
|
||||
element: document.createElement('audio'),
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
const uploadWidget = { label: '', serialize: true, canvasOnly: false }
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
widgets: [audioWidget, audioUIWidget],
|
||||
isUploading: false,
|
||||
graph: { setDirtyCanvas: vi.fn() },
|
||||
addWidget: vi.fn(() => uploadWidget),
|
||||
onWidgetChanged: vi.fn()
|
||||
})
|
||||
|
||||
return { audioUIWidget, audioWidget, node, uploadWidget }
|
||||
}
|
||||
|
||||
async function loadAudioUploadWidget() {
|
||||
vi.resetModules()
|
||||
mockRegisterExtension.mockClear()
|
||||
await import('./uploadAudio')
|
||||
const extension = mockRegisterExtension.mock.calls
|
||||
.map(([extension]) => extension as ComfyExtension)
|
||||
.find((extension) => extension.name === 'Comfy.UploadAudio')
|
||||
if (!extension)
|
||||
throw new Error('Comfy.UploadAudio extension was not registered')
|
||||
const widgets = await extension.getCustomWidgets!(fromAny({}))
|
||||
return (widgets as Record<string, AudioUploadWidget>).AUDIOUPLOAD
|
||||
}
|
||||
|
||||
describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedDragDrop = undefined
|
||||
capturedFileSelect = undefined
|
||||
capturedPaste = undefined
|
||||
})
|
||||
|
||||
it('sets isUploading while upload is in progress and clears it after success', async () => {
|
||||
const AUDIOUPLOAD = await loadAudioUploadWidget()
|
||||
const { audioWidget, node } = createAudioNode()
|
||||
AUDIOUPLOAD(node, 'upload')
|
||||
|
||||
let resolveUpload: (response: ReturnType<typeof successResponse>) => void
|
||||
mockFetchApi.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const upload = capturedDragDrop!([createFile()])
|
||||
|
||||
expect(node.isUploading).toBe(true)
|
||||
expect(audioWidget.value).toBe('clip.mp3')
|
||||
|
||||
resolveUpload!(successResponse('uploaded.mp3', 'pasted'))
|
||||
await upload
|
||||
|
||||
expect(node.isUploading).toBe(false)
|
||||
expect(audioWidget.value).toBe('pasted/uploaded.mp3')
|
||||
expect(audioWidget.options.values).toContain('pasted/uploaded.mp3')
|
||||
expect(node.onWidgetChanged).toHaveBeenCalledWith(
|
||||
'audio',
|
||||
'pasted/uploaded.mp3',
|
||||
'clip.mp3',
|
||||
audioWidget
|
||||
)
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('rejects concurrent audio uploads without starting another request', async () => {
|
||||
const AUDIOUPLOAD = await loadAudioUploadWidget()
|
||||
const { node } = createAudioNode()
|
||||
AUDIOUPLOAD(node, 'upload')
|
||||
node.isUploading = true
|
||||
|
||||
const result = await capturedDragDrop!([createFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the widget value and clears isUploading when upload fails', async () => {
|
||||
const AUDIOUPLOAD = await loadAudioUploadWidget()
|
||||
const { audioWidget, node } = createAudioNode()
|
||||
AUDIOUPLOAD(node, 'upload')
|
||||
mockFetchApi.mockResolvedValueOnce(failResponse())
|
||||
|
||||
await capturedPaste!([createFile()])
|
||||
|
||||
expect(node.isUploading).toBe(false)
|
||||
expect(audioWidget.value).toBe('previous.mp3')
|
||||
expect(mockAddAlert).toHaveBeenCalledWith('500 - Server Error')
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('rolls back the widget value and clears isUploading when upload throws synchronously', async () => {
|
||||
const AUDIOUPLOAD = await loadAudioUploadWidget()
|
||||
const { audioWidget, node } = createAudioNode()
|
||||
AUDIOUPLOAD(node, 'upload')
|
||||
const error = new Error('Upload failed before request promise')
|
||||
mockFetchApi.mockImplementationOnce(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await capturedDragDrop!([createFile()])
|
||||
|
||||
expect(node.isUploading).toBe(false)
|
||||
expect(audioWidget.value).toBe('previous.mp3')
|
||||
expect(mockAddAlert).toHaveBeenCalledWith(error)
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('returns early when no files are provided', async () => {
|
||||
const AUDIOUPLOAD = await loadAudioUploadWidget()
|
||||
const { node } = createAudioNode()
|
||||
AUDIOUPLOAD(node, 'upload')
|
||||
|
||||
const result = await capturedFileSelect!([])
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(node.isUploading).toBe(false)
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ function updateUIWidget(
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
node: LGraphNode,
|
||||
audioWidget: IStringWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
file: File,
|
||||
@@ -67,6 +68,7 @@ async function uploadFile(
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
const oldValue = audioWidget.value
|
||||
updateUIWidget(
|
||||
audioUIWidget,
|
||||
api.apiURL(getResourceURL(...splitFilePath(path)))
|
||||
@@ -75,6 +77,7 @@ async function uploadFile(
|
||||
audioWidget.value = path
|
||||
// Manually trigger the callback to update VueNodes
|
||||
audioWidget.callback?.(path)
|
||||
node.onWidgetChanged?.(audioWidget.name, path, oldValue, audioWidget)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
@@ -234,10 +237,19 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
if (files?.length) {
|
||||
const previousValue = audioWidget.value
|
||||
audioWidget.value = files[0].name
|
||||
if (!files?.length) return files
|
||||
|
||||
if (node.isUploading) {
|
||||
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
|
||||
return []
|
||||
}
|
||||
|
||||
node.isUploading = true
|
||||
const previousValue = audioWidget.value
|
||||
audioWidget.value = files[0].name
|
||||
try {
|
||||
const success = await uploadFile(
|
||||
node,
|
||||
audioWidget,
|
||||
audioUIWidget,
|
||||
files[0],
|
||||
@@ -246,6 +258,9 @@ app.registerExtension({
|
||||
if (!success) {
|
||||
audioWidget.value = previousValue
|
||||
}
|
||||
} finally {
|
||||
node.isUploading = false
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
109
src/i18n.test.ts
109
src/i18n.test.ts
@@ -1,5 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
|
||||
|
||||
import type * as I18nModule from './i18n'
|
||||
|
||||
let i18n: typeof I18nModule.i18n
|
||||
let loadLocale: typeof I18nModule.loadLocale
|
||||
let mergeCustomNodesI18n: typeof I18nModule.mergeCustomNodesI18n
|
||||
let resolveSupportedLocale: typeof I18nModule.resolveSupportedLocale
|
||||
let setActiveLocale: typeof I18nModule.setActiveLocale
|
||||
|
||||
async function importI18nModule() {
|
||||
const i18nModule = await import('./i18n')
|
||||
i18n = i18nModule.i18n
|
||||
loadLocale = i18nModule.loadLocale
|
||||
mergeCustomNodesI18n = i18nModule.mergeCustomNodesI18n
|
||||
resolveSupportedLocale = i18nModule.resolveSupportedLocale
|
||||
setActiveLocale = i18nModule.setActiveLocale
|
||||
}
|
||||
|
||||
// Mock the JSON imports before importing i18n module
|
||||
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
|
||||
@@ -24,6 +40,7 @@ vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
|
||||
describe('i18n', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
await importI18nModule()
|
||||
})
|
||||
|
||||
describe('mergeCustomNodesI18n', () => {
|
||||
@@ -46,8 +63,6 @@ describe('i18n', () => {
|
||||
})
|
||||
|
||||
it('should store data for not-yet-loaded locales', async () => {
|
||||
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
|
||||
|
||||
// Chinese is not pre-loaded, data should be stored but not merged yet
|
||||
mergeCustomNodesI18n({
|
||||
zh: {
|
||||
@@ -148,7 +163,7 @@ describe('i18n', () => {
|
||||
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
|
||||
// Use fresh module instance to ensure clean state
|
||||
vi.resetModules()
|
||||
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
|
||||
await importI18nModule()
|
||||
|
||||
mergeCustomNodesI18n({
|
||||
zh: { plugin1: { name: '插件1' } }
|
||||
@@ -175,26 +190,88 @@ describe('i18n', () => {
|
||||
it('should not reload already loaded locale', async () => {
|
||||
await loadLocale('zh')
|
||||
await loadLocale('zh')
|
||||
|
||||
// Should complete without error (second call returns early)
|
||||
})
|
||||
|
||||
it('should warn for unsupported locale', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await loadLocale('unsupported-locale')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Locale "unsupported-locale" is not supported'
|
||||
it('should load shipped BCP-47 variants', async () => {
|
||||
await loadLocale('zh-TW')
|
||||
expect(i18n.global.getLocaleMessage('zh-TW')).toEqual(
|
||||
expect.objectContaining({
|
||||
commands: expect.any(Object),
|
||||
nodeDefs: expect.any(Object),
|
||||
settings: expect.any(Object)
|
||||
})
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle concurrent load requests for same locale', async () => {
|
||||
// Start multiple loads concurrently
|
||||
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
|
||||
|
||||
await Promise.all(promises)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveLocale', () => {
|
||||
it('clamps unsupported input to en', async () => {
|
||||
expect(await setActiveLocale('de')).toBe('en')
|
||||
expect(i18n.global.locale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('resolves shipped variants and sets the active locale', async () => {
|
||||
expect(await setActiveLocale('pt-BR')).toBe('pt-BR')
|
||||
expect(i18n.global.locale.value).toBe('pt-BR')
|
||||
// pt is not shipped — pt-BR must not be promoted as a base match
|
||||
expect(await setActiveLocale('pt')).toBe('en')
|
||||
})
|
||||
|
||||
it('honors prioritized navigator.languages', async () => {
|
||||
// First preference unsupported, second shipped — should land on French.
|
||||
expect(await setActiveLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSupportedLocale', () => {
|
||||
it('returns the canonical tag when the input is shipped', () => {
|
||||
expect(resolveSupportedLocale('en')).toBe('en')
|
||||
expect(resolveSupportedLocale('ja')).toBe('ja')
|
||||
expect(resolveSupportedLocale('zh-TW')).toBe('zh-TW')
|
||||
expect(resolveSupportedLocale('pt-BR')).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('matches case-insensitively per BCP-47 and returns canonical casing', () => {
|
||||
// Older browsers / OS configs may emit lowercase region tags.
|
||||
expect(resolveSupportedLocale('pt-br')).toBe('pt-BR')
|
||||
expect(resolveSupportedLocale('PT-BR')).toBe('pt-BR')
|
||||
expect(resolveSupportedLocale('zh-tw')).toBe('zh-TW')
|
||||
expect(resolveSupportedLocale('ZH-TW')).toBe('zh-TW')
|
||||
expect(resolveSupportedLocale('EN')).toBe('en')
|
||||
})
|
||||
|
||||
it('falls back to the base tag when the full tag is unshipped', () => {
|
||||
// de-DE → de (unshipped) → en
|
||||
expect(resolveSupportedLocale('de-DE')).toBe('en')
|
||||
// fr-CA → fr (shipped) → fr
|
||||
expect(resolveSupportedLocale('fr-CA')).toBe('fr')
|
||||
// ko-KR → ko (shipped) → ko
|
||||
expect(resolveSupportedLocale('ko-KR')).toBe('ko')
|
||||
// zh-CN → zh (shipped) → zh (Simplified is the base)
|
||||
expect(resolveSupportedLocale('zh-CN')).toBe('zh')
|
||||
})
|
||||
|
||||
it('falls back to en for unsupported and missing inputs', () => {
|
||||
expect(resolveSupportedLocale('de')).toBe('en')
|
||||
expect(resolveSupportedLocale('it')).toBe('en')
|
||||
expect(resolveSupportedLocale('nl')).toBe('en')
|
||||
expect(resolveSupportedLocale('xx-YY')).toBe('en')
|
||||
expect(resolveSupportedLocale('')).toBe('en')
|
||||
expect(resolveSupportedLocale(undefined)).toBe('en')
|
||||
expect(resolveSupportedLocale(null)).toBe('en')
|
||||
})
|
||||
|
||||
it('walks a prioritized array per RFC 4647 lookup order', () => {
|
||||
// First shipped match wins (de unshipped → fr shipped → fr).
|
||||
expect(resolveSupportedLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
|
||||
// Empty / all-unshipped arrays fall back to en.
|
||||
expect(resolveSupportedLocale([])).toBe('en')
|
||||
expect(resolveSupportedLocale(['de', 'it'])).toBe('en')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
149
src/i18n.ts
149
src/i18n.ts
@@ -1,7 +1,11 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
// ESLint cannot statically resolve dynamic imports with relative paths in template strings,
|
||||
// but these are valid ES module imports that Vite processes correctly at build time.
|
||||
import {
|
||||
getDefaultLocale,
|
||||
localeDefinitions,
|
||||
resolveSupportedLocale
|
||||
} from '@/locales/localeConfig'
|
||||
import type { SupportedLocale } from '@/locales/localeConfig'
|
||||
|
||||
// Import only English locale eagerly as the default/fallback
|
||||
import enCommands from './locales/en/commands.json' with { type: 'json' }
|
||||
@@ -9,6 +13,8 @@ import en from './locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
|
||||
import enSettings from './locales/en/settings.json' with { type: 'json' }
|
||||
|
||||
export { resolveSupportedLocale }
|
||||
|
||||
function buildLocale<
|
||||
M extends Record<string, unknown>,
|
||||
N extends Record<string, unknown>,
|
||||
@@ -23,75 +29,6 @@ function buildLocale<
|
||||
} as M & { nodeDefs: N; commands: C; settings: S }
|
||||
}
|
||||
|
||||
// Locale loader map - dynamically import locales only when needed
|
||||
const localeLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/main.json'),
|
||||
es: () => import('./locales/es/main.json'),
|
||||
fa: () => import('./locales/fa/main.json'),
|
||||
fr: () => import('./locales/fr/main.json'),
|
||||
ja: () => import('./locales/ja/main.json'),
|
||||
ko: () => import('./locales/ko/main.json'),
|
||||
ru: () => import('./locales/ru/main.json'),
|
||||
tr: () => import('./locales/tr/main.json'),
|
||||
zh: () => import('./locales/zh/main.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/main.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR/main.json')
|
||||
}
|
||||
|
||||
const nodeDefsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/nodeDefs.json'),
|
||||
es: () => import('./locales/es/nodeDefs.json'),
|
||||
fa: () => import('./locales/fa/nodeDefs.json'),
|
||||
fr: () => import('./locales/fr/nodeDefs.json'),
|
||||
ja: () => import('./locales/ja/nodeDefs.json'),
|
||||
ko: () => import('./locales/ko/nodeDefs.json'),
|
||||
ru: () => import('./locales/ru/nodeDefs.json'),
|
||||
tr: () => import('./locales/tr/nodeDefs.json'),
|
||||
zh: () => import('./locales/zh/nodeDefs.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/nodeDefs.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR/nodeDefs.json')
|
||||
}
|
||||
|
||||
const commandsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/commands.json'),
|
||||
es: () => import('./locales/es/commands.json'),
|
||||
fa: () => import('./locales/fa/commands.json'),
|
||||
fr: () => import('./locales/fr/commands.json'),
|
||||
ja: () => import('./locales/ja/commands.json'),
|
||||
ko: () => import('./locales/ko/commands.json'),
|
||||
ru: () => import('./locales/ru/commands.json'),
|
||||
tr: () => import('./locales/tr/commands.json'),
|
||||
zh: () => import('./locales/zh/commands.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/commands.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR/commands.json')
|
||||
}
|
||||
|
||||
const settingsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/settings.json'),
|
||||
es: () => import('./locales/es/settings.json'),
|
||||
fa: () => import('./locales/fa/settings.json'),
|
||||
fr: () => import('./locales/fr/settings.json'),
|
||||
ja: () => import('./locales/ja/settings.json'),
|
||||
ko: () => import('./locales/ko/settings.json'),
|
||||
ru: () => import('./locales/ru/settings.json'),
|
||||
tr: () => import('./locales/tr/settings.json'),
|
||||
zh: () => import('./locales/zh/settings.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/settings.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR/settings.json')
|
||||
}
|
||||
|
||||
// Track which locales have been loaded
|
||||
const loadedLocales = new Set<string>(['en'])
|
||||
|
||||
@@ -102,37 +39,33 @@ const loadingLocales = new Map<string, Promise<void>>()
|
||||
const customNodesI18nData: Record<string, unknown> = {}
|
||||
|
||||
/**
|
||||
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
|
||||
* Dynamically load a shipped locale's bundles (nodeDefs, commands, settings).
|
||||
* Callers must pre-resolve untrusted input via `resolveSupportedLocale` or
|
||||
* `setActiveLocale`, which is the boundary helper for arbitrary input.
|
||||
*/
|
||||
export async function loadLocale(locale: string): Promise<void> {
|
||||
export async function loadLocale(locale: SupportedLocale): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise to prevent duplicate loads
|
||||
const existingLoad = loadingLocales.get(locale)
|
||||
if (existingLoad) {
|
||||
return existingLoad
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const nodeDefsLoader = nodeDefsLoaders[locale]
|
||||
const commandsLoader = commandsLoaders[locale]
|
||||
const settingsLoader = settingsLoaders[locale]
|
||||
|
||||
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
|
||||
console.warn(`Locale "${locale}" is not supported`)
|
||||
await existingLoad
|
||||
return
|
||||
}
|
||||
|
||||
const loaders = localeDefinitions[locale].loaders
|
||||
if (!loaders) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create and track the loading promise
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const [main, nodes, commands, settings] = await Promise.all([
|
||||
loader(),
|
||||
nodeDefsLoader(),
|
||||
commandsLoader(),
|
||||
settingsLoader()
|
||||
loaders.main(),
|
||||
loaders.nodeDefs(),
|
||||
loaders.commands(),
|
||||
loaders.settings()
|
||||
])
|
||||
|
||||
const messages = buildLocale(
|
||||
@@ -152,13 +85,33 @@ export async function loadLocale(locale: string): Promise<void> {
|
||||
console.error(`Failed to load locale "${locale}":`, error)
|
||||
throw error
|
||||
} finally {
|
||||
// Clean up the loading promise once complete
|
||||
loadingLocales.delete(locale)
|
||||
}
|
||||
})()
|
||||
|
||||
loadingLocales.set(locale, loadPromise)
|
||||
return loadPromise
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helper for arbitrary locale input (settings, browser preferences):
|
||||
* resolves to a shipped tag, loads it, and updates the active locale.
|
||||
*
|
||||
* Returns the resolved tag so callers can detect a clamp (e.g. a stale stored
|
||||
* `Comfy.Locale` from an older build) and self-heal persisted state.
|
||||
*/
|
||||
export async function setActiveLocale(
|
||||
input: string | readonly string[] | null | undefined
|
||||
): Promise<SupportedLocale> {
|
||||
const resolved = resolveSupportedLocale(input)
|
||||
if (typeof input === 'string' && input && input !== resolved) {
|
||||
// Single warn — gated on a real clamp event, never per missing key — so
|
||||
// stale stored locales surface in logs without re-introducing #1867's spam.
|
||||
console.warn(`Locale "${input}" not shipped; using "${resolved}"`)
|
||||
}
|
||||
await loadLocale(resolved)
|
||||
i18n.global.locale.value = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,18 +132,18 @@ export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
}
|
||||
// Only include English in the initial bundle; other locales lazy-load.
|
||||
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
|
||||
type LocaleMessages = typeof enMessages
|
||||
|
||||
// Type for locale messages - inferred from the English locale structure
|
||||
type LocaleMessages = typeof messages.en
|
||||
const messages: Partial<Record<SupportedLocale, LocaleMessages>> = {
|
||||
en: enMessages
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
escapeParameter: true,
|
||||
messages,
|
||||
|
||||
@@ -35,47 +35,13 @@ module.exports = defineConfig({
|
||||
})
|
||||
```
|
||||
|
||||
#### 1.2 Update `src/platform/settings/constants/coreSettings.ts`
|
||||
#### 1.2 Update `src/locales/localeConfig.ts`
|
||||
|
||||
Add your language to the dropdown options:
|
||||
Add your language to the shared runtime locale definition. This feeds the
|
||||
settings dropdown, supported-locale resolution, and lazy locale loading:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Language',
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: 'en', text: 'English' },
|
||||
{ value: 'zh', text: '中文' },
|
||||
{ value: 'zh-TW', text: '繁體中文 (台灣)' }, // Add your language here
|
||||
{ value: 'ru', text: 'Русский' },
|
||||
{ value: 'ja', text: '日本語' },
|
||||
{ value: 'ko', text: '한국어' },
|
||||
{ value: 'fr', text: 'Français' },
|
||||
{ value: 'es', text: 'Español' }
|
||||
],
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
},
|
||||
```
|
||||
|
||||
#### 1.3 Update `src/i18n.ts`
|
||||
|
||||
Add imports for your new language files:
|
||||
|
||||
```typescript
|
||||
// Add these imports (replace zh-TW with your language code)
|
||||
import zhTWCommands from './locales/zh-TW/commands.json'
|
||||
import zhTW from './locales/zh-TW/main.json'
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
|
||||
import zhTWSettings from './locales/zh-TW/settings.json'
|
||||
|
||||
// Add to the messages object
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings),
|
||||
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
|
||||
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings) // Add this line
|
||||
// ... other languages
|
||||
}
|
||||
'zh-TW': { text: '繁體中文', loaders: loadersFor('zh-TW') }
|
||||
```
|
||||
|
||||
### Step 2: Generate Translation Files
|
||||
@@ -168,7 +134,7 @@ Each language has 4 translation files:
|
||||
|
||||
### Issue: Language not appearing in dropdown
|
||||
|
||||
**Solution**: Check that the language code in `coreSettings.ts` matches your other files exactly
|
||||
**Solution**: Check that the language code in `src/locales/localeConfig.ts` matches your other files exactly
|
||||
|
||||
### Issue: Rate limits during local translation
|
||||
|
||||
|
||||
@@ -3214,6 +3214,7 @@
|
||||
"copyAssetsAndOpen": "Import assets & open workflow",
|
||||
"openWorkflow": "Open workflow",
|
||||
"openWithoutImporting": "Open without importing",
|
||||
"opening": "Opening shared workflow...",
|
||||
"importFailed": "Failed to import workflow assets",
|
||||
"loadError": "Could not load this shared workflow. Please try again later."
|
||||
},
|
||||
|
||||
82
src/locales/localeConfig.ts
Normal file
82
src/locales/localeConfig.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
type LocaleJsonLoader = () => Promise<{
|
||||
default: Record<string, unknown>
|
||||
}>
|
||||
|
||||
type LocaleLoaderBundle = {
|
||||
main: LocaleJsonLoader
|
||||
nodeDefs: LocaleJsonLoader
|
||||
commands: LocaleJsonLoader
|
||||
settings: LocaleJsonLoader
|
||||
}
|
||||
|
||||
type LocaleDefinition = {
|
||||
text: string
|
||||
loaders: LocaleLoaderBundle | null
|
||||
}
|
||||
|
||||
// Vite code-splits each matched module into its own async chunk; only the
|
||||
// resolved locale's bundle is fetched at runtime.
|
||||
const localeFiles = import.meta.glob<{ default: Record<string, unknown> }>(
|
||||
'./*/{main,nodeDefs,commands,settings}.json'
|
||||
)
|
||||
|
||||
function loadersFor(locale: string): LocaleLoaderBundle {
|
||||
return {
|
||||
main: localeFiles[`./${locale}/main.json`],
|
||||
nodeDefs: localeFiles[`./${locale}/nodeDefs.json`],
|
||||
commands: localeFiles[`./${locale}/commands.json`],
|
||||
settings: localeFiles[`./${locale}/settings.json`]
|
||||
}
|
||||
}
|
||||
|
||||
export const localeDefinitions = {
|
||||
en: { text: 'English', loaders: null },
|
||||
zh: { text: '中文', loaders: loadersFor('zh') },
|
||||
'zh-TW': { text: '繁體中文', loaders: loadersFor('zh-TW') },
|
||||
ru: { text: 'Русский', loaders: loadersFor('ru') },
|
||||
ja: { text: '日本語', loaders: loadersFor('ja') },
|
||||
ko: { text: '한국어', loaders: loadersFor('ko') },
|
||||
fr: { text: 'Français', loaders: loadersFor('fr') },
|
||||
es: { text: 'Español', loaders: loadersFor('es') },
|
||||
ar: { text: 'عربي', loaders: loadersFor('ar') },
|
||||
tr: { text: 'Türkçe', loaders: loadersFor('tr') },
|
||||
'pt-BR': { text: 'Português (BR)', loaders: loadersFor('pt-BR') },
|
||||
fa: { text: 'فارسی', loaders: loadersFor('fa') }
|
||||
} as const satisfies Record<string, LocaleDefinition>
|
||||
|
||||
export type SupportedLocale = keyof typeof localeDefinitions
|
||||
|
||||
const SUPPORTED_LOCALES = Object.keys(localeDefinitions) as SupportedLocale[]
|
||||
|
||||
export const SUPPORTED_LOCALE_OPTIONS = SUPPORTED_LOCALES.map((value) => ({
|
||||
value,
|
||||
text: localeDefinitions[value].text
|
||||
}))
|
||||
|
||||
const supportedLocaleByLower = new Map<string, SupportedLocale>(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])
|
||||
)
|
||||
|
||||
function matchSingle(candidate: string): SupportedLocale | undefined {
|
||||
const normalized = candidate.toLowerCase()
|
||||
return (
|
||||
supportedLocaleByLower.get(normalized) ??
|
||||
supportedLocaleByLower.get(normalized.split('-')[0])
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveSupportedLocale(
|
||||
input?: string | readonly string[] | null
|
||||
): SupportedLocale {
|
||||
const candidates = Array.isArray(input) ? input : input ? [input] : []
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const matched = matchSingle(candidate)
|
||||
if (matched) return matched
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function getDefaultLocale(): SupportedLocale {
|
||||
return resolveSupportedLocale(navigator.languages)
|
||||
}
|
||||
27
src/platform/assets/composables/media/useFlatOutputAssets.ts
Normal file
27
src/platform/assets/composables/media/useFlatOutputAssets.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
|
||||
export function useFlatOutputAssets(): IAssetsProvider {
|
||||
const store = useAssetsStore()
|
||||
const {
|
||||
flatOutputAssets,
|
||||
flatOutputLoading,
|
||||
flatOutputError,
|
||||
flatOutputHasMore,
|
||||
flatOutputIsLoadingMore
|
||||
} = storeToRefs(store)
|
||||
|
||||
return {
|
||||
media: flatOutputAssets,
|
||||
loading: flatOutputLoading,
|
||||
error: flatOutputError,
|
||||
fetchMediaList: store.updateFlatOutputs,
|
||||
refresh: store.updateFlatOutputs,
|
||||
loadMore: store.loadMoreFlatOutputs,
|
||||
hasMore: flatOutputHasMore,
|
||||
isLoadingMore: flatOutputIsLoadingMore
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,52 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get graph() {
|
||||
return mockAppGraph.value
|
||||
},
|
||||
get rootGraph() {
|
||||
return mockAppGraph.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
removeNodeOutputs: mockRemoveNodeOutputs,
|
||||
removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: { captureCanvasState: mockCaptureCanvasState }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
|
||||
clearNodePreviewCacheForValues: mockClearNodePreviewCache,
|
||||
findNodesReferencingValues: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
const mockClearWidgetValues = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
|
||||
clearDeletedAssetWidgetValues: mockClearWidgetValues
|
||||
}))
|
||||
|
||||
const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
|
||||
markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
@@ -793,4 +839,120 @@ describe('useMediaAssetActions', () => {
|
||||
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets — FE-230 preview cache clearing', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockReset()
|
||||
mockShowDialog.mockImplementation(
|
||||
(opts: {
|
||||
props: {
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
}) => {
|
||||
void opts.props.onConfirm()
|
||||
}
|
||||
)
|
||||
mockAppGraph.value = { _nodes: [] }
|
||||
})
|
||||
|
||||
it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-match',
|
||||
name: 'foo.png',
|
||||
asset_hash: 'abc123.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const [graphArg, valuesArg, removeArg] =
|
||||
mockClearNodePreviewCache.mock.calls[0]
|
||||
expect(graphArg).toBe(mockAppGraph.value)
|
||||
expect(valuesArg).toEqual(
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
expect(typeof removeArg).toBe('function')
|
||||
|
||||
const sampleNode = { id: 42 }
|
||||
removeArg(sampleNode)
|
||||
expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
|
||||
// Locator is resolved from the node's own graph, not from the raw id —
|
||||
// covers Load Image / Load Video nodes nested inside subgraphs.
|
||||
expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
|
||||
|
||||
expect(mockClearWidgetValues).toHaveBeenCalledWith(
|
||||
mockAppGraph.value,
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
|
||||
expect(mockMarkMissingMedia).toHaveBeenCalledWith(
|
||||
mockAppGraph.value,
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
|
||||
// markMissing + previewCache must run before widget-value clearing,
|
||||
// otherwise findNodesReferencingValues sees blanked widgets and matches
|
||||
// nothing.
|
||||
const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
|
||||
const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
|
||||
const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
|
||||
expect(markOrder).toBeLessThan(clearOrder)
|
||||
expect(cacheOrder).toBeLessThan(clearOrder)
|
||||
|
||||
// Programmatic widget mutation doesn't go through DOM events, so the
|
||||
// workflow won't be flagged as modified unless we capture explicitly.
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
subfolder: 'outputs/2025'
|
||||
})
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-output',
|
||||
name: 'gen.png',
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
|
||||
expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
|
||||
expect(valuesArg.has('gen.png')).toBe(false)
|
||||
expect(valuesArg.has('gen.png [input]')).toBe(false)
|
||||
})
|
||||
|
||||
it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
|
||||
mockDeleteAsset.mockRejectedValue(new Error('boom'))
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-failed',
|
||||
name: 'failed.png',
|
||||
asset_hash: 'failhash.png'
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDeleteAsset).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
|
||||
expect(mockClearWidgetValues).not.toHaveBeenCalled()
|
||||
expect(mockMarkMissingMedia).not.toHaveBeenCalled()
|
||||
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,16 +7,22 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
import { getAssetOutputCount } from '../utils/outputAssetUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
@@ -30,6 +36,35 @@ import { assetService } from '../services/assetService'
|
||||
|
||||
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
|
||||
/**
|
||||
* Canonical widget-value strings that may reference this asset, scoped by the
|
||||
* asset's source type so basenames cannot cross-match across input/output.
|
||||
*
|
||||
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
|
||||
* present in metadata). Input/temp assets emit the bare name plus the explicit
|
||||
* annotation. `asset_hash` is included whenever present, since cloud-stored
|
||||
* assets can be referenced by hash.
|
||||
*/
|
||||
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
const variants: string[] = []
|
||||
const type = getAssetType(asset, 'input')
|
||||
const name = asset.name
|
||||
if (name) {
|
||||
if (type === 'output') {
|
||||
const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
|
||||
const path = subfolder ? `${subfolder}/${name}` : name
|
||||
variants.push(`${path} [output]`)
|
||||
} else if (type === 'temp') {
|
||||
variants.push(`${name} [temp]`)
|
||||
} else {
|
||||
variants.push(name)
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
if (asset.asset_hash) variants.push(asset.asset_hash)
|
||||
return variants
|
||||
}
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -639,6 +674,31 @@ export function useMediaAssetActions() {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
const rootGraph = app.rootGraph
|
||||
if (rootGraph) {
|
||||
const deletedValues = new Set<string>()
|
||||
assetArray.forEach((asset, index) => {
|
||||
if (results[index].status !== 'fulfilled') return
|
||||
for (const value of widgetValueVariantsForAsset(asset)) {
|
||||
deletedValues.add(value)
|
||||
}
|
||||
})
|
||||
if (deletedValues.size > 0) {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
// Order matters: mark + cache-clear both look up nodes by
|
||||
// current widget.value, so they must run before
|
||||
// clearDeletedAssetWidgetValues blanks those values.
|
||||
markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
|
||||
clearNodePreviewCacheForValues(
|
||||
rootGraph,
|
||||
deletedValues,
|
||||
(node) => nodeOutputStore.removeNodeOutputsForNode(node)
|
||||
)
|
||||
clearDeletedAssetWidgetValues(rootGraph, deletedValues)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate model caches for affected categories
|
||||
const modelCategories = new Set<string>()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||
@@ -20,11 +21,11 @@ const zAsset = z.object({
|
||||
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
assets: z.array(zAsset).optional(),
|
||||
total: z.number().optional(),
|
||||
has_more: z.boolean().optional()
|
||||
})
|
||||
const zAssetResponse = zListAssetsResponse
|
||||
.pick({ total: true, has_more: true })
|
||||
.extend({
|
||||
assets: z.array(zAsset)
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetResponse
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
assetService,
|
||||
isBlake3AssetHash,
|
||||
toBlake3AssetHash
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -49,9 +50,10 @@ vi.mock('@/i18n', () => ({
|
||||
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
|
||||
const validBlake3Hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
|
||||
type AssetListResponseOptions = {
|
||||
hasMore?: AssetResponse['has_more']
|
||||
total?: AssetResponse['total']
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
body: unknown,
|
||||
@@ -64,6 +66,13 @@ function buildResponse(
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function buildAssetListResponse(
|
||||
assets: AssetItem[],
|
||||
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
|
||||
): Response {
|
||||
return buildResponse({ assets, total, has_more: hasMore })
|
||||
}
|
||||
|
||||
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
@@ -189,25 +198,6 @@ describe(assetService.getAssetMetadata, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(isBlake3AssetHash, () => {
|
||||
it('accepts only prefixed 64-character blake3 hashes', () => {
|
||||
expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
|
||||
expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
|
||||
true
|
||||
)
|
||||
expect(isBlake3AssetHash('blake3:abc')).toBe(false)
|
||||
expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(toBlake3AssetHash, () => {
|
||||
it('normalizes 64-character blake3 hex values to asset hashes', () => {
|
||||
expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
|
||||
expect(toBlake3AssetHash('abc')).toBeNull()
|
||||
expect(toBlake3AssetHash(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.uploadAssetFromUrl, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -218,7 +208,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -240,7 +230,7 @@ describe(assetService.uploadAssetFromUrl, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
|
||||
)
|
||||
@@ -301,7 +291,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -327,7 +317,7 @@ describe(assetService.uploadAssetFromBase64, () => {
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('hello'))
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
|
||||
@@ -421,17 +411,14 @@ describe(assetService.getAssetModelFolders, () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
|
||||
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
])
|
||||
)
|
||||
|
||||
const folders = await assetService.getAssetModelFolders()
|
||||
@@ -444,6 +431,7 @@ describe(assetService.getAssetModelFolders, () => {
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.has('include_public')).toBe(false)
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -490,14 +478,9 @@ describe(assetService.getAssetsByTag, () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
|
||||
it('forwards include_public=true by default and requests missing-tag exclusion', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
|
||||
]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAssetsByTag('input')
|
||||
@@ -507,6 +490,20 @@ describe(assetService.getAssetsByTag, () => {
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_public')).toBe('true')
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
})
|
||||
|
||||
it('normalizes tag query parameters', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
|
||||
)
|
||||
|
||||
await assetService.getAssetsByTag(' input ')
|
||||
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_tags')).toBe('input')
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -518,17 +515,16 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'c', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -540,63 +536,33 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
|
||||
expect(firstParams.get('include_public')).toBe('true')
|
||||
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
expect(firstParams.get('limit')).toBe('2')
|
||||
expect(firstParams.has('offset')).toBe(false)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('include_public')).toBe('true')
|
||||
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
expect(secondParams.get('limit')).toBe('2')
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('paginates from raw response size before filtering missing-tagged assets', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
validAsset({ id: 'visible', tags: ['input'] }),
|
||||
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
|
||||
if (typeof secondUrl !== 'string') {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [
|
||||
buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
has_more: true
|
||||
})
|
||||
{ hasMore: true }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
|
||||
has_more: false
|
||||
})
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'later-public', tags: ['input'] })
|
||||
])
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -614,12 +580,41 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'missing has_more',
|
||||
body: {
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })],
|
||||
total: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'missing total',
|
||||
body: {
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })],
|
||||
has_more: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'non-boolean has_more',
|
||||
body: {
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })],
|
||||
total: 1,
|
||||
has_more: 'false'
|
||||
}
|
||||
}
|
||||
])('rejects asset responses with $name', async ({ body }) => {
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(body))
|
||||
|
||||
await expect(
|
||||
assetService.getAllAssetsByTag('input', true, { limit: 2 })
|
||||
).rejects.toThrow(/Invalid asset response/)
|
||||
})
|
||||
|
||||
it('passes abort signals through paginated requests', async () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
assets: [validAsset({ id: 'a', tags: ['input'] })]
|
||||
})
|
||||
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
|
||||
)
|
||||
|
||||
await assetService.getAllAssetsByTag('input', true, {
|
||||
@@ -636,12 +631,13 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
const controller = new AbortController()
|
||||
fetchApiMock.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return buildResponse({
|
||||
assets: [
|
||||
return buildAssetListResponse(
|
||||
[
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
]
|
||||
})
|
||||
],
|
||||
{ hasMore: true }
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -666,7 +662,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
validAsset({ id: 'user-input', tags: ['input'] }),
|
||||
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
|
||||
]
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
|
||||
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
|
||||
|
||||
const first = await assetService.getInputAssetsIncludingPublic()
|
||||
const second = await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -685,8 +681,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
@@ -720,7 +716,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(serviceSignal).toBeUndefined()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(second).resolves.toEqual(assets)
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
@@ -750,7 +746,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
await Promise.resolve()
|
||||
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
@@ -770,12 +766,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
const inFlight = assetService.getInputAssetsIncludingPublic()
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
|
||||
resolveResponse(buildResponse({ assets }))
|
||||
resolveResponse(buildAssetListResponse(assets))
|
||||
|
||||
await expect(inFlight).resolves.toEqual(assets)
|
||||
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
|
||||
@@ -788,9 +784,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(null))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.deleteAsset('stale-input')
|
||||
@@ -809,9 +805,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
|
||||
const freshAssets = [uploadedAsset]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(uploadedAsset))
|
||||
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
await assetService.uploadAssetAsync({
|
||||
@@ -827,7 +823,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for pending async input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse(
|
||||
{ task_id: 'task-1', status: 'running' },
|
||||
@@ -849,7 +845,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
it('does not invalidate cached input assets for non-input uploads', async () => {
|
||||
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
|
||||
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
|
||||
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
|
||||
|
||||
await assetService.getInputAssetsIncludingPublic()
|
||||
@@ -863,37 +859,3 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.checkAssetHash, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[200, 'exists'],
|
||||
[404, 'missing'],
|
||||
[400, 'invalid']
|
||||
] as const)('maps %s responses to %s', async (status, expected) => {
|
||||
const hash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
|
||||
|
||||
await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
`/assets/hash/${encodeURIComponent(hash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for unexpected responses', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
|
||||
|
||||
await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
|
||||
'Unexpected asset hash check status: 500'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AssetPaginationOptions extends PaginationOptions {
|
||||
|
||||
interface AssetRequestOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
excludeTags?: string[]
|
||||
includePublic?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
@@ -179,29 +180,16 @@ const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
|
||||
|
||||
/** Result of a HEAD lookup against an exact asset hash. */
|
||||
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
|
||||
|
||||
const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
|
||||
const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
|
||||
const uploadedAssetResponseSchema = assetItemSchema.extend({
|
||||
created_new: z.boolean()
|
||||
})
|
||||
|
||||
/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
|
||||
export function isBlake3AssetHash(value: string): boolean {
|
||||
return BLAKE3_ASSET_HASH_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
|
||||
export function toBlake3AssetHash(hash: string | undefined): string | null {
|
||||
if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
|
||||
return `blake3:${hash}`
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
@@ -210,6 +198,10 @@ function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
}
|
||||
|
||||
function normalizeAssetTags(tags: string[]): string[] {
|
||||
return tags.map((tag) => tag.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
@@ -290,15 +282,22 @@ function createAssetService() {
|
||||
): Promise<AssetResponse> {
|
||||
const {
|
||||
includeTags,
|
||||
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
includePublic,
|
||||
signal
|
||||
} = options
|
||||
const normalizedIncludeTags = normalizeAssetTags(includeTags)
|
||||
const normalizedExcludeTags = normalizeAssetTags(excludeTags)
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
include_tags: includeTags.join(','),
|
||||
include_tags: normalizedIncludeTags.join(','),
|
||||
limit: limit.toString()
|
||||
})
|
||||
if (normalizedExcludeTags.length > 0) {
|
||||
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
|
||||
}
|
||||
if (offset !== undefined && offset > 0) {
|
||||
queryParams.set('offset', offset.toString())
|
||||
}
|
||||
@@ -337,15 +336,10 @@ function createAssetService() {
|
||||
// Blacklist directories we don't want to show
|
||||
const blacklistedDirectories = new Set(['configs'])
|
||||
|
||||
// Extract directory names from assets that actually exist, exclude missing assets
|
||||
const discoveredFolders = new Set<string>(
|
||||
data?.assets
|
||||
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
|
||||
?.flatMap((asset) => asset.tags)
|
||||
?.filter(
|
||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
|
||||
) ?? []
|
||||
)
|
||||
const folderTags = data.assets
|
||||
.flatMap((asset) => asset.tags)
|
||||
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
|
||||
const discoveredFolders = new Set<string>(folderTags)
|
||||
|
||||
// Return only discovered folders in alphabetical order
|
||||
const sortedFolders = Array.from(discoveredFolders).toSorted()
|
||||
@@ -363,17 +357,10 @@ function createAssetService() {
|
||||
`models for ${folder}`
|
||||
)
|
||||
|
||||
return (
|
||||
data?.assets
|
||||
?.filter(
|
||||
(asset) =>
|
||||
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
|
||||
)
|
||||
?.map((asset) => ({
|
||||
name: asset.name,
|
||||
pathIndex: 0
|
||||
})) ?? []
|
||||
)
|
||||
return data.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
pathIndex: 0
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,12 +436,7 @@ function createAssetService() {
|
||||
)
|
||||
|
||||
// Return full AssetItem[] objects (don't strip like getAssetModels does)
|
||||
return (
|
||||
data?.assets?.filter(
|
||||
(asset) =>
|
||||
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
|
||||
) ?? []
|
||||
)
|
||||
return data.assets
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -473,11 +455,8 @@ function createAssetService() {
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// Validate the single asset response against our schema
|
||||
const result = assetResponseSchema.safeParse({ assets: [data] })
|
||||
if (result.success && result.data.assets?.[0]) {
|
||||
return result.data.assets[0]
|
||||
}
|
||||
const result = assetItemSchema.safeParse(data)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = result.error
|
||||
? fromZodError(result.error)
|
||||
@@ -503,18 +482,32 @@ function createAssetService() {
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const data = await handleAssetRequest(
|
||||
const data = await getAssetsPageByTag(tag, includePublic, {
|
||||
limit,
|
||||
offset,
|
||||
signal
|
||||
})
|
||||
|
||||
return data.assets
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets one paginated asset response filtered by a specific tag.
|
||||
*/
|
||||
async function getAssetsPageByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
): Promise<AssetResponse> {
|
||||
return await handleAssetRequest(
|
||||
{ includeTags: [tag], limit, offset, includePublic, signal },
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
|
||||
return (
|
||||
data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets every asset for a tag by walking paginated asset API responses.
|
||||
* Pagination follows the required server-provided `has_more` flag.
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models', 'input')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
@@ -535,23 +528,19 @@ function createAssetService() {
|
||||
while (true) {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
|
||||
const data = await handleAssetRequest(
|
||||
{
|
||||
includeTags: [tag],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
includePublic,
|
||||
signal
|
||||
},
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
const batch = data.assets ?? []
|
||||
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
|
||||
const data = await getAssetsPageByTag(tag, includePublic, {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
signal
|
||||
})
|
||||
const batch = data.assets
|
||||
if (batch.length === 0) {
|
||||
return assets
|
||||
}
|
||||
|
||||
const noMoreFromServer = data.has_more === false
|
||||
const inferredLastPage =
|
||||
data.has_more === undefined && batch.length < pageSize
|
||||
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
|
||||
assets.push(...batch)
|
||||
|
||||
if (!data.has_more) {
|
||||
return assets
|
||||
}
|
||||
|
||||
@@ -598,31 +587,6 @@ function createAssetService() {
|
||||
return await withCallerAbort(request, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an asset exists for an exact asset hash.
|
||||
*
|
||||
* Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
|
||||
* 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
|
||||
*/
|
||||
async function checkAssetHash(
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetHashStatus> {
|
||||
const response = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
|
||||
{
|
||||
method: 'HEAD',
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 200) return 'exists'
|
||||
if (response.status === 404) return 'missing'
|
||||
if (response.status === 400) return 'invalid'
|
||||
|
||||
throw new Error(`Unexpected asset hash check status: ${response.status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset by ID
|
||||
* Only available in cloud environment
|
||||
@@ -983,10 +947,10 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
getAssetsPageByTag,
|
||||
getAllAssetsByTag,
|
||||
getInputAssetsIncludingPublic,
|
||||
invalidateInputAssetsIncludingPublic,
|
||||
checkAssetHash,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
addAssetTags,
|
||||
|
||||
@@ -204,3 +204,13 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
if (curatedName && curatedName !== asset.name) return curatedName
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
|
||||
* in storage), otherwise `asset.name`. Use this when constructing widget
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.asset_hash || asset.name
|
||||
}
|
||||
|
||||
173
src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
Normal file
173
src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
|
||||
|
||||
type MockWidget = {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: (value: unknown) => void
|
||||
}
|
||||
type MockNode = {
|
||||
id: number
|
||||
widgets?: MockWidget[]
|
||||
graph?: { setDirtyCanvas: (v: boolean) => void }
|
||||
isSubgraphNode?: () => boolean
|
||||
subgraph?: { nodes: MockNode[] }
|
||||
}
|
||||
|
||||
function makeGraph(nodes: MockNode[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 clearDeletedAssetWidgetValues', () => {
|
||||
it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 1,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
expect(callback).toHaveBeenCalledWith('')
|
||||
expect(setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('leaves untouched widgets that do not match deleted values', () => {
|
||||
const matchedCallback = vi.fn()
|
||||
const keptCallback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 2,
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'outputs/foo.png [output]',
|
||||
callback: matchedCallback
|
||||
},
|
||||
{ name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
expect(node.widgets![1].value).toBe('inputs/keep.png')
|
||||
expect(matchedCallback).toHaveBeenCalledWith('')
|
||||
expect(keptCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 3,
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'clipspace/clipspace-painted-masked-1.png [input]',
|
||||
callback
|
||||
}
|
||||
],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/some-other-asset.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe(
|
||||
'clipspace/clipspace-painted-masked-1.png [input]'
|
||||
)
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the deleted-values set is empty', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 4,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
|
||||
|
||||
expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles widgets without a callback (legacy nodes) without throwing', () => {
|
||||
const node: MockNode = {
|
||||
id: 5,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
).not.toThrow()
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
})
|
||||
|
||||
it('clears all matching widgets across multiple nodes', () => {
|
||||
const cbA = vi.fn()
|
||||
const cbB = vi.fn()
|
||||
const nodeA: MockNode = {
|
||||
id: 6,
|
||||
widgets: [
|
||||
{ name: 'image', value: 'outputs/a.png [output]', callback: cbA }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const nodeB: MockNode = {
|
||||
id: 7,
|
||||
widgets: [
|
||||
{ name: 'image', value: 'outputs/a.png [output]', callback: cbB }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([nodeA, nodeB]),
|
||||
new Set(['outputs/a.png [output]'])
|
||||
)
|
||||
|
||||
expect(nodeA.widgets![0].value).toBe('')
|
||||
expect(nodeB.widgets![0].value).toBe('')
|
||||
expect(cbA).toHaveBeenCalledWith('')
|
||||
expect(cbB).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('does not affect nodes without widgets', () => {
|
||||
const node: MockNode = {
|
||||
id: 8,
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
40
src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
Normal file
40
src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
|
||||
|
||||
/**
|
||||
* Clear widget values that reference deleted assets so the persisted workflow
|
||||
* JSON stops claiming the deleted asset is in use.
|
||||
*
|
||||
* Without this, after `useMediaAssetActions.deleteAssets` succeeds the
|
||||
* in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
|
||||
* widget value still points at the deleted asset. On reload the workflow JSON
|
||||
* is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
|
||||
* output assets the file is still served (history-soft-delete), so the
|
||||
* preview re-renders despite the asset being "deleted" everywhere else.
|
||||
*
|
||||
* Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
|
||||
* `widgets_values`) and invokes `widget.callback` so widgets like Load Image
|
||||
* run their own change-handling (clearing `node.imgs`, calling
|
||||
* `setNodeOutputs`, etc.).
|
||||
*
|
||||
* FE-230 — covers the post-reload case without re-introducing
|
||||
* useMissingMediaPreviewSync, which couldn't distinguish deletion from
|
||||
* verification false-positives (e.g. mask-editor saved values).
|
||||
*/
|
||||
export function clearDeletedAssetWidgetValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
|
||||
if (!node.widgets) continue
|
||||
for (const widget of node.widgets) {
|
||||
if (typeof widget.value !== 'string') continue
|
||||
if (!deletedValues.has(widget.value)) continue
|
||||
widget.value = ''
|
||||
widget.callback?.('')
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
241
src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
Normal file
241
src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
clearNodePreviewCacheForValues,
|
||||
findNodesReferencingValues
|
||||
} from './clearNodePreviewCacheForValues'
|
||||
|
||||
type MockWidget = { name: string; value: unknown }
|
||||
type MockNode = {
|
||||
id: number
|
||||
widgets?: MockWidget[]
|
||||
imgs?: unknown
|
||||
videoContainer?: unknown
|
||||
graph?: { setDirtyCanvas: (v: boolean) => void }
|
||||
isSubgraphNode?: () => boolean
|
||||
subgraph?: { nodes: MockNode[] }
|
||||
}
|
||||
|
||||
function makeGraph(nodes: MockNode[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 clearNodePreviewCacheForValues', () => {
|
||||
it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 7,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
expect(setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated nodes untouched', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 8,
|
||||
widgets: [{ name: 'image', value: 'unrelated.png' }],
|
||||
imgs: [{ src: 'blob:keep' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:keep' }])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the deleted value set is empty', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 9,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:keep' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:keep' }])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches the [output]-annotated form for output assets', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 12,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('matches the subfolder-prefixed annotated form when provided', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 13,
|
||||
widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['sub/foo.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input/output sources', () => {
|
||||
const remove = vi.fn()
|
||||
const inputNode: MockNode = {
|
||||
id: 1,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:input' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const outputNode: MockNode = {
|
||||
id: 2,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:output' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([inputNode, outputNode]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(inputNode.imgs).toBeUndefined()
|
||||
expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
|
||||
expect(remove).toHaveBeenCalledWith(inputNode)
|
||||
expect(remove).not.toHaveBeenCalledWith(outputNode)
|
||||
})
|
||||
|
||||
it('also clears videoContainer for video previews', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 15,
|
||||
widgets: [{ name: 'video', value: 'clip.mp4' }],
|
||||
videoContainer: { foo: 'bar' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['clip.mp4']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.videoContainer).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('matches any widget on the node, not just "image"', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 10,
|
||||
widgets: [
|
||||
{ name: 'seed', value: 42 },
|
||||
{ name: 'video', value: 'clip.mp4' }
|
||||
],
|
||||
imgs: [{ src: 'blob:videostale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['clip.mp4']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('walks subgraph interiors and matches nested nodes', () => {
|
||||
const inner: MockNode = {
|
||||
id: 100,
|
||||
widgets: [{ name: 'image', value: 'nested.png [output]' }],
|
||||
imgs: [{ src: 'blob:nested' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const wrapper: MockNode = {
|
||||
id: 50,
|
||||
widgets: [],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
const remove = vi.fn()
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['nested.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(inner.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(inner)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FE-230 findNodesReferencingValues', () => {
|
||||
it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
|
||||
const inner: MockNode = {
|
||||
id: 100,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }]
|
||||
}
|
||||
const wrapper: MockNode = {
|
||||
id: 50,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
|
||||
const matches = findNodesReferencingValues(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['foo.png'])
|
||||
)
|
||||
|
||||
expect(matches).toEqual([inner])
|
||||
})
|
||||
})
|
||||
65
src/platform/assets/utils/clearNodePreviewCacheForValues.ts
Normal file
65
src/platform/assets/utils/clearNodePreviewCacheForValues.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Clear cached Load Image / Load Video preview state on any node whose widget
|
||||
* value matches one of the given values. Covers:
|
||||
* - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
|
||||
* - the Vue preview source — must be cleared via `removeOutputsForNode`
|
||||
* so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
|
||||
* not just the legacy `app.nodeOutputs` mirror
|
||||
*
|
||||
* Comparison is full-string against the widget value as stored — callers must
|
||||
* provide the canonical widget-value variants for each deleted asset (e.g.
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
|
||||
* avoids false matches when two distinct assets share a basename across
|
||||
* input/output sources.
|
||||
*
|
||||
* Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
|
||||
* Video nodes inside subgraphs are also matched.
|
||||
*
|
||||
* FE-230 — invoked after successful asset deletion so the Load Image / Load
|
||||
* Video node preview does not keep displaying a thumbnail for an asset that
|
||||
* no longer exists.
|
||||
*/
|
||||
export function clearNodePreviewCacheForValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>,
|
||||
removeOutputsForNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
|
||||
removeOutputsForNode(node)
|
||||
node.imgs = undefined
|
||||
node.videoContainer = undefined
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the graph hierarchy and yield each leaf node whose widget value matches
|
||||
* one of `deletedValues`. Used by both the preview-clearing path and the
|
||||
* missing-media-marking path so the two stay in lockstep.
|
||||
*
|
||||
* Skips subgraph wrapper nodes — only their interior nodes are inspected.
|
||||
*/
|
||||
export function findNodesReferencingValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): LGraphNode[] {
|
||||
if (deletedValues.size === 0) return []
|
||||
const matches: LGraphNode[] = []
|
||||
for (const node of collectAllNodes(rootGraph)) {
|
||||
if (!node.widgets?.length) continue
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
const referencesDeleted = node.widgets.some(
|
||||
(w) => typeof w.value === 'string' && deletedValues.has(w.value)
|
||||
)
|
||||
if (referencesDeleted) matches.push(node)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
|
||||
import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
|
||||
scanNodeMediaCandidates: mockScanNodeMediaCandidates
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ currentGraph: null })
|
||||
}))
|
||||
|
||||
function makeGraph(nodes: unknown[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockScanNodeMediaCandidates.mockReset()
|
||||
mockScanNodeMediaCandidates.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
|
||||
const node = {
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
widgets: [
|
||||
{ name: 'image', value: 'sub/foo.png [output]' },
|
||||
{ name: 'mask', value: 'unrelated.png' }
|
||||
]
|
||||
}
|
||||
mockScanNodeMediaCandidates.mockReturnValue([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'sub/foo.png [output]'
|
||||
},
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'mask',
|
||||
mediaType: 'image',
|
||||
name: 'unrelated.png'
|
||||
}
|
||||
])
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([node]),
|
||||
new Set(['sub/foo.png [output]'])
|
||||
)
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toEqual([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'sub/foo.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input/output sources', () => {
|
||||
const inputNode = {
|
||||
id: 2,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'foo.png' }]
|
||||
}
|
||||
const outputNode = {
|
||||
id: 3,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([inputNode, outputNode]),
|
||||
new Set(['foo.png'])
|
||||
)
|
||||
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
inputNode,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes with NEVER or BYPASS mode', () => {
|
||||
const bypassed = {
|
||||
id: 4,
|
||||
type: 'LoadImage',
|
||||
mode: 4,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
const never = {
|
||||
id: 5,
|
||||
type: 'LoadImage',
|
||||
mode: 2,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([bypassed, never]),
|
||||
new Set(['foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('walks subgraph interiors and marks nested nodes', () => {
|
||||
const inner = {
|
||||
id: 100,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'nested.png [output]' }]
|
||||
}
|
||||
const wrapper = {
|
||||
id: 50,
|
||||
widgets: [],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
mockScanNodeMediaCandidates.mockReturnValue([
|
||||
{
|
||||
nodeId: '50:100',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'nested.png [output]'
|
||||
}
|
||||
])
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['nested.png [output]'])
|
||||
)
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toEqual([
|
||||
{
|
||||
nodeId: '50:100',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'nested.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when no nodes reference any deleted value', () => {
|
||||
const node = {
|
||||
id: 2,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'kept.png' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
|
||||
|
||||
expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the deleted value set is empty', () => {
|
||||
markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
50
src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
Normal file
50
src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
|
||||
|
||||
/**
|
||||
* After a successful asset deletion, surface the affected Load Image / Load
|
||||
* Video / Load Audio nodes through the missing-media store. Without this, UI
|
||||
* surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
|
||||
* widget dropdown) keep listing the deleted asset because the verification
|
||||
* pipeline only runs on workflow load — there is no signal that the live
|
||||
* deletion just invalidated some references.
|
||||
*
|
||||
* Walks the full graph hierarchy (including subgraphs) and skips bypassed /
|
||||
* never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
|
||||
* path stays in lockstep with the workflow-load verification.
|
||||
*
|
||||
* Comparison is full-string against the widget value, so two distinct assets
|
||||
* that share a basename across input/output sources do not cross-match.
|
||||
*/
|
||||
export function markDeletedAssetsAsMissingMedia(
|
||||
rootGraph: LGraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
|
||||
const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
|
||||
if (!matchedNodes.length) return
|
||||
|
||||
const candidates: MissingMediaCandidate[] = []
|
||||
for (const node of matchedNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
|
||||
if (!deletedValues.has(candidate.name)) continue
|
||||
candidates.push({ ...candidate, isMissing: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length) {
|
||||
useMissingMediaStore().addMissingMedia(candidates)
|
||||
}
|
||||
}
|
||||
80
src/platform/missingMedia/mediaPathDetectionUtil.test.ts
Normal file
80
src/platform/missingMedia/mediaPathDetectionUtil.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getAnnotatedMediaPathTypeForDetection,
|
||||
getMediaPathDetectionNames,
|
||||
normalizeAnnotatedMediaPathForDetection
|
||||
} from './mediaPathDetectionUtil'
|
||||
|
||||
describe('normalizeAnnotatedMediaPathForDetection', () => {
|
||||
it.each([
|
||||
['photo.png [input]', 'photo.png'],
|
||||
['result.png [output]', 'result.png'],
|
||||
['photo.png [input]', 'photo.png'],
|
||||
['with spaces.png [output]', 'with spaces.png'],
|
||||
['nested/folder/video.mp4 [output]', 'nested/folder/video.mp4']
|
||||
])('strips Core-style annotation from %s', (value, expected) => {
|
||||
expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['photo.png[input]', 'photo.png'],
|
||||
['result.png[output]', 'result.png'],
|
||||
['with spaces.png [output]', 'with spaces.png']
|
||||
])('strips Cloud compact annotation from %s', (value, expected) => {
|
||||
expect(
|
||||
normalizeAnnotatedMediaPathForDetection(value, {
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('does not strip compact annotations in Core mode', () => {
|
||||
expect(normalizeAnnotatedMediaPathForDetection('photo.png[input]')).toBe(
|
||||
'photo.png[input]'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['photo.png [draft]', 'photo [output] copy.png', 'photo.png', ''])(
|
||||
'leaves non-matching values unchanged: %s',
|
||||
(value) => {
|
||||
expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(value)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('getMediaPathDetectionNames', () => {
|
||||
it('returns raw and normalized names when an annotation is stripped', () => {
|
||||
expect(getMediaPathDetectionNames('photo.png [input]')).toEqual([
|
||||
'photo.png [input]',
|
||||
'photo.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns only the raw name when no annotation is stripped', () => {
|
||||
expect(getMediaPathDetectionNames('photo.png')).toEqual(['photo.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAnnotatedMediaPathTypeForDetection', () => {
|
||||
it.each([
|
||||
['photo.png [input]', 'input'],
|
||||
['photo.png [output]', 'output']
|
||||
])('returns the Core-style annotation type from %s', (value, expected) => {
|
||||
expect(getAnnotatedMediaPathTypeForDetection(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('returns the compact annotation type in Cloud mode', () => {
|
||||
expect(
|
||||
getAnnotatedMediaPathTypeForDetection('photo.png[output]', {
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
).toBe('output')
|
||||
})
|
||||
|
||||
it('returns undefined when no supported annotation is present', () => {
|
||||
expect(getAnnotatedMediaPathTypeForDetection('photo.png [draft]')).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/platform/missingMedia/mediaPathDetectionUtil.ts
Normal file
44
src/platform/missingMedia/mediaPathDetectionUtil.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Missing-media-scoped helpers for deriving comparison keys from media widget paths.
|
||||
const CORE_ANNOTATED_MEDIA_PATTERN = /\s+\[(input|output)\]$/
|
||||
const CLOUD_ANNOTATED_MEDIA_PATTERN = /\s*\[(input|output)\]$/
|
||||
|
||||
type AnnotatedMediaPathType = 'input' | 'output'
|
||||
|
||||
interface AnnotatedMediaPathOptions {
|
||||
allowCompactSuffix?: boolean
|
||||
}
|
||||
|
||||
function getAnnotatedMediaPathMatch(
|
||||
value: string,
|
||||
options: AnnotatedMediaPathOptions = {}
|
||||
): RegExpMatchArray | null {
|
||||
const pattern = options.allowCompactSuffix
|
||||
? CLOUD_ANNOTATED_MEDIA_PATTERN
|
||||
: CORE_ANNOTATED_MEDIA_PATTERN
|
||||
return value.match(pattern)
|
||||
}
|
||||
|
||||
export function getAnnotatedMediaPathTypeForDetection(
|
||||
value: string,
|
||||
options: AnnotatedMediaPathOptions = {}
|
||||
): AnnotatedMediaPathType | undefined {
|
||||
return getAnnotatedMediaPathMatch(value, options)?.[1] as
|
||||
| AnnotatedMediaPathType
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function normalizeAnnotatedMediaPathForDetection(
|
||||
value: string,
|
||||
options: AnnotatedMediaPathOptions = {}
|
||||
): string {
|
||||
const match = getAnnotatedMediaPathMatch(value, options)
|
||||
return match ? value.slice(0, match.index) : value
|
||||
}
|
||||
|
||||
export function getMediaPathDetectionNames(
|
||||
value: string,
|
||||
options: AnnotatedMediaPathOptions = {}
|
||||
): string[] {
|
||||
const normalized = normalizeAnnotatedMediaPathForDetection(value, options)
|
||||
return normalized === value ? [value] : [value, normalized]
|
||||
}
|
||||
325
src/platform/missingMedia/missingMediaAssetResolver.test.ts
Normal file
325
src/platform/missingMedia/missingMediaAssetResolver.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import {
|
||||
getAssetDetectionNames,
|
||||
resolveMissingMediaAssetSources
|
||||
} from './missingMediaAssetResolver'
|
||||
|
||||
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetInputAssetsIncludingPublic: vi.fn(),
|
||||
mockGetAssetsPageByTag: vi.fn()
|
||||
}))
|
||||
|
||||
const { mockFetchHistoryPage } = vi.hoisted(() => ({
|
||||
mockFetchHistoryPage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
|
||||
getAssetsPageByTag: mockGetAssetsPageByTag
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
|
||||
const actual = await vi.importActual<typeof FetchJobsModule>(
|
||||
'@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
fetchHistoryPage: mockFetchHistoryPage
|
||||
}
|
||||
})
|
||||
|
||||
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
}
|
||||
|
||||
function makeHistoryJob(
|
||||
filename: string,
|
||||
options: { id?: string; subfolder?: string } = {}
|
||||
): JobListItem {
|
||||
return fromAny<JobListItem, unknown>({
|
||||
id: options.id ?? filename,
|
||||
status: 'completed',
|
||||
create_time: 0,
|
||||
priority: 0,
|
||||
preview_output: {
|
||||
filename,
|
||||
subfolder: options.subfolder ?? '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeHistoryPage(
|
||||
jobs: JobListItem[],
|
||||
options: { offset?: number; hasMore?: boolean; total?: number } = {}
|
||||
) {
|
||||
return {
|
||||
jobs,
|
||||
total: options.total ?? jobs.length,
|
||||
offset: options.offset ?? 0,
|
||||
limit: 200,
|
||||
hasMore: options.hasMore ?? false
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssetPage(
|
||||
assets: AssetItem[],
|
||||
options: { hasMore?: boolean; total?: number } = {}
|
||||
) {
|
||||
return {
|
||||
assets,
|
||||
total: options.total ?? assets.length,
|
||||
has_more: options.hasMore ?? false
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveMissingMediaAssetSources', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
|
||||
mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
|
||||
})
|
||||
|
||||
it('loads cloud input assets when requested', async () => {
|
||||
const inputAsset = makeAsset('photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.inputAssets).toEqual([inputAsset])
|
||||
expect(result.generatedAssets).toEqual([])
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads cloud output assets by tag when generated candidates need verification', async () => {
|
||||
const outputAsset = makeAsset('output.png')
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['output.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toEqual([outputAsset])
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
expect.objectContaining({
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops reading cloud output asset pages once all requested names are found', async () => {
|
||||
const target = 'target-output.png'
|
||||
mockGetAssetsPageByTag.mockResolvedValueOnce(
|
||||
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toEqual([makeAsset(target)])
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('aborts cloud output asset loading when input asset loading fails', async () => {
|
||||
const inputError = new Error('input failed')
|
||||
let rejectInputAssets!: (err: Error) => void
|
||||
let resolveOutputAssets!: (page: ReturnType<typeof makeAssetPage>) => void
|
||||
mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
|
||||
new Promise<AssetItem[]>((_, reject) => {
|
||||
rejectInputAssets = reject
|
||||
})
|
||||
)
|
||||
mockGetAssetsPageByTag.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOutputAssets = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const promise = resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['target.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
|
||||
rejectInputAssets(inputError)
|
||||
await expect(promise).rejects.toBe(inputError)
|
||||
|
||||
resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
|
||||
await Promise.resolve()
|
||||
|
||||
const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
|
||||
expect(outputSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(outputSignal.aborted).toBe(true)
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops reading generated history once all requested names are found', async () => {
|
||||
const target = 'target.png'
|
||||
mockFetchHistoryPage.mockResolvedValueOnce(
|
||||
makeHistoryPage([makeHistoryJob(target)], {
|
||||
hasMore: true,
|
||||
total: 400
|
||||
})
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toHaveLength(1)
|
||||
expect(result.generatedAssets[0].name).toBe(target)
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('advances pagination from the requested offset, not the echoed offset', async () => {
|
||||
const target = 'target.png'
|
||||
mockFetchHistoryPage
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPage(
|
||||
Array.from({ length: 200 }, (_, index) =>
|
||||
makeHistoryJob(`other-${index}.png`)
|
||||
),
|
||||
{ offset: 0, hasMore: true, total: 201 }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPage([makeHistoryJob(target)], {
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
total: 201
|
||||
})
|
||||
)
|
||||
|
||||
await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(Function),
|
||||
200,
|
||||
0
|
||||
)
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
200,
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
it('stops if history reports hasMore but returns an empty page', async () => {
|
||||
mockFetchHistoryPage.mockResolvedValueOnce(
|
||||
makeHistoryPage([], { hasMore: true, total: 1 })
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['missing.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toEqual([])
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops if history repeats the same job page', async () => {
|
||||
const repeatedJob = makeHistoryJob('other.png', { id: 'same-job' })
|
||||
mockFetchHistoryPage
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPage([repeatedJob], { hasMore: true, total: 2 })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPage([repeatedJob], { offset: 1, hasMore: true, total: 2 })
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set(['missing.png']),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toHaveLength(1)
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('includes slash and backslash subfolder identifiers for detection', () => {
|
||||
const names = getAssetDetectionNames(
|
||||
{
|
||||
...makeAsset('child\\photo.png', 'hash.png'),
|
||||
user_metadata: { subfolder: 'nested\\folder' }
|
||||
},
|
||||
{ allowCompactSuffix: true }
|
||||
)
|
||||
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'child\\photo.png',
|
||||
'hash.png',
|
||||
'nested/folder/child/photo.png',
|
||||
'nested\\folder\\child\\photo.png'
|
||||
])
|
||||
)
|
||||
expect(names).not.toContain('nested/folder/hash.png')
|
||||
expect(names).not.toContain('nested\\folder\\hash.png')
|
||||
})
|
||||
})
|
||||
286
src/platform/missingMedia/missingMediaAssetResolver.ts
Normal file
286
src/platform/missingMedia/missingMediaAssetResolver.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
|
||||
import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
|
||||
|
||||
const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
|
||||
const CLOUD_OUTPUT_ASSETS_PAGE_SIZE = 500
|
||||
|
||||
interface MediaPathDetectionOptions {
|
||||
allowCompactSuffix: boolean
|
||||
}
|
||||
|
||||
export interface MissingMediaAssetSources {
|
||||
inputAssets: AssetItem[]
|
||||
generatedAssets: AssetItem[]
|
||||
}
|
||||
|
||||
export interface ResolveMissingMediaAssetSourcesOptions {
|
||||
signal?: AbortSignal
|
||||
isCloud: boolean
|
||||
includeGeneratedAssets: boolean
|
||||
generatedMatchNames: ReadonlySet<string>
|
||||
allowCompactSuffix: boolean
|
||||
}
|
||||
|
||||
export type MissingMediaAssetResolver = (
|
||||
options: ResolveMissingMediaAssetSourcesOptions
|
||||
) => Promise<MissingMediaAssetSources>
|
||||
|
||||
export async function resolveMissingMediaAssetSources({
|
||||
signal,
|
||||
isCloud,
|
||||
includeGeneratedAssets,
|
||||
generatedMatchNames,
|
||||
allowCompactSuffix
|
||||
}: ResolveMissingMediaAssetSourcesOptions): Promise<MissingMediaAssetSources> {
|
||||
const pathOptions = { allowCompactSuffix }
|
||||
|
||||
const controller = new AbortController()
|
||||
const abortFromCaller = () => controller.abort(signal?.reason)
|
||||
if (signal?.aborted) {
|
||||
abortFromCaller()
|
||||
} else {
|
||||
signal?.addEventListener('abort', abortFromCaller, { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const [inputAssets, generatedAssets] = await Promise.all([
|
||||
abortSiblingsOnFailure(
|
||||
isCloud
|
||||
? assetService.getInputAssetsIncludingPublic(controller.signal)
|
||||
: Promise.resolve<AssetItem[]>([]),
|
||||
controller
|
||||
),
|
||||
abortSiblingsOnFailure(
|
||||
includeGeneratedAssets
|
||||
? fetchGeneratedAssets(controller.signal, {
|
||||
isCloud,
|
||||
generatedMatchNames,
|
||||
pathOptions
|
||||
})
|
||||
: Promise.resolve<AssetItem[]>([]),
|
||||
controller
|
||||
)
|
||||
])
|
||||
|
||||
return { inputAssets, generatedAssets }
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortFromCaller)
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchGeneratedAssetsOptions {
|
||||
isCloud: boolean
|
||||
generatedMatchNames: ReadonlySet<string>
|
||||
pathOptions: MediaPathDetectionOptions
|
||||
}
|
||||
|
||||
export function getAssetDetectionNames(
|
||||
asset: AssetItem,
|
||||
options: MediaPathDetectionOptions
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
|
||||
addPathDetectionNames(names, asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
if (typeof subfolder === 'string' && subfolder) {
|
||||
addSubfolderPathDetectionNames(names, subfolder, asset.name, options)
|
||||
}
|
||||
|
||||
return Array.from(names)
|
||||
}
|
||||
|
||||
async function fetchGeneratedAssets(
|
||||
signal: AbortSignal | undefined,
|
||||
{ isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
|
||||
): Promise<AssetItem[]> {
|
||||
if (isCloud) {
|
||||
return await fetchCloudGeneratedAssets(
|
||||
signal,
|
||||
generatedMatchNames,
|
||||
pathOptions
|
||||
)
|
||||
}
|
||||
|
||||
return await fetchGeneratedHistoryAssets(
|
||||
signal,
|
||||
generatedMatchNames,
|
||||
pathOptions
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchCloudGeneratedAssets(
|
||||
signal: AbortSignal | undefined,
|
||||
targetNames: ReadonlySet<string>,
|
||||
pathOptions: MediaPathDetectionOptions
|
||||
): Promise<AssetItem[]> {
|
||||
const assets: AssetItem[] = []
|
||||
const foundTargetNames = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
|
||||
const assetPage = await assetService.getAssetsPageByTag('output', true, {
|
||||
limit: CLOUD_OUTPUT_ASSETS_PAGE_SIZE,
|
||||
offset,
|
||||
signal
|
||||
})
|
||||
|
||||
signal?.throwIfAborted()
|
||||
|
||||
const batch = assetPage.assets
|
||||
if (batch.length === 0) return assets
|
||||
|
||||
for (const asset of batch) {
|
||||
assets.push(asset)
|
||||
rememberResolvedTargetNames(
|
||||
asset,
|
||||
targetNames,
|
||||
foundTargetNames,
|
||||
pathOptions
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
!assetPage.has_more ||
|
||||
hasResolvedAllTargetNames(targetNames, foundTargetNames)
|
||||
) {
|
||||
return assets
|
||||
}
|
||||
|
||||
offset += batch.length
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGeneratedHistoryAssets(
|
||||
signal: AbortSignal | undefined,
|
||||
targetNames: ReadonlySet<string>,
|
||||
pathOptions: MediaPathDetectionOptions
|
||||
): Promise<AssetItem[]> {
|
||||
const assets: AssetItem[] = []
|
||||
const foundTargetNames = new Set<string>()
|
||||
const seenJobIds = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
|
||||
const requestedOffset = offset
|
||||
const historyPage = await fetchHistoryPage(
|
||||
api.fetchApi.bind(api),
|
||||
HISTORY_MEDIA_ASSETS_PAGE_SIZE,
|
||||
requestedOffset
|
||||
)
|
||||
|
||||
signal?.throwIfAborted()
|
||||
|
||||
let newJobCount = 0
|
||||
for (const job of historyPage.jobs) {
|
||||
if (seenJobIds.has(job.id)) continue
|
||||
seenJobIds.add(job.id)
|
||||
newJobCount += 1
|
||||
|
||||
const asset = mapHistoryJobToAsset(job)
|
||||
if (!asset) continue
|
||||
|
||||
assets.push(asset)
|
||||
rememberResolvedTargetNames(
|
||||
asset,
|
||||
targetNames,
|
||||
foundTargetNames,
|
||||
pathOptions
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
!historyPage.hasMore ||
|
||||
historyPage.jobs.length === 0 ||
|
||||
newJobCount === 0 ||
|
||||
hasResolvedAllTargetNames(targetNames, foundTargetNames)
|
||||
) {
|
||||
return assets
|
||||
}
|
||||
|
||||
offset = requestedOffset + historyPage.jobs.length
|
||||
}
|
||||
}
|
||||
|
||||
async function abortSiblingsOnFailure<T>(
|
||||
promise: Promise<T>,
|
||||
controller: AbortController
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await promise
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) controller.abort(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function addPathDetectionNames(
|
||||
names: Set<string>,
|
||||
value: string | null | undefined,
|
||||
options: MediaPathDetectionOptions
|
||||
) {
|
||||
if (!value) return
|
||||
for (const name of getMediaPathDetectionNames(value, options)) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function addSubfolderPathDetectionNames(
|
||||
names: Set<string>,
|
||||
subfolder: string,
|
||||
value: string | null | undefined,
|
||||
options: MediaPathDetectionOptions
|
||||
) {
|
||||
if (!value) return
|
||||
|
||||
const filePath = joinFilePath(subfolder, value)
|
||||
for (const path of getFilePathSeparatorVariants(filePath)) {
|
||||
addPathDetectionNames(names, path, options)
|
||||
}
|
||||
}
|
||||
|
||||
function rememberResolvedTargetNames(
|
||||
asset: AssetItem,
|
||||
targetNames: ReadonlySet<string>,
|
||||
foundTargetNames: Set<string>,
|
||||
options: MediaPathDetectionOptions
|
||||
) {
|
||||
if (targetNames.size === 0) return
|
||||
|
||||
for (const name of getAssetDetectionNames(asset, options)) {
|
||||
if (targetNames.has(name)) foundTargetNames.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function hasResolvedAllTargetNames(
|
||||
targetNames: ReadonlySet<string>,
|
||||
foundTargetNames: ReadonlySet<string>
|
||||
): boolean {
|
||||
return targetNames.size > 0 && foundTargetNames.size === targetNames.size
|
||||
}
|
||||
|
||||
function mapHistoryJobToAsset(job: JobListItem): AssetItem | null {
|
||||
const output = job.preview_output
|
||||
if (job.status !== 'completed' || !output?.filename) return null
|
||||
|
||||
return {
|
||||
id: `${job.id}-${output.filename}`,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
mime_type: null,
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
subfolder: output.subfolder
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,27 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates,
|
||||
verifyMediaCandidates,
|
||||
groupCandidatesByName,
|
||||
groupCandidatesByMediaType
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
|
||||
() => ({
|
||||
mockCheckAssetHash: vi.fn(),
|
||||
mockGetInputAssetsIncludingPublic: vi.fn()
|
||||
})
|
||||
)
|
||||
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetInputAssetsIncludingPublic: vi.fn(),
|
||||
mockGetAssetsPageByTag: vi.fn()
|
||||
}))
|
||||
|
||||
const { mockFetchHistoryPage } = vi.hoisted(() => ({
|
||||
mockFetchHistoryPage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
@@ -39,12 +45,23 @@ vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash,
|
||||
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
|
||||
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
|
||||
getAssetsPageByTag: mockGetAssetsPageByTag
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
|
||||
const actual = await vi.importActual<typeof FetchJobsModule>(
|
||||
'@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
fetchHistoryPage: mockFetchHistoryPage
|
||||
}
|
||||
})
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
@@ -104,6 +121,43 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssetResolver(
|
||||
inputAssets: AssetItem[],
|
||||
generatedAssets: AssetItem[] = []
|
||||
): MissingMediaAssetResolver {
|
||||
return vi.fn(async () => ({ inputAssets, generatedAssets }))
|
||||
}
|
||||
|
||||
function makeAssetPage(
|
||||
assets: AssetItem[],
|
||||
options: { hasMore?: boolean; total?: number } = {}
|
||||
) {
|
||||
return {
|
||||
assets,
|
||||
total: options.total ?? assets.length,
|
||||
has_more: options.hasMore ?? false
|
||||
}
|
||||
}
|
||||
|
||||
function makeHistoryJob(
|
||||
filename: string,
|
||||
options: { id?: string; subfolder?: string } = {}
|
||||
): JobListItem {
|
||||
return fromAny<JobListItem, unknown>({
|
||||
id: options.id ?? filename,
|
||||
status: 'completed',
|
||||
create_time: 0,
|
||||
priority: 0,
|
||||
preview_output: {
|
||||
filename,
|
||||
subfolder: options.subfolder ?? '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('scanNodeMediaCandidates', () => {
|
||||
it('returns candidate for a LoadImage node with missing image', () => {
|
||||
const graph = makeGraph([])
|
||||
@@ -149,6 +203,173 @@ describe('scanNodeMediaCandidates', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it.for([false, true])(
|
||||
'returns empty while a media upload is pending on the node (isCloud: %s)',
|
||||
(isCloud) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadVideo',
|
||||
[makeMediaCombo('file', 'clip.mp4', [])],
|
||||
0
|
||||
)
|
||||
node.isUploading = true
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, isCloud)
|
||||
|
||||
expect(result).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('detects missing media again after upload state clears', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadVideo',
|
||||
[makeMediaCombo('file', 'clip.mp4', [])],
|
||||
0
|
||||
)
|
||||
|
||||
node.isUploading = true
|
||||
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([])
|
||||
|
||||
node.isUploading = false
|
||||
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
name: 'clip.mp4',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
value: 'photo.png [input]',
|
||||
option: 'photo.png'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadImageMask',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
value: 'mask.png [input]',
|
||||
option: 'mask.png'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
value: 'clip.mp4 [input]',
|
||||
option: 'clip.mp4'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio',
|
||||
value: 'sound.wav [input]',
|
||||
option: 'sound.wav'
|
||||
}
|
||||
])(
|
||||
'matches annotated $nodeType values against clean OSS options',
|
||||
({ nodeType, widgetName, mediaType, value, option }) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
nodeType,
|
||||
[makeMediaCombo(widgetName, value, [option])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaType,
|
||||
name: value,
|
||||
isMissing: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
value: 'photo.png [output]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
value: 'clip.mp4 [output]'
|
||||
},
|
||||
{
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
value: 'sound.wav [output]'
|
||||
}
|
||||
])(
|
||||
'leaves OSS $nodeType output annotations pending when not in options',
|
||||
({ nodeType, widgetName, value }) => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
nodeType,
|
||||
[makeMediaCombo(widgetName, value, ['other-file.png', value])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
nodeType,
|
||||
widgetName,
|
||||
name: value,
|
||||
isMissing: undefined
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('marks OSS input annotations missing when the clean option is absent', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'photo.png [input]',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat compact Cloud annotations as valid OSS options', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'photo.png[input]',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllMediaCandidates', () => {
|
||||
@@ -265,7 +486,7 @@ describe('groupCandidatesByMediaType', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCloudMediaCandidates', () => {
|
||||
describe('verifyMediaCandidates', () => {
|
||||
const existingHash =
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const missingHash =
|
||||
@@ -273,36 +494,355 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
|
||||
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
|
||||
mockFetchHistoryPage.mockResolvedValue({
|
||||
jobs: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
hasMore: false
|
||||
})
|
||||
})
|
||||
|
||||
it('marks candidates missing when the asset hash is not found', async () => {
|
||||
it('matches candidates by available input asset name or hash', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', existingHash, { isMissing: undefined })
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', existingHash, { isMissing: undefined }),
|
||||
makeCandidate('3', missingHash, { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
const checkAssetHash = vi.fn(async (assetHash: string) =>
|
||||
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
expect(candidates[2].isMissing).toBe(true)
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
})
|
||||
|
||||
it('matches asset names when asset_hash is null', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([
|
||||
makeAsset('legacy-photo.png', null)
|
||||
])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('matches annotated candidate names against clean asset names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
|
||||
makeCandidate('2', 'clip.mp4[input]', {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
isMissing: undefined
|
||||
}),
|
||||
makeCandidate('3', 'missing.wav [output]', {
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio',
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver(
|
||||
[makeAsset('photo.png'), makeAsset('clip.mp4')],
|
||||
[]
|
||||
)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: 'photo.png [input]',
|
||||
isMissing: false
|
||||
})
|
||||
expect(candidates[1]).toMatchObject({
|
||||
name: 'clip.mp4[input]',
|
||||
isMissing: false
|
||||
})
|
||||
expect(candidates[2]).toMatchObject({
|
||||
name: 'missing.wav [output]',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses assetService.checkAssetHash by default', async () => {
|
||||
it('matches output hash filenames against generated media assets', async () => {
|
||||
const candidates = [
|
||||
makeCandidate(
|
||||
'1',
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
|
||||
{
|
||||
isMissing: undefined
|
||||
}
|
||||
)
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver(
|
||||
[],
|
||||
[
|
||||
makeAsset(
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
|
||||
isMissing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not satisfy output annotations with input assets of the same name', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('does not satisfy input candidates with output assets of the same name', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies OSS output candidates against generated history without cloud assets', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'subfolder/photo.png [output]', {
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
|
||||
mockFetchHistoryPage.mockResolvedValueOnce({
|
||||
jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
200,
|
||||
0
|
||||
)
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: 'subfolder/photo.png [output]',
|
||||
isMissing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not normalize compact annotations when verifying OSS candidates', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: false,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: false,
|
||||
includeGeneratedAssets: false,
|
||||
generatedMatchNames: new Set(),
|
||||
allowCompactSuffix: false
|
||||
})
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('matches when the asset identifier itself is annotated', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver(
|
||||
[],
|
||||
[makeAsset('clip.mp4 [output]')]
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('marks pending candidates missing when no input assets are available', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources: makeAssetResolver([])
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('uses public input assets by default', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue([
|
||||
makeAsset('stored-photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads cloud output assets by tag for output candidates', async () => {
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const candidates = [
|
||||
makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
|
||||
]
|
||||
mockGetAssetsPageByTag.mockResolvedValue(
|
||||
makeAssetPage([makeAsset(outputHash)])
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
expect.objectContaining({
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('walks OSS generated history pages until hasMore is false', async () => {
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const candidates = [
|
||||
makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
|
||||
]
|
||||
mockFetchHistoryPage
|
||||
.mockResolvedValueOnce({
|
||||
jobs: Array.from({ length: 200 }, (_, index) =>
|
||||
makeHistoryJob(`other-${index}.png`)
|
||||
),
|
||||
total: 201,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
hasMore: true
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
jobs: [makeHistoryJob(outputHash)],
|
||||
total: 201,
|
||||
offset: 200,
|
||||
limit: 200,
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(Function),
|
||||
200,
|
||||
0
|
||||
)
|
||||
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
200,
|
||||
200
|
||||
)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('trusts OSS history hasMore instead of page length', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'missing-output.png [output]', {
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
mockFetchHistoryPage.mockResolvedValueOnce({
|
||||
jobs: Array.from({ length: 200 }, (_, index) =>
|
||||
makeHistoryJob(`other-${index}.png`)
|
||||
),
|
||||
total: 200,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: false })
|
||||
|
||||
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('respects abort signal before execution', async () => {
|
||||
@@ -313,27 +853,33 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
makeCandidate('1', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects abort signal after hash verification', async () => {
|
||||
it('respects abort signal after loading input assets', async () => {
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
|
||||
controller.abort()
|
||||
return 'exists' as const
|
||||
return {
|
||||
inputAssets: [makeAsset('stored-photo.png', existingHash)],
|
||||
generatedAssets: []
|
||||
}
|
||||
})
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
checkAssetHash
|
||||
)
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
signal: controller.signal,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
@@ -341,52 +887,30 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
it('skips candidates already resolved as true', async () => {
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as false', async () => {
|
||||
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips entirely when no pending candidates', async () => {
|
||||
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to input assets for non-blake3 candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('stored-photo.png', 'photo.png')
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('uses public input assets for default legacy fallback', async () => {
|
||||
it('loads public input assets for default verification', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
|
||||
]
|
||||
@@ -396,135 +920,62 @@ describe('verifyCloudMediaCandidates', () => {
|
||||
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
|
||||
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('silences aborts while loading legacy fallback input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
controller.signal,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('silences aborts from the default legacy fallback input asset store path', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
).resolves.toBeUndefined()
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
|
||||
controller.signal
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('silences aborts while loading input assets', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw abortError
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
signal: controller.signal,
|
||||
resolveAssetSources
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to input assets when the hash endpoint returns 400', async () => {
|
||||
it('forwards the signal to the default input asset fetcher and silences aborts', async () => {
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const controller = new AbortController()
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
makeCandidate('1', 'photo.png', { isMissing: undefined })
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('invalid')
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
let serviceSignal: AbortSignal | undefined
|
||||
mockGetInputAssetsIncludingPublic.mockImplementationOnce(
|
||||
async (signal?: AbortSignal) => {
|
||||
serviceSignal = signal
|
||||
controller.abort()
|
||||
throw abortError
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
await expect(
|
||||
verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
it('falls back to input assets when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const candidates = [
|
||||
makeCandidate('1', existingHash, { isMissing: undefined })
|
||||
]
|
||||
const checkAssetHash = vi.fn(async () => {
|
||||
throw new Error('network failed')
|
||||
})
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('photo.png', existingHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
checkAssetHash,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
|
||||
const malformedHash = 'blake3:abc'
|
||||
const candidates = [
|
||||
makeCandidate('1', malformedHash, { isMissing: undefined })
|
||||
]
|
||||
const fetchInputAssets = vi.fn(async () => [
|
||||
makeAsset('legacy.png', malformedHash)
|
||||
])
|
||||
|
||||
await verifyCloudMediaCandidates(
|
||||
candidates,
|
||||
undefined,
|
||||
undefined,
|
||||
fetchInputAssets
|
||||
)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(fetchInputAssets).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates checks for repeated candidate names', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', missingHash, { isMissing: undefined }),
|
||||
makeCandidate('2', missingHash, { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(true)
|
||||
expect(serviceSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(serviceSignal?.aborted).toBe(true)
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,11 +19,17 @@ import {
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
assetService,
|
||||
isBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
getAnnotatedMediaPathTypeForDetection,
|
||||
getMediaPathDetectionNames,
|
||||
normalizeAnnotatedMediaPathForDetection
|
||||
} from './mediaPathDetectionUtil'
|
||||
import {
|
||||
getAssetDetectionNames,
|
||||
resolveMissingMediaAssetSources
|
||||
} from './missingMediaAssetResolver'
|
||||
import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
@@ -31,6 +37,7 @@ const MEDIA_NODE_WIDGETS: Record<
|
||||
{ widgetName: string; mediaType: MediaType }
|
||||
> = {
|
||||
LoadImage: { widgetName: 'image', mediaType: 'image' },
|
||||
LoadImageMask: { widgetName: 'image', mediaType: 'image' },
|
||||
LoadVideo: { widgetName: 'file', mediaType: 'video' },
|
||||
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
|
||||
}
|
||||
@@ -42,7 +49,8 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
/**
|
||||
* Scan combo widgets on media nodes for file values that may be missing.
|
||||
*
|
||||
* OSS: `isMissing` resolved immediately via widget options.
|
||||
* OSS: `isMissing` is resolved immediately via widget options unless an
|
||||
* output annotation needs generated-history verification.
|
||||
* Cloud: `isMissing` left `undefined` for async verification.
|
||||
*/
|
||||
export function scanAllMediaCandidates(
|
||||
@@ -79,6 +87,7 @@ export function scanNodeMediaCandidates(
|
||||
|
||||
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
||||
if (!mediaInfo) return []
|
||||
if (node.isUploading) return []
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) return []
|
||||
@@ -95,8 +104,17 @@ export function scanNodeMediaCandidates(
|
||||
if (isCloud) {
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
isMissing = !options.includes(value)
|
||||
const type = getAnnotatedMediaPathTypeForDetection(value)
|
||||
if (type === 'output') {
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
const detectionNames = getMediaPathDetectionNames(value)
|
||||
const existsInOptions = detectionNames.some((name) =>
|
||||
options.includes(name)
|
||||
)
|
||||
isMissing = !existsInOptions
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
@@ -112,99 +130,57 @@ export function scanNodeMediaCandidates(
|
||||
return candidates
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
interface MediaVerificationOptions {
|
||||
isCloud: boolean
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
|
||||
|
||||
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>
|
||||
legacyCandidates: MissingMediaCandidate[]
|
||||
} {
|
||||
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
|
||||
const legacyCandidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isBlake3AssetHash(candidate.name)) {
|
||||
legacyCandidates.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(candidate.name)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(candidate.name, [candidate])
|
||||
}
|
||||
|
||||
return { candidatesByHash, legacyCandidates }
|
||||
}
|
||||
|
||||
async function verifyCandidatesByHash(
|
||||
candidatesByHash: Map<string, MissingMediaCandidate[]>,
|
||||
legacyCandidates: MissingMediaCandidate[],
|
||||
signal: AbortSignal | undefined,
|
||||
checkAssetHash: AssetHashVerifier
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
let status: AssetHashStatus
|
||||
try {
|
||||
status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
legacyCandidates.push(...hashCandidates)
|
||||
return
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = status === 'missing'
|
||||
}
|
||||
})
|
||||
)
|
||||
resolveAssetSources?: MissingMediaAssetResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates by probing the asset hash endpoint first.
|
||||
* Invalid hash values fall back to the legacy input asset list check.
|
||||
* Verify media candidates against assets available to the current runtime.
|
||||
*
|
||||
* A candidate's `name` may be either a filename or an opaque asset hash.
|
||||
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.asset_hash`. Output
|
||||
* candidates are matched against Cloud output assets or Core generated-history
|
||||
* assets because Core resolves those annotations against output folders, not
|
||||
* input files.
|
||||
* Cloud accepts compact annotated media paths, so only Cloud verification
|
||||
* normalizes compact suffixes.
|
||||
*/
|
||||
export async function verifyCloudMediaCandidates(
|
||||
export async function verifyMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
signal?: AbortSignal,
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
|
||||
fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
|
||||
{
|
||||
isCloud,
|
||||
signal,
|
||||
resolveAssetSources = resolveMissingMediaAssetSources
|
||||
}: MediaVerificationOptions
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const { candidatesByHash, legacyCandidates } =
|
||||
groupCandidatesForHashLookup(pending)
|
||||
await verifyCandidatesByHash(
|
||||
candidatesByHash,
|
||||
legacyCandidates,
|
||||
signal,
|
||||
checkAssetHash
|
||||
// Core stores spaced annotations such as `file.png [output]`; Cloud also
|
||||
// accepts compact forms such as `file.png[output]`.
|
||||
const pathOptions = { allowCompactSuffix: isCloud }
|
||||
const generatedMatchNames = getGeneratedCandidateMatchNames(
|
||||
pending,
|
||||
pathOptions
|
||||
)
|
||||
|
||||
if (signal?.aborted || legacyCandidates.length === 0) return
|
||||
|
||||
let inputAssets: AssetItem[]
|
||||
let generatedAssets: AssetItem[]
|
||||
try {
|
||||
inputAssets = await fetchInputAssets(signal)
|
||||
const assetSources = await resolveAssetSources({
|
||||
signal,
|
||||
isCloud,
|
||||
includeGeneratedAssets: generatedMatchNames.size > 0,
|
||||
generatedMatchNames,
|
||||
allowCompactSuffix: isCloud
|
||||
})
|
||||
inputAssets = assetSources.inputAssets
|
||||
generatedAssets = assetSources.generatedAssets
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
throw err
|
||||
@@ -212,28 +188,62 @@ export async function verifyCloudMediaCandidates(
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
const assetHashes = new Set(
|
||||
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
const inputAssetIdentifiers = new Set<string>()
|
||||
const outputAssetIdentifiers = new Set<string>()
|
||||
addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
|
||||
addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
|
||||
|
||||
for (const candidate of legacyCandidates) {
|
||||
candidate.isMissing = !assetHashes.has(candidate.name)
|
||||
for (const candidate of pending) {
|
||||
const detectionNames = getMediaPathDetectionNames(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
const type = getAnnotatedMediaPathTypeForDetection(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
const identifiers =
|
||||
type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
|
||||
candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingInputAssets(
|
||||
signal?: AbortSignal
|
||||
): Promise<AssetItem[]> {
|
||||
return await assetService.getInputAssetsIncludingPublic(signal)
|
||||
function getGeneratedCandidateMatchNames(
|
||||
candidates: MissingMediaCandidate[],
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const candidate of candidates) {
|
||||
if (!isGeneratedCandidate(candidate, pathOptions)) continue
|
||||
|
||||
names.add(
|
||||
normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
|
||||
)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
function isGeneratedCandidate(
|
||||
candidate: MissingMediaCandidate,
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
): boolean {
|
||||
const type = getAnnotatedMediaPathTypeForDetection(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
return type === 'output'
|
||||
}
|
||||
|
||||
function addAssetIdentifiers(
|
||||
identifiers: Set<string>,
|
||||
assets: AssetItem[],
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
) {
|
||||
for (const asset of assets) {
|
||||
for (const name of getAssetDetectionNames(asset, pathOptions)) {
|
||||
identifiers.add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface MissingMediaCandidate {
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed present
|
||||
* - `undefined` — pending async verification (cloud only)
|
||||
* - `undefined` — pending async verification. Cloud candidates start pending;
|
||||
* OSS output annotated paths may also be deferred to generated-history
|
||||
* verification.
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
|
||||
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'
|
||||
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
|
||||
|
||||
const { mockCheckAssetHash } = vi.hoisted(() => ({
|
||||
mockCheckAssetHash: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
@@ -33,20 +28,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', async () => {
|
||||
const actual = await vi.importActual<typeof AssetServiceModule>(
|
||||
'@/platform/assets/services/assetService'
|
||||
)
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assetService: {
|
||||
...actual.assetService,
|
||||
checkAssetHash: mockCheckAssetHash
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Helper: create a combo widget mock */
|
||||
function makeComboWidget(
|
||||
name: string,
|
||||
@@ -1391,23 +1372,14 @@ describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const {
|
||||
mockUpdateModelsForNodeType,
|
||||
mockIsModelLoading,
|
||||
mockHasMore,
|
||||
mockGetAssets
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({
|
||||
mockUpdateModelsForNodeType: vi.fn().mockResolvedValue(undefined),
|
||||
mockIsModelLoading: vi.fn().mockReturnValue(false),
|
||||
mockHasMore: vi.fn().mockReturnValue(false),
|
||||
mockGetAssets: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType,
|
||||
isModelLoading: mockIsModelLoading,
|
||||
hasMore: mockHasMore,
|
||||
getAssets: mockGetAssets
|
||||
})
|
||||
}))
|
||||
@@ -1440,9 +1412,7 @@ function makeAssetCandidate(
|
||||
describe('verifyAssetSupportedCandidates', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockIsModelLoading.mockReturnValue(false)
|
||||
mockHasMore.mockReturnValue(false)
|
||||
mockUpdateModelsForNodeType.mockResolvedValue(undefined)
|
||||
mockGetAssets.mockReturnValue([])
|
||||
})
|
||||
|
||||
@@ -1458,84 +1428,15 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
|
||||
const hash =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('exists')
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when the blake3 hash is not found', async () => {
|
||||
it('should match filenames regardless of hash metadata shape', async () => {
|
||||
const hash =
|
||||
'2222222222222222222222222222222222222222222222222222222222222222'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockResolvedValue('missing')
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to asset store matching when hash verification fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hash =
|
||||
'3333333333333333333333333333333333333333333333333333333333333333'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should skip malformed blake3 hashes and use asset store matching', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
}),
|
||||
makeAssetCandidate('other_model.safetensors', {
|
||||
hash: 'abc123',
|
||||
hashType: 'blake3'
|
||||
})
|
||||
@@ -1546,38 +1447,25 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'other_model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'other_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not warn or fall back when hash verification is aborted', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const abortError = new Error('aborted')
|
||||
abortError.name = 'AbortError'
|
||||
const hash =
|
||||
'4444444444444444444444444444444444444444444444444444444444444444'
|
||||
const candidates = [
|
||||
makeAssetCandidate('my_model.safetensors', {
|
||||
hash,
|
||||
hashType: 'blake3'
|
||||
})
|
||||
]
|
||||
mockCheckAssetHash.mockRejectedValue(abortError)
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash: 'abc123',
|
||||
@@ -1591,7 +1479,6 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(mockCheckAssetHash).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching filename exists', async () => {
|
||||
@@ -1675,6 +1562,55 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
|
||||
})
|
||||
|
||||
it('should leave candidates unresolved when their node type fails to load', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('checkpoint.safetensors', {
|
||||
nodeType: 'CheckpointLoaderSimple'
|
||||
}),
|
||||
makeAssetCandidate('lora.safetensors', { nodeType: 'LoraLoader' })
|
||||
]
|
||||
mockUpdateModelsForNodeType.mockImplementation(async (nodeType: string) => {
|
||||
if (nodeType === 'LoraLoader') throw new Error('load failed')
|
||||
})
|
||||
mockGetAssets.mockImplementation((nodeType: string) =>
|
||||
nodeType === 'CheckpointLoaderSimple'
|
||||
? [
|
||||
{
|
||||
id: '1',
|
||||
name: 'checkpoint.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'checkpoint.safetensors' }
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
expect(candidates[1].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should leave candidates unresolved when aborted after asset loads settle', async () => {
|
||||
const controller = new AbortController()
|
||||
const candidates = [makeAssetCandidate('model.safetensors')]
|
||||
mockUpdateModelsForNodeType.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
})
|
||||
mockGetAssets.mockReturnValue([
|
||||
{
|
||||
id: '1',
|
||||
name: 'model.safetensors',
|
||||
asset_hash: null,
|
||||
metadata: { filename: 'model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates, controller.signal)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should match filename with path prefix normalization', async () => {
|
||||
const candidates = [makeAssetCandidate('subfolder/my_model.safetensors')]
|
||||
mockGetAssets.mockReturnValue([
|
||||
|
||||
@@ -24,11 +24,6 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
assetService,
|
||||
toBlake3AssetHash
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
|
||||
models?: ModelFile[]
|
||||
@@ -450,16 +445,10 @@ interface AssetVerifier {
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
type AssetHashVerifier = (
|
||||
assetHash: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<AssetHashStatus>
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: AssetVerifier,
|
||||
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
|
||||
assetsStore?: AssetVerifier
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
@@ -468,52 +457,10 @@ export async function verifyAssetSupportedCandidates(
|
||||
)
|
||||
if (pendingCandidates.length === 0) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
|
||||
|
||||
for (const candidate of pendingCandidates) {
|
||||
const assetHash = getBlake3AssetHash(candidate)
|
||||
if (!assetHash) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashCandidates = candidatesByHash.get(assetHash)
|
||||
if (hashCandidates) hashCandidates.push(candidate)
|
||||
else candidatesByHash.set(assetHash, [candidate])
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
|
||||
if (signal?.aborted) return
|
||||
|
||||
try {
|
||||
const status = await checkAssetHash(assetHash, signal)
|
||||
if (signal?.aborted) return
|
||||
|
||||
if (status === 'exists') {
|
||||
for (const candidate of hashCandidates) {
|
||||
candidate.isMissing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || isAbortError(err)) return
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] Failed to verify asset hash:',
|
||||
err
|
||||
)
|
||||
}
|
||||
|
||||
for (const candidate of hashCandidates) {
|
||||
pendingNodeTypes.add(candidate.nodeType)
|
||||
}
|
||||
})
|
||||
const pendingNodeTypes = new Set(
|
||||
pendingCandidates.map((candidate) => candidate.nodeType)
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
if (pendingNodeTypes.size === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
|
||||
@@ -544,20 +491,6 @@ export async function verifyAssetSupportedCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
|
||||
if (candidate.hashType?.toLowerCase() !== 'blake3') return null
|
||||
return toBlake3AssetHash(candidate.hash)
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
err.name === 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractWorkflow,
|
||||
fetchHistory,
|
||||
fetchHistoryPage,
|
||||
fetchJobDetail,
|
||||
fetchQueue
|
||||
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
@@ -29,15 +30,16 @@ function createMockJob(
|
||||
|
||||
function createMockResponse(
|
||||
jobs: RawJobListItem[],
|
||||
total: number = jobs.length
|
||||
total: number = jobs.length,
|
||||
pagination: Partial<JobsListResponse['pagination']> = {}
|
||||
): JobsListResponse {
|
||||
return {
|
||||
jobs,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
offset: pagination.offset ?? 0,
|
||||
limit: pagination.limit ?? 200,
|
||||
total,
|
||||
has_more: false
|
||||
has_more: pagination.has_more ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +102,8 @@ describe('fetchJobs', () => {
|
||||
createMockJob('job4', 'completed'),
|
||||
createMockJob('job5', 'completed')
|
||||
],
|
||||
10 // total of 10 jobs
|
||||
10, // total of 10 jobs
|
||||
{ offset: 5 }
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -185,6 +188,36 @@ describe('fetchJobs', () => {
|
||||
expect(result[1].id).toBe('text-job')
|
||||
expect(result[2].id).toBe('no-preview-job')
|
||||
})
|
||||
|
||||
it('returns server pagination metadata for history pages', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse(
|
||||
[
|
||||
createMockJob('job4', 'completed'),
|
||||
createMockJob('job5', 'completed')
|
||||
],
|
||||
10,
|
||||
{ offset: 5, limit: 2, has_more: true }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistoryPage(mockFetch, 2, 5)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/jobs?status=completed,failed,cancelled&limit=2&offset=5'
|
||||
)
|
||||
expect(result.jobs).toHaveLength(2)
|
||||
expect(result.offset).toBe(5)
|
||||
expect(result.limit).toBe(2)
|
||||
expect(result.total).toBe(10)
|
||||
expect(result.hasMore).toBe(true)
|
||||
expect(result.jobs[0].priority).toBe(5)
|
||||
expect(result.jobs[1].priority).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchQueue', () => {
|
||||
|
||||
@@ -22,6 +22,16 @@ interface FetchJobsRawResult {
|
||||
jobs: RawJobListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface FetchHistoryPageResult {
|
||||
jobs: JobListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,13 +50,25 @@ async function fetchJobsRaw(
|
||||
const res = await fetchApi(url)
|
||||
if (!res.ok) {
|
||||
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
|
||||
return { jobs: [], total: 0, offset: 0 }
|
||||
return {
|
||||
jobs: [],
|
||||
total: 0,
|
||||
offset,
|
||||
limit: maxItems,
|
||||
hasMore: false
|
||||
}
|
||||
}
|
||||
const data = zJobsListResponse.parse(await res.json())
|
||||
return { jobs: data.jobs, total: data.pagination.total, offset }
|
||||
return {
|
||||
jobs: data.jobs,
|
||||
total: data.pagination.total,
|
||||
offset: data.pagination.offset,
|
||||
limit: data.pagination.limit,
|
||||
hasMore: data.pagination.has_more
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Jobs API] Error fetching jobs:', error)
|
||||
return { jobs: [], total: 0, offset: 0 }
|
||||
return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +98,33 @@ export async function fetchHistory(
|
||||
maxItems: number = 200,
|
||||
offset: number = 0
|
||||
): Promise<JobListItem[]> {
|
||||
const { jobs, total } = await fetchJobsRaw(
|
||||
const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
|
||||
return jobs
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of history with server-provided pagination metadata.
|
||||
*/
|
||||
export async function fetchHistoryPage(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200,
|
||||
offset: number = 0
|
||||
): Promise<FetchHistoryPageResult> {
|
||||
const result = await fetchJobsRaw(
|
||||
fetchApi,
|
||||
['completed', 'failed', 'cancelled'],
|
||||
maxItems,
|
||||
offset
|
||||
)
|
||||
|
||||
// History gets priority based on total count (lower than queue)
|
||||
return assignPriority(jobs, total - offset)
|
||||
return {
|
||||
jobs: assignPriority(result.jobs, result.total - result.offset),
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
hasMore: result.hasMore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { LinkMarkerShape, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getDefaultLocale,
|
||||
SUPPORTED_LOCALE_OPTIONS
|
||||
} from '@/locales/localeConfig'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
@@ -439,21 +443,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Language',
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: 'en', text: 'English' },
|
||||
{ value: 'zh', text: '中文' },
|
||||
{ value: 'zh-TW', text: '繁體中文' },
|
||||
{ value: 'ru', text: 'Русский' },
|
||||
{ value: 'ja', text: '日本語' },
|
||||
{ value: 'ko', text: '한국어' },
|
||||
{ value: 'fr', text: 'Français' },
|
||||
{ value: 'es', text: 'Español' },
|
||||
{ value: 'ar', text: 'عربي' },
|
||||
{ value: 'tr', text: 'Türkçe' },
|
||||
{ value: 'pt-BR', text: 'Português (BR)' },
|
||||
{ value: 'fa', text: 'فارسی' }
|
||||
],
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
options: SUPPORTED_LOCALE_OPTIONS,
|
||||
defaultValue: getDefaultLocale
|
||||
},
|
||||
{
|
||||
id: 'Comfy.NodeBadge.NodeSourceBadgeMode',
|
||||
|
||||
@@ -34,6 +34,7 @@ const i18n = createI18n({
|
||||
copyAssetsAndOpen: 'Copy assets & open workflow',
|
||||
openWorkflow: 'Open workflow',
|
||||
openWithoutImporting: 'Open without importing',
|
||||
opening: 'Opening shared workflow...',
|
||||
loadError:
|
||||
'Could not load this shared workflow. Please try again later.'
|
||||
},
|
||||
@@ -292,6 +293,25 @@ describe('OpenSharedWorkflowDialogContent', () => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(assetsPayload)
|
||||
})
|
||||
|
||||
it('shows opening status and disables actions while opening', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const { container } = renderComponent({ openingAction: 'copy-and-open' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'Opening shared workflow...'
|
||||
)
|
||||
expect(container.textContent).not.toContain(
|
||||
'Opening the workflow will create a new copy in your workspace'
|
||||
)
|
||||
expect(screen.getByTestId('open-shared-workflow-close')).toBeEnabled()
|
||||
expect(screen.getByTestId('open-shared-workflow-cancel')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByTestId('open-shared-workflow-open-without-importing')
|
||||
).toBeDisabled()
|
||||
expect(screen.getByTestId('open-shared-workflow-confirm')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters out assets already in library', async () => {
|
||||
const mixedPayload = makePayload({
|
||||
assets: [
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col">
|
||||
<div
|
||||
data-testid="open-shared-workflow-dialog"
|
||||
class="flex w-full flex-col"
|
||||
:aria-busy="isOpening"
|
||||
>
|
||||
<header
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="text-sm text-base-foreground">
|
||||
<h2
|
||||
data-testid="open-shared-workflow-title"
|
||||
class="text-sm text-base-foreground"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.dialogTitle') }}
|
||||
</h2>
|
||||
<Button size="icon" :aria-label="$t('g.close')" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-close"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
@@ -43,7 +55,12 @@
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-error-close"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</footer>
|
||||
@@ -55,8 +72,23 @@
|
||||
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ workflowName }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('openSharedWorkflow.copyDescription') }}
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="m-0 flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<i
|
||||
v-if="isOpening"
|
||||
class="icon-[lucide--loader-circle] size-4 motion-safe:animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
isOpening
|
||||
? $t('openSharedWorkflow.opening')
|
||||
: $t('openSharedWorkflow.copyDescription')
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -102,18 +134,34 @@
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-cancel"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isOpening"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAssets"
|
||||
data-testid="open-shared-workflow-open-without-importing"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="openingAction === 'open-only'"
|
||||
:disabled="isOpening"
|
||||
@click="onOpenWithoutImporting(sharedWorkflow)"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.openWithoutImporting') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onConfirm(sharedWorkflow)">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-confirm"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="openingAction === 'copy-and-open'"
|
||||
:disabled="isOpening"
|
||||
@click="onConfirm(sharedWorkflow)"
|
||||
>
|
||||
{{
|
||||
hasAssets
|
||||
? $t('openSharedWorkflow.copyAssetsAndOpen')
|
||||
@@ -141,8 +189,17 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { shareId, onConfirm, onOpenWithoutImporting, onCancel } = defineProps<{
|
||||
type OpeningAction = 'copy-and-open' | 'open-only'
|
||||
|
||||
const {
|
||||
shareId,
|
||||
openingAction = null,
|
||||
onConfirm,
|
||||
onOpenWithoutImporting,
|
||||
onCancel
|
||||
} = defineProps<{
|
||||
shareId: string
|
||||
openingAction?: OpeningAction | null
|
||||
onConfirm: (payload: SharedWorkflowPayload) => void
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => void
|
||||
onCancel: () => void
|
||||
@@ -162,6 +219,7 @@ const nonOwnedAssets = computed(
|
||||
)
|
||||
|
||||
const hasAssets = computed(() => nonOwnedAssets.value.length > 0)
|
||||
const isOpening = computed(() => openingAction !== null)
|
||||
|
||||
const workflowName = computed(() => {
|
||||
if (!sharedWorkflow.value) return ''
|
||||
|
||||
@@ -80,6 +80,15 @@ vi.mock('vue-i18n', () => ({
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockHideTemplateSelector = vi.hoisted(() => vi.fn())
|
||||
const mockDialogStack = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
key: string
|
||||
contentProps: Record<string, unknown>
|
||||
dialogComponentProps: Record<string, unknown>
|
||||
}>
|
||||
)
|
||||
const mockUpdateDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
@@ -89,7 +98,9 @@ vi.mock('@/services/dialogService', () => ({
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
dialogStack: mockDialogStack,
|
||||
closeDialog: mockCloseDialog,
|
||||
updateDialog: mockUpdateDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -117,17 +128,11 @@ function makePayload(
|
||||
}
|
||||
|
||||
function resolveDialogWithConfirm(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onConfirm(payload)
|
||||
getLastDialogOptions().props.onConfirm(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithOpenOnly(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onOpenWithoutImporting(payload)
|
||||
getLastDialogOptions().props.onOpenWithoutImporting(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithCancel() {
|
||||
@@ -137,10 +142,66 @@ function resolveDialogWithCancel() {
|
||||
options.props.onCancel()
|
||||
}
|
||||
|
||||
function getLastDialogOptions() {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
return call[0]
|
||||
}
|
||||
|
||||
function createDialogInstance(options: {
|
||||
key: string
|
||||
props: Record<string, unknown>
|
||||
dialogComponentProps?: Record<string, unknown>
|
||||
}) {
|
||||
const dialog = {
|
||||
key: options.key,
|
||||
contentProps: { ...options.props },
|
||||
dialogComponentProps: { ...options.dialogComponentProps }
|
||||
}
|
||||
mockDialogStack.push(dialog)
|
||||
return dialog
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useSharedWorkflowUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockDialogStack.length = 0
|
||||
mockShowLayoutDialog.mockImplementation(createDialogInstance)
|
||||
mockUpdateDialog.mockImplementation(
|
||||
(options: {
|
||||
key: string
|
||||
contentProps?: Record<string, unknown>
|
||||
dialogComponentProps?: Record<string, unknown>
|
||||
}) => {
|
||||
const dialog = mockDialogStack.find((item) => item.key === options.key)
|
||||
if (!dialog) return false
|
||||
|
||||
if (options.contentProps) {
|
||||
dialog.contentProps = {
|
||||
...dialog.contentProps,
|
||||
...options.contentProps
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dialogComponentProps) {
|
||||
dialog.dialogComponentProps = {
|
||||
...dialog.dialogComponentProps,
|
||||
...options.dialogComponentProps
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
@@ -193,6 +254,38 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps dialog open with opening state while shared workflow loads', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const graphLoad = createDeferred()
|
||||
mockLoadGraphData.mockReturnValue(graphLoad.promise)
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loadPromise = loadSharedWorkflowFromUrl()
|
||||
await Promise.resolve()
|
||||
const dialogOptions = getLastDialogOptions()
|
||||
const dialogInstance = mockShowLayoutDialog.mock.results[0].value
|
||||
|
||||
dialogOptions.props.onConfirm(makePayload())
|
||||
await Promise.resolve()
|
||||
|
||||
expect(dialogInstance.contentProps.openingAction).toBe('copy-and-open')
|
||||
expect(mockUpdateDialog).toHaveBeenCalledWith({
|
||||
key: 'open-shared-workflow',
|
||||
contentProps: { openingAction: 'copy-and-open' }
|
||||
})
|
||||
expect(dialogInstance.dialogComponentProps.closable).toBeUndefined()
|
||||
expect(dialogInstance.dialogComponentProps.closeOnEscape).toBeUndefined()
|
||||
expect(dialogInstance.dialogComponentProps.dismissableMask).toBeUndefined()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
|
||||
graphLoad.resolve()
|
||||
await loadPromise
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenLastCalledWith({
|
||||
key: 'open-shared-workflow'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not load graph when user cancels dialog', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
@@ -222,7 +315,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockHideTemplateSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls import when non-owned assets exist and user confirms', async () => {
|
||||
it('imports non-owned assets before loading graph when user confirms', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
@@ -242,9 +335,13 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
expect(loaded).toBe('loaded')
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
expect(mockImportPublishedAssets.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockLoadGraphData.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call import when user chooses open-only', async () => {
|
||||
@@ -309,6 +406,13 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded-without-assets')
|
||||
expect(mockLoadGraphData).toHaveBeenCalledWith(
|
||||
{ nodes: [] },
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
@@ -317,6 +421,37 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clears share intent when graph load fails after importing assets', async () => {
|
||||
mockQueryParams = { share: 'share-id-1', tab: 'assets' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'img.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
mockLoadGraphData.mockRejectedValue(new Error('Graph load failed'))
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('failed')
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: { tab: 'assets' } })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
})
|
||||
|
||||
it('filters out in_library assets before importing', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
@@ -348,7 +483,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
})
|
||||
|
||||
it('restores preserved share query before loading', async () => {
|
||||
|
||||
@@ -28,6 +28,10 @@ type DialogResult =
|
||||
| { action: 'open-only'; payload: SharedWorkflowPayload }
|
||||
| { action: 'cancel' }
|
||||
|
||||
type OpeningAction = Exclude<DialogResult['action'], 'cancel'>
|
||||
|
||||
const OPEN_SHARED_WORKFLOW_DIALOG_KEY = 'open-shared-workflow'
|
||||
|
||||
export function useSharedWorkflowUrlLoader() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -63,28 +67,39 @@ export function useSharedWorkflowUrlLoader() {
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
function clearShareIntent() {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
}
|
||||
|
||||
function showOpenSharedWorkflowDialog(
|
||||
shareId: string
|
||||
): Promise<DialogResult> {
|
||||
const dialogKey = 'open-shared-workflow'
|
||||
function setOpeningAction(openingAction: OpeningAction) {
|
||||
dialogStore.updateDialog({
|
||||
key: OPEN_SHARED_WORKFLOW_DIALOG_KEY,
|
||||
contentProps: { openingAction }
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<DialogResult>((resolve) => {
|
||||
dialogService.showLayoutDialog({
|
||||
key: dialogKey,
|
||||
key: OPEN_SHARED_WORKFLOW_DIALOG_KEY,
|
||||
component: OpenSharedWorkflowDialogContent,
|
||||
props: {
|
||||
shareId,
|
||||
openingAction: null,
|
||||
onConfirm: (payload: SharedWorkflowPayload) => {
|
||||
setOpeningAction('copy-and-open')
|
||||
resolve({ action: 'copy-and-open', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => {
|
||||
setOpeningAction('open-only')
|
||||
resolve({ action: 'open-only', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve({ action: 'cancel' })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
dialogStore.closeDialog({ key: OPEN_SHARED_WORKFLOW_DIALOG_KEY })
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
@@ -108,8 +123,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
}
|
||||
|
||||
if (typeof shareParam !== 'string') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'not-present'
|
||||
}
|
||||
|
||||
@@ -122,66 +136,74 @@ export function useSharedWorkflowUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const result = await showOpenSharedWorkflowDialog(shareParam)
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
templateSelectorDialog.hide()
|
||||
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
|
||||
try {
|
||||
await app.loadGraphData(payload.workflowJson, true, true, workflowName, {
|
||||
openSource: 'shared_url'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
error
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
let importFailed = false
|
||||
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id),
|
||||
payload.shareId
|
||||
)
|
||||
} catch (importError) {
|
||||
importFailed = true
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to import assets:',
|
||||
importError
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('openSharedWorkflow.importFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id)
|
||||
await app.loadGraphData(
|
||||
payload.workflowJson,
|
||||
true,
|
||||
true,
|
||||
workflowName,
|
||||
{
|
||||
openSource: 'shared_url'
|
||||
}
|
||||
)
|
||||
} catch (importError) {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to import assets:',
|
||||
importError
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
error
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('openSharedWorkflow.importFailed')
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded-without-assets'
|
||||
clearShareIntent()
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded'
|
||||
clearShareIntent()
|
||||
return importFailed ? 'loaded-without-assets' : 'loaded'
|
||||
} finally {
|
||||
dialogStore.closeDialog({ key: OPEN_SHARED_WORKFLOW_DIALOG_KEY })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
|
||||
const mockGetShareableAssets = vi.fn()
|
||||
const mockFetchApi = vi.fn()
|
||||
const mockInvalidateInputAssetsIncludingPublic = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/validation/schemas/workflowSchema',
|
||||
@@ -32,6 +33,13 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
invalidateInputAssetsIncludingPublic:
|
||||
mockInvalidateInputAssetsIncludingPublic
|
||||
}
|
||||
}))
|
||||
|
||||
describe(useWorkflowShareService, () => {
|
||||
const mockShareableAssets: AssetInfo[] = [
|
||||
{
|
||||
@@ -334,16 +342,46 @@ describe(useWorkflowShareService, () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('imports published assets via POST /assets/import', async () => {
|
||||
it('imports published assets via POST /assets/import with share_id', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1', 'pa-2'])
|
||||
await service.importPublishedAssets(['pa-1', 'pa-2'], 'share-id-1')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1', 'pa-2'] })
|
||||
body: JSON.stringify({
|
||||
published_asset_ids: ['pa-1', 'pa-2'],
|
||||
share_id: 'share-id-1'
|
||||
})
|
||||
})
|
||||
expect(mockInvalidateInputAssetsIncludingPublic).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('omits share_id from the payload when not provided', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1'])
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('omits share_id from the payload when shareId is an empty string', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
await service.importPublishedAssets(['pa-1'], '')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -352,9 +390,10 @@ describe(useWorkflowShareService, () => {
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
|
||||
await expect(service.importPublishedAssets(['bad-id'])).rejects.toThrow(
|
||||
'Failed to import assets: 400'
|
||||
)
|
||||
await expect(
|
||||
service.importPublishedAssets(['bad-id'], 'share-id-1')
|
||||
).rejects.toThrow('Failed to import assets: 400')
|
||||
expect(mockInvalidateInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when shared workflow payload is invalid', async () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ImportPublishedAssetsRequest } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
PublishPrefill,
|
||||
SharedWorkflowPayload,
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
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'
|
||||
@@ -255,16 +258,26 @@ export function useWorkflowShareService() {
|
||||
return workflow
|
||||
}
|
||||
|
||||
async function importPublishedAssets(assetIds: string[]): Promise<void> {
|
||||
async function importPublishedAssets(
|
||||
assetIds: string[],
|
||||
shareId?: string
|
||||
): Promise<void> {
|
||||
const body: ImportPublishedAssetsRequest = {
|
||||
published_asset_ids: assetIds,
|
||||
...(shareId ? { share_id: shareId } : {})
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/assets/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ published_asset_ids: assetIds })
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import assets: ${response.status}`)
|
||||
}
|
||||
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -68,10 +68,16 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
<Button
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
<!-- Upgrade to add credits (free tier) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
|
||||
|
||||
@@ -46,10 +46,17 @@ function createMockPointerEvent(
|
||||
return mockEvent as PointerEvent
|
||||
}
|
||||
|
||||
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
|
||||
function createMockWheelEvent(
|
||||
ctrlKey = false,
|
||||
metaKey = false,
|
||||
deltaX = 0,
|
||||
deltaY = 0
|
||||
): WheelEvent {
|
||||
const mockEvent: Partial<WheelEvent> = {
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
deltaX,
|
||||
deltaY,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad pinch-zoom inside a focused textarea must not
|
||||
* fall through to browser page zoom in non-standard navigation modes. */
|
||||
it.for(['legacy', 'custom'])(
|
||||
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should forward meta+wheel to canvas when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, true)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
|
||||
/** Regression: trackpad two-finger horizontal swipes inside a focused
|
||||
* textarea must not fall through to browser back/forward navigation. */
|
||||
it.for(['standard', 'legacy', 'custom'])(
|
||||
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
|
||||
(mode) => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue(mode)
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 30, 5)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
}
|
||||
)
|
||||
|
||||
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
|
||||
const { get } = useSettingStore()
|
||||
vi.mocked(get).mockReturnValue('standard')
|
||||
|
||||
const captureElement = document.createElement('div')
|
||||
captureElement.setAttribute('data-capture-wheel', 'true')
|
||||
const textarea = document.createElement('textarea')
|
||||
captureElement.appendChild(textarea)
|
||||
document.body.appendChild(captureElement)
|
||||
textarea.focus()
|
||||
|
||||
const { handleWheel } = useCanvasInteractions()
|
||||
const mockEvent = createMockWheelEvent(false, false, 0, 30)
|
||||
Object.defineProperty(mockEvent, 'target', { value: textarea })
|
||||
|
||||
handleWheel(mockEvent)
|
||||
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(captureElement)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
|
||||
return !!(captureElement && active && captureElement.contains(active))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward to canvas when the event is not consumed by a focused widget,
|
||||
* or when it is a canvas gesture (which must override widget consumption
|
||||
* to prevent destructive browser defaults).
|
||||
*/
|
||||
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
|
||||
!wheelCapturedByFocusedElement(event) ||
|
||||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
|
||||
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
|
||||
* mode; all wheel events in legacy mode).
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!shouldForwardWheelEvent(event)) return
|
||||
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
forwardEventToCanvas(event)
|
||||
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
|
||||
// vertical wheel falls through so the document/widget scrolls normally.
|
||||
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
|
||||
// that function also returns true for unfocused vertical wheel (its
|
||||
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
|
||||
if (isStandardNavMode.value) {
|
||||
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import NodeFooter from '@/renderer/extensions/vueNodes/components/NodeFooter.vue'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
return { layoutStore: { isDraggingVueNodes } }
|
||||
})
|
||||
|
||||
const { layoutStore } = await import('@/renderer/core/layout/store/layoutStore')
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -143,6 +151,33 @@ describe('NodeFooter', () => {
|
||||
await user.click(screen.getByText('Show Advanced Inputs'))
|
||||
expect(emitted()).toHaveProperty('toggleAdvanced')
|
||||
})
|
||||
|
||||
describe('drag-then-click suppression', () => {
|
||||
beforeEach(() => {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
})
|
||||
|
||||
it('does not emit enterSubgraph when a node drag is in progress at pointerup', async () => {
|
||||
const { emitted } = renderFooter({ isSubgraph: true })
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await user.click(screen.getByTestId('subgraph-enter-button'))
|
||||
|
||||
expect(emitted().enterSubgraph).toBeUndefined()
|
||||
})
|
||||
|
||||
it('only suppresses the immediately following click, not later ones', async () => {
|
||||
const { emitted } = renderFooter({ isSubgraph: true })
|
||||
const button = screen.getByTestId('subgraph-enter-button')
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await user.click(button)
|
||||
expect(emitted().enterSubgraph).toBeUndefined()
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
await user.click(button)
|
||||
expect(emitted()).toHaveProperty('enterSubgraph')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shape-based radius classes (getBottomRadius)', () => {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('openErrors')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
@@ -32,7 +33,8 @@
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
@@ -60,7 +62,8 @@
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('openErrors')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
@@ -78,7 +81,8 @@
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{
|
||||
@@ -111,7 +115,8 @@
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('openErrors')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
@@ -142,7 +147,8 @@
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
|
||||
@@ -172,7 +178,8 @@
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
@pointerup="snapshotDragOnPointerUp"
|
||||
@click.stop="emitIfNotDragged('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
@@ -197,6 +204,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -221,12 +229,29 @@ const {
|
||||
shape
|
||||
} = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
enterSubgraph: []
|
||||
openErrors: []
|
||||
toggleAdvanced: []
|
||||
}>()
|
||||
|
||||
let suppressNextClick = false
|
||||
|
||||
function snapshotDragOnPointerUp() {
|
||||
suppressNextClick = layoutStore.isDraggingVueNodes.value
|
||||
}
|
||||
|
||||
function emitIfNotDragged(
|
||||
name: 'enterSubgraph' | 'openErrors' | 'toggleAdvanced'
|
||||
) {
|
||||
const wasDrag = suppressNextClick
|
||||
suppressNextClick = false
|
||||
if (wasDrag) return
|
||||
if (name === 'enterSubgraph') emit('enterSubgraph')
|
||||
else if (name === 'openErrors') emit('openErrors')
|
||||
else emit('toggleAdvanced')
|
||||
}
|
||||
|
||||
const RADIUS_CLASS = {
|
||||
'rounded-b-17': 'rounded-b-[17px]',
|
||||
'rounded-b-20': 'rounded-b-[20px]',
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
@@ -47,7 +49,9 @@ const modelValue = defineModel<string | undefined>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const outputMediaAssets = isCloud
|
||||
? useFlatOutputAssets()
|
||||
: useMediaAssets('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
|
||||
@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
const { container } = render(FormDropdownMenu, {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
container.firstElementChild!.getAttribute('data-capture-wheel')
|
||||
screen
|
||||
.getByTestId('form-dropdown-menu')
|
||||
.getAttribute('data-capture-wheel')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
/** Regression: PrimeVue Popover teleports the menu to document.body, so
|
||||
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
|
||||
* itself rather than relying on the LGraphNode wheel handler. */
|
||||
it.for([
|
||||
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
|
||||
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
|
||||
])('suppresses browser default for $name', ({ overrides }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: 30,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
|
||||
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
|
||||
* Suppress only the destructive browser defaults (page zoom on pinch and
|
||||
* back/forward on horizontal swipe); regular vertical scrolling still
|
||||
* scrolls the dropdown's own content.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (isCanvasGestureWheel(event)) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
|
||||
data-capture-wheel="true"
|
||||
data-testid="form-dropdown-menu"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type CapturedImageUploadOptions = {
|
||||
onUploadComplete: (paths: (string | ResultItem)[]) => void
|
||||
allow_batch?: boolean
|
||||
folder?: ResultItemType
|
||||
onUploadStart?: (files: File[]) => void
|
||||
onUploadError?: () => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedUploadOptions: undefined as CapturedImageUploadOptions | undefined,
|
||||
openFileSelection: vi.fn(),
|
||||
setNodeOutputs: vi.fn(),
|
||||
showPreview: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeImage', () => ({
|
||||
useNodeImage: () => ({ showPreview: mocks.showPreview }),
|
||||
useNodeVideo: () => ({ showPreview: mocks.showPreview })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeImageUpload', () => ({
|
||||
useNodeImageUpload: (
|
||||
_node: LGraphNode,
|
||||
options: CapturedImageUploadOptions
|
||||
) => {
|
||||
mocks.capturedUploadOptions = options
|
||||
return { openFileSelection: mocks.openFileSelection }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
setNodeOutputs: mocks.setNodeOutputs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
addToComboValues: (widget: IComboWidget, value: string) => {
|
||||
const values = widget.options?.values
|
||||
if (Array.isArray(values) && !values.includes(value)) {
|
||||
values.push(value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createUploadNode() {
|
||||
const onWidgetChanged = vi.fn()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
node.type = 'LoadImage'
|
||||
node.onWidgetChanged = onWidgetChanged
|
||||
const fileComboWidget = node.addWidget(
|
||||
'combo',
|
||||
'image',
|
||||
'missing.png',
|
||||
() => undefined,
|
||||
{ values: ['missing.png'] }
|
||||
) as IComboWidget
|
||||
|
||||
return { fileComboWidget, node, onWidgetChanged }
|
||||
}
|
||||
|
||||
describe('useImageUploadWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.capturedUploadOptions = undefined
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('emits onWidgetChanged after upload changes the combo widget value', () => {
|
||||
const { fileComboWidget, node, onWidgetChanged } = createUploadNode()
|
||||
const constructor = useImageUploadWidget()
|
||||
|
||||
constructor(
|
||||
node,
|
||||
'upload',
|
||||
[
|
||||
'IMAGEUPLOAD',
|
||||
{ imageInputName: 'image', image_upload: true }
|
||||
] as InputSpec,
|
||||
fromPartial({})
|
||||
)
|
||||
|
||||
mocks.capturedUploadOptions?.onUploadComplete(['uploaded.png'])
|
||||
|
||||
expect(fileComboWidget.value).toBe('uploaded.png')
|
||||
expect(mocks.setNodeOutputs).toHaveBeenCalledWith(node, 'uploaded.png', {
|
||||
isAnimated: false
|
||||
})
|
||||
expect(onWidgetChanged).toHaveBeenCalledWith(
|
||||
'image',
|
||||
'uploaded.png',
|
||||
'missing.png',
|
||||
fileComboWidget
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -83,10 +83,17 @@ export const useImageUploadWidget = () => {
|
||||
})
|
||||
|
||||
const newValue = allow_batch ? annotated : annotated[0]
|
||||
const oldValue = fileComboWidget.value
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = newValue
|
||||
fileComboWidget.callback?.(newValue)
|
||||
node.onWidgetChanged?.(
|
||||
fileComboWidget.name,
|
||||
newValue,
|
||||
oldValue,
|
||||
fileComboWidget
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -681,6 +681,201 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value[0].name).toBe('preview.png [output]')
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
|
||||
// Defense against future cloud-schema changes: if a flat output row
|
||||
// ever ships with both asset_hash AND multi-output user_metadata, the
|
||||
// watcher must NOT replace it with synthesized AssetItems lacking the
|
||||
// hash, or select+load reverts to the FE-227 broken state.
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-flat-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-future',
|
||||
nodeId: '9',
|
||||
subfolder: '',
|
||||
outputCount: 4,
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'should-not-replace.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '9',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-out-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
preview_url: '/api/view?filename=039b...0b13.png',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
// The value (item.name) — what becomes modelValue on click — must be the
|
||||
// hash-keyed path so /api/view resolves it. Cloud's hash is in
|
||||
// asset_hash, not asset.name (which is the human filename).
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
// The label keeps the human filename for the dropdown UI.
|
||||
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'local-1',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
tags: ['output']
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
expect(dropdownItems.value[0].name).toBe('ComfyUI_00001_.png [output]')
|
||||
})
|
||||
|
||||
it('does not partially expand the list while some multi-output jobs are still resolving (FE-227)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
makeMultiOutputAsset('job-FIRST', 'previewFirst.png', '1', 3),
|
||||
makeMultiOutputAsset('job-SECOND', 'previewSecond.png', '2', 2)
|
||||
]
|
||||
|
||||
let resolveFirst!: (items: AssetItem[]) => void
|
||||
let resolveSecond!: (items: AssetItem[]) => void
|
||||
const firstPromise = new Promise<AssetItem[]>((res) => {
|
||||
resolveFirst = res
|
||||
})
|
||||
const secondPromise = new Promise<AssetItem[]>((res) => {
|
||||
resolveSecond = res
|
||||
})
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
async (meta: { jobId: string }) => {
|
||||
if (meta.jobId === 'job-FIRST') return firstPromise
|
||||
if (meta.jobId === 'job-SECOND') return secondPromise
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'outputs'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'previewFirst.png [output]',
|
||||
'previewSecond.png [output]'
|
||||
])
|
||||
|
||||
resolveSecond([
|
||||
{
|
||||
id: 'job-SECOND-2--out2a.png',
|
||||
name: 'out2a.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-SECOND-2--out2b.png',
|
||||
name: 'out2b.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'previewFirst.png [output]',
|
||||
'previewSecond.png [output]'
|
||||
])
|
||||
|
||||
resolveFirst([
|
||||
{
|
||||
id: 'job-FIRST-1--out1a.png',
|
||||
name: 'out1a.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-FIRST-1--out1b.png',
|
||||
name: 'out1b.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
},
|
||||
{
|
||||
id: 'job-FIRST-1--out1c.png',
|
||||
name: 'out1c.png',
|
||||
preview_url: '',
|
||||
tags: ['output']
|
||||
}
|
||||
])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dropdownItems.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
expect(dropdownItems.value.map((i) => i.name)).toEqual([
|
||||
'out1a.png [output]',
|
||||
'out1b.png [output]',
|
||||
'out1c.png [output]',
|
||||
'out2a.png [output]',
|
||||
'out2b.png [output]'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('output asset subfolder', () => {
|
||||
@@ -871,4 +1066,136 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FE-230 missing-media filtering', () => {
|
||||
it('drops input items whose name is in the missing-media store', async () => {
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo_abc.jpg',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('photo_abc.jpg')
|
||||
expect(names).toContain('img_001.png')
|
||||
})
|
||||
|
||||
it('drops output items whose annotated path is in the missing-media store', async () => {
|
||||
mockMediaAssets = createMockMediaAssets()
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'gone.png',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem,
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'kept.png',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem
|
||||
]
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '7',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'gone.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
outputMediaAssets: mockMediaAssets
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('gone.png [output]')
|
||||
expect(names).toContain('kept.png [output]')
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input and output sources', async () => {
|
||||
mockMediaAssets = createMockMediaAssets()
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'photo_abc.jpg',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem
|
||||
]
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo_abc.jpg',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ outputMediaAssets: mockMediaAssets })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('photo_abc.jpg')
|
||||
expect(names).toContain('photo_abc.jpg [output]')
|
||||
})
|
||||
|
||||
it('does not surface a missing-value placeholder when the modelValue is confirmed missing', async () => {
|
||||
const modelValue = ref<string | undefined>('gone.png [output]')
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '7',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'gone.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems, selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({ modelValue, values: () => [] })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('gone.png [output]')
|
||||
expect(selectedSet.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
getAssetBaseModels,
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
getAssetFilename,
|
||||
getAssetUrlFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -72,6 +74,14 @@ interface UseWidgetSelectItemsOptions {
|
||||
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
const { modelValue, outputMediaAssets, assetData } = options
|
||||
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const missingMediaValues = computed<ReadonlySet<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
missingMediaStore.missingMediaCandidates?.map((c) => c.name) ?? []
|
||||
)
|
||||
)
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
const isAsset = toValue(options.isAssetMode)
|
||||
@@ -101,7 +111,6 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
})
|
||||
|
||||
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
|
||||
const pendingJobIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => outputMediaAssets.media.value,
|
||||
@@ -109,10 +118,22 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
pendingJobIds.clear()
|
||||
})
|
||||
|
||||
const seenJobIds = new Set<string>()
|
||||
const jobsToResolve: Array<{
|
||||
jobId: string
|
||||
meta: ReturnType<typeof getOutputAssetMetadata>
|
||||
createdAt?: string
|
||||
}> = []
|
||||
|
||||
for (const asset of assets) {
|
||||
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.asset_hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -120,29 +141,41 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
if (
|
||||
outputCount <= 1 ||
|
||||
resolvedByJobId.value.has(meta.jobId) ||
|
||||
pendingJobIds.has(meta.jobId)
|
||||
seenJobIds.has(meta.jobId)
|
||||
)
|
||||
continue
|
||||
|
||||
pendingJobIds.add(meta.jobId)
|
||||
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
|
||||
.then((resolved) => {
|
||||
if (cancelled || !resolved.length) return
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
next.set(meta.jobId, resolved)
|
||||
resolvedByJobId.value = next
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'Failed to resolve multi-output job',
|
||||
meta.jobId,
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingJobIds.delete(meta.jobId)
|
||||
})
|
||||
seenJobIds.add(meta.jobId)
|
||||
jobsToResolve.push({
|
||||
jobId: meta.jobId,
|
||||
meta,
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
}
|
||||
|
||||
if (jobsToResolve.length === 0) return
|
||||
|
||||
void Promise.all(
|
||||
jobsToResolve.map(({ jobId, meta, createdAt }) =>
|
||||
resolveOutputAssetItems(meta!, { createdAt })
|
||||
.then((resolved) => ({ jobId, resolved }))
|
||||
.catch((error) => {
|
||||
console.warn('Failed to resolve multi-output job', jobId, error)
|
||||
return { jobId, resolved: [] as AssetItem[] }
|
||||
})
|
||||
)
|
||||
).then((results) => {
|
||||
if (cancelled) return
|
||||
|
||||
const next = new Map(resolvedByJobId.value)
|
||||
let changed = false
|
||||
for (const { jobId, resolved } of results) {
|
||||
if (!resolved.length) continue
|
||||
next.set(jobId, resolved)
|
||||
changed = true
|
||||
}
|
||||
if (changed) resolvedByJobId.value = next
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -153,12 +186,15 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
return values.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
const missing = missingMediaValues.value
|
||||
return values
|
||||
.filter((value) => !missing.has(String(value)))
|
||||
.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
})
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
@@ -176,25 +212,28 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
const missing = missingMediaValues.value
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
seen.add(asset.id)
|
||||
const filenameForUrl = getAssetUrlFilename(asset)
|
||||
const subfolder =
|
||||
kind === 'mesh'
|
||||
? getOutputAssetMetadata(asset.user_metadata)?.subfolder
|
||||
: undefined
|
||||
const pathWithSubfolder = subfolder
|
||||
? `${subfolder}/${asset.name}`
|
||||
: asset.name
|
||||
? `${subfolder}/${filenameForUrl}`
|
||||
: filenameForUrl
|
||||
const annotatedPath = `${pathWithSubfolder} [output]`
|
||||
if (missing.has(annotatedPath)) continue
|
||||
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
|
||||
items.push({
|
||||
id: `output-${asset.id}`,
|
||||
preview_url:
|
||||
kind === 'mesh'
|
||||
? ''
|
||||
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
: asset.preview_url || getMediaUrl(filenameForUrl, 'output', kind),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(displayLabel, labelFn)
|
||||
})
|
||||
@@ -209,6 +248,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
|
||||
if (missingMediaValues.value.has(currentValue)) return undefined
|
||||
|
||||
if (toValue(options.isAssetMode) && assetData) {
|
||||
const existsInAssets = assetData.assets.value.some(
|
||||
(asset) => getAssetFilename(asset) === currentValue
|
||||
|
||||
@@ -96,7 +96,7 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
verifyMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
@@ -1508,9 +1508,13 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
|
||||
if (isCloud) {
|
||||
const pending = candidates.some((c) => c.isMissing === undefined)
|
||||
if (pending) {
|
||||
const controller = missingMediaStore.createVerificationAbortController()
|
||||
void verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
void verifyMediaCandidates(candidates, {
|
||||
isCloud,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
// Re-check ancestor after async verification (see model pipeline).
|
||||
|
||||
@@ -5,6 +5,7 @@ import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
@@ -30,7 +31,9 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
updateAsset: vi.fn(),
|
||||
addAssetTags: vi.fn(),
|
||||
removeAssetTags: vi.fn()
|
||||
}
|
||||
},
|
||||
INPUT_TAG: 'input',
|
||||
OUTPUT_TAG: 'output'
|
||||
}))
|
||||
|
||||
// Mock distribution type - hoisted so it can be changed per test
|
||||
@@ -1420,3 +1423,137 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
asset_hash?: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash,
|
||||
size: 0,
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetches outputs via getAssetsByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('marks hasMore=false when the page is short', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'one.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('marks hasMore=true when a full page is returned', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('appends and dedupes on loadMoreFlatOutputs', async () => {
|
||||
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
const secondPage = [
|
||||
makeAsset('a0', 'duplicate.png'),
|
||||
makeAsset('newId', 'new.png')
|
||||
]
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputAssets).toHaveLength(FLAT_OUTPUT_PAGE_SIZE + 1)
|
||||
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
|
||||
})
|
||||
|
||||
it('records error and clears media on initial-fetch failure', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
const result = await store.updateFlatOutputs()
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(store.flatOutputError).toBe(err)
|
||||
expect(store.flatOutputLoading).toBe(false)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refresh resets pagination', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(
|
||||
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh'])
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupes concurrent fetches into a single request', async () => {
|
||||
let resolvePage!: (assets: AssetItem[]) => void
|
||||
const pagePromise = new Promise<AssetItem[]>((res) => {
|
||||
resolvePage = res
|
||||
})
|
||||
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
|
||||
|
||||
const store = useAssetsStore()
|
||||
const p1 = store.updateFlatOutputs()
|
||||
const p2 = store.updateFlatOutputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,11 @@ import type {
|
||||
AssetItem,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
INPUT_TAG,
|
||||
OUTPUT_TAG,
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
@@ -46,7 +50,7 @@ async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag('input', false, {
|
||||
return await assetService.getAssetsByTag(INPUT_TAG, false, {
|
||||
limit: INPUT_LIMIT
|
||||
})
|
||||
}
|
||||
@@ -89,6 +93,7 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||
|
||||
const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
@@ -255,6 +260,65 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const flatOutputAssets = ref<AssetItem[]>([])
|
||||
const flatOutputLoading = ref(false)
|
||||
const flatOutputError = ref<unknown>(null)
|
||||
const flatOutputOffset = ref(0)
|
||||
const flatOutputHasMore = ref(true)
|
||||
const flatOutputIsLoadingMore = ref(false)
|
||||
const flatOutputSeenIds = new Set<string>()
|
||||
let flatOutputInFlight: Promise<AssetItem[]> | null = null
|
||||
|
||||
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
|
||||
if (flatOutputInFlight) return flatOutputInFlight
|
||||
|
||||
if (loadMore) {
|
||||
if (!flatOutputHasMore.value) return flatOutputAssets.value
|
||||
flatOutputIsLoadingMore.value = true
|
||||
} else {
|
||||
flatOutputLoading.value = true
|
||||
flatOutputOffset.value = 0
|
||||
flatOutputHasMore.value = true
|
||||
flatOutputSeenIds.clear()
|
||||
}
|
||||
flatOutputError.value = null
|
||||
|
||||
flatOutputInFlight = (async () => {
|
||||
try {
|
||||
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
offset: flatOutputOffset.value
|
||||
})
|
||||
const fresh = loadMore
|
||||
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: page
|
||||
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
|
||||
flatOutputAssets.value = loadMore
|
||||
? [...flatOutputAssets.value, ...fresh]
|
||||
: page
|
||||
flatOutputOffset.value += page.length
|
||||
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
|
||||
return flatOutputAssets.value
|
||||
} catch (err) {
|
||||
flatOutputError.value = err
|
||||
console.error('Failed to fetch output assets:', err)
|
||||
return loadMore ? flatOutputAssets.value : []
|
||||
} finally {
|
||||
if (loadMore) flatOutputIsLoadingMore.value = false
|
||||
else flatOutputLoading.value = false
|
||||
flatOutputInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return flatOutputInFlight
|
||||
}
|
||||
|
||||
const updateFlatOutputs = () => fetchFlatOutputs(false)
|
||||
const loadMoreFlatOutputs = async () => {
|
||||
if (flatOutputIsLoadingMore.value) return
|
||||
await fetchFlatOutputs(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
@@ -783,6 +847,15 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
|
||||
// Flat output assets (cloud-only, tag-based)
|
||||
flatOutputAssets,
|
||||
flatOutputLoading,
|
||||
flatOutputError,
|
||||
flatOutputHasMore,
|
||||
flatOutputIsLoadingMore,
|
||||
updateFlatOutputs,
|
||||
loadMoreFlatOutputs,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
getInputName,
|
||||
|
||||
@@ -10,6 +10,17 @@ const MockComponent = defineComponent({
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
const MockContentPropsComponent = defineComponent({
|
||||
name: 'MockContentPropsComponent',
|
||||
props: {
|
||||
openingAction: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
describe('dialogStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -172,6 +183,31 @@ describe('dialogStore', () => {
|
||||
expect(store.dialogStack[0].key).toBe('reusable-dialog')
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
})
|
||||
|
||||
it('should update existing dialog props by key', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'updatable-dialog',
|
||||
component: MockContentPropsComponent,
|
||||
props: { openingAction: null },
|
||||
dialogComponentProps: { dismissableMask: true }
|
||||
})
|
||||
|
||||
const updated = store.updateDialog({
|
||||
key: 'updatable-dialog',
|
||||
contentProps: { openingAction: 'copy-and-open' },
|
||||
dialogComponentProps: { dismissableMask: false }
|
||||
})
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(store.dialogStack[0].contentProps).toMatchObject({
|
||||
openingAction: 'copy-and-open'
|
||||
})
|
||||
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESC key behavior with multiple dialogs', () => {
|
||||
|
||||
@@ -88,6 +88,12 @@ export interface ShowDialogOptions<
|
||||
priority?: number
|
||||
}
|
||||
|
||||
interface UpdateDialogOptions {
|
||||
key: string
|
||||
contentProps?: Partial<DialogInstance['contentProps']>
|
||||
dialogComponentProps?: Partial<DialogComponentProps>
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
const dialogStack = ref<DialogInstance[]>([])
|
||||
|
||||
@@ -264,6 +270,28 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
return dialogStack.value.some((d) => d.key === key)
|
||||
}
|
||||
|
||||
function updateDialog(options: UpdateDialogOptions): boolean {
|
||||
const dialog = dialogStack.value.find((d) => d.key === options.key)
|
||||
if (!dialog) return false
|
||||
|
||||
if (options.contentProps) {
|
||||
dialog.contentProps = {
|
||||
...dialog.contentProps,
|
||||
...options.contentProps
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dialogComponentProps) {
|
||||
dialog.dialogComponentProps = {
|
||||
...dialog.dialogComponentProps,
|
||||
...options.dialogComponentProps
|
||||
}
|
||||
updateCloseOnEscapeStates()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
dialogStack,
|
||||
riseDialog,
|
||||
@@ -271,6 +299,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closeDialog,
|
||||
showExtensionDialog,
|
||||
isDialogOpen,
|
||||
updateDialog,
|
||||
activeKey
|
||||
}
|
||||
})
|
||||
|
||||
93
src/stores/missingMediaPreviewRegression.test.ts
Normal file
93
src/stores/missingMediaPreviewRegression.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ removeNodeOutputs: mockRemoveNodeOutputs })
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
isGraphReady: true,
|
||||
rootGraph: { nodes: [], _nodes: [] } as unknown as LGraph
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/utils/graphTraversalUtil', async () => {
|
||||
const actual = await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getNodeByExecutionId: mockGetNodeByExecutionId
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({ clearMissingModelState: vi.fn() })
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
|
||||
function makeNodeWithPreview(id: number): LGraphNode {
|
||||
return {
|
||||
id,
|
||||
imgs: [{ src: 'blob:mask-edited' }],
|
||||
videoContainer: undefined,
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('FE-230 regression — workflow-load missing-media flagging must not wipe node previews', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.isGraphReady = true
|
||||
mockApp.rootGraph = { nodes: [], _nodes: [] } as unknown as LGraph
|
||||
mockRemoveNodeOutputs.mockReset()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
})
|
||||
|
||||
it('does not clear node.imgs when verification flags a Load Image as missing on workflow load (e.g. mask-editor saved value)', async () => {
|
||||
const node = makeNodeWithPreview(42)
|
||||
mockGetNodeByExecutionId.mockReturnValue(node)
|
||||
|
||||
useExecutionErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
|
||||
missingMediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: '42',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'clipspace/clipspace-painted-masked-1.png [input]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:mask-edited' }])
|
||||
expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -367,22 +367,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove node outputs for a specific node
|
||||
* Clears both outputs and preview images
|
||||
*/
|
||||
function removeNodeOutputs(nodeId: number | string) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
||||
if (!nodeLocatorId) return false
|
||||
|
||||
// Clear from app.nodeOutputs
|
||||
function removeOutputsByLocatorId(nodeLocatorId: NodeLocatorId) {
|
||||
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
|
||||
delete app.nodeOutputs[nodeLocatorId]
|
||||
|
||||
// Clear from reactive state
|
||||
delete nodeOutputs.value[nodeLocatorId]
|
||||
|
||||
// Clear preview images
|
||||
if (app.nodePreviewImages[nodeLocatorId]) {
|
||||
const previews = app.nodePreviewImages[nodeLocatorId]
|
||||
if (previews?.[Symbol.iterator]) {
|
||||
@@ -397,6 +386,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
return hadOutputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove node outputs for a specific node
|
||||
* Clears both outputs and preview images
|
||||
*/
|
||||
function removeNodeOutputs(nodeId: number | string) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
||||
if (!nodeLocatorId) return false
|
||||
return removeOutputsByLocatorId(nodeLocatorId)
|
||||
}
|
||||
|
||||
// Resolves the locator from the node's own graph, so interior subgraph nodes
|
||||
// are addressed correctly even when the user has a different graph active.
|
||||
function removeNodeOutputsForNode(node: LGraphNode) {
|
||||
return removeOutputsByLocatorId(nodeToNodeLocatorId(node))
|
||||
}
|
||||
|
||||
function snapshotOutputs(): Record<string, ExecutedWsMessage['output']> {
|
||||
return clone(app.nodeOutputs)
|
||||
}
|
||||
@@ -493,6 +498,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
revokeAllPreviews,
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
removeNodeOutputsForNode,
|
||||
snapshotOutputs,
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
@@ -289,7 +289,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
)
|
||||
const workflowExtra = workflow.initialState.extra
|
||||
const description =
|
||||
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
|
||||
workflowExtra?.BlueprintDescription ??
|
||||
workflow.initialState?.definitions?.subgraphs[0].description ??
|
||||
'User generated subgraph blueprint'
|
||||
const search_aliases = workflowExtra?.BlueprintSearchAliases
|
||||
const subgraphDefCategory =
|
||||
workflow.initialState.definitions?.subgraphs?.[0]?.category
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user