Compare commits

...

18 Commits

Author SHA1 Message Date
jaeone94
b52828c25d fix: backport shared workflow asset import timing
Backport #12333 to cloud/1.44.

Resolve the templates.spec conflict and adapt the browser regression test to the 1.44 ComfyPage fixture API.
2026-05-20 13:30:36 +09:00
Dante
e6a751f42f [backport cloud/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12353)
Backport of #12006 to cloud/1.44.

## Conflict resolution

One conflict in
`src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
(imports + dropdown source). On main, the original PR replaced
`useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() :
useAssetsApi('output')`. On `cloud/1.44`, the local path still goes
through `useMediaAssets`, which itself internally gates `isCloud →
useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud
branch (which is the whole point of this PR — single
`getAssetsByTag('output')` instead of jobs-walk + per-job expansion)
while keeping `useMediaAssets('output')` for the local path on this
branch:

```ts
const outputMediaAssets = isCloud
  ? useFlatOutputAssets()
  : useMediaAssets('output')
```

All other files auto-merged.

## Verification

- `pnpm typecheck` 
- `pnpm test:unit` — `assetsStore.test.ts` (60) +
`useWidgetSelectItems.test.ts` (40) = 100/100 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12353-backport-cloud-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetc-3666d73d3650815280a4f9207332e058)
by [Unito](https://www.unito.io)
2026-05-20 12:01:15 +09:00
Comfy Org PR Bot
f8a3f462b7 [backport cloud/1.44] Revert "fix(cloud): stop bouncing working users to /cloud/survey mid-session" (#12346)
Backport of #12344 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12346-backport-cloud-1-44-Revert-fix-cloud-stop-bouncing-working-users-to-cloud-survey-m-3656d73d365081a2bd91ce5002af5fdc)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-05-20 10:26:06 +09:00
Comfy Org PR Bot
81229466e1 [backport cloud/1.44] fix: keep node context menu overflow visible when content fits (#12338)
Backport of #12035 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12338-backport-cloud-1-44-fix-keep-node-context-menu-overflow-visible-when-content-fits-3656d73d3650811aa95bc47b0e41fb4e)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-19 22:21:39 +09:00
Comfy Org PR Bot
db2d381c89 [backport cloud/1.44] fix(cloud): stop bouncing working users to /cloud/survey mid-session (#12320)
Backport of #12301 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12320-backport-cloud-1-44-fix-cloud-stop-bouncing-working-users-to-cloud-survey-mid-sessi-3646d73d3650815185cecaa2babdf3d1)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 19:50:13 -07:00
Comfy Org PR Bot
13894beeb2 [backport cloud/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12291)
Backport of #12052 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12291-backport-cloud-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081f3a362e618ab12d34d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:12:41 +09:00
Comfy Org PR Bot
4c4b85bd49 [backport cloud/1.44] fix: include share_id when importing published assets (FE-603) (#12256)
Backport of #12055 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12256-backport-cloud-1-44-fix-include-share_id-when-importing-published-assets-FE-603-3606d73d365081d4965fcec791a68bd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-18 12:11:55 +09:00
Comfy Org PR Bot
1dffc948d7 [backport cloud/1.44] Fix descriptions on core blueprints (#12259)
Backport of #12220 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12259-backport-cloud-1-44-Fix-descriptions-on-core-blueprints-3606d73d3650811f9956f4368d9e723f)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-13 23:51:19 -07:00
Comfy Org PR Bot
b30454ac4f [backport cloud/1.44] fix: clear media upload errors via widget change (#12245)
Backport of #12212 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12245-backport-cloud-1-44-fix-clear-media-upload-errors-via-widget-change-3606d73d365081bb948cd1d6729565bd)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-14 12:22:26 +09:00
Comfy Org PR Bot
b1b6394345 [backport cloud/1.44] fix: open node info panel from context menu (#12248)
Backport of #12205 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12248-backport-cloud-1-44-fix-open-node-info-panel-from-context-menu-3606d73d36508184b212c72f0af6af26)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 12:22:18 +09:00
Comfy Org PR Bot
efde624926 [backport cloud/1.44] fix: Load Image preview retains deleted asset (FE-230) (#12130)
Backport of #11493 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12130-backport-cloud-1-44-fix-Load-Image-preview-retains-deleted-asset-FE-230-35d6d73d3650812983cfdd3086dc6969)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-14 11:21:41 +09:00
jaeone94
2f48694192 [backport cloud/1.44] fix: suppress missing media scan during uploads (#12111) (#12189)
## Summary

Manual backport of #12111 to `cloud/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12189-backport-cloud-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d36508195a407f1fa0d6898e7)
by [Unito](https://www.unito.io)
2026-05-12 20:38:01 +09:00
Dante
c6d9a84aac [backport cloud/1.44] fix(i18n): clamp unsupported browser locales to a shipped tag (#11712) (#12179)
*PR Created by the Glary-Bot Agent*

---

Backport of #11712 to `cloud/1.44`. Companion to #12178 (`core/1.44`).

## Summary

Cherry-picks `ceb993605` ("fix(i18n): clamp unsupported browser locales
to a shipped tag") onto `cloud/1.44`.

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

The i18n files this PR touches are byte-identical between `core/1.44`
and `cloud/1.44`, so this backport applies the same patch as the core
backport (verified via `git show` byte diff).

## Cherry-pick conflict resolution

One file required manual resolution (same as the core backport):

- **`browser_tests/tests/customNodeLocales.spec.ts`** — `modify/delete`
conflict. This file does not exist on `cloud/1.44` (it was introduced on
`main` after the 1.44 branch cut, in #12132). Dropped the modification
from the backport; the underlying test file is not on the release
branch, so its updates are irrelevant here.

All other files merged cleanly (`src/views/GraphView.vue` had an
auto-merge that resolved without intervention).

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/i18n.test.ts` — **17/17 passing** (covers the new
`resolveSupportedLocale` block and the updated unsupported-locale clamp
behaviour)
- `eslint` on all changed files — clean
- **Manual verification via Playwright on the backport branch**,
simulating a fresh install with `navigator.language='de-DE'`:
- Sidebar `aria-label`s render real strings ("Assets (a)", "Node Library
(n)", "Workflows (w)", "Settings", …) — **no** literal i18n keys like
`sideToolbar.labels.assets`.
- Confirmed `hasLiteralKeys: false` on the rendered DOM, matching the
fixed behaviour from the original PR.
  - Screenshot attached.

## Files

```
 apps/desktop-ui/src/i18n.ts                     |   3 +-
 browser_tests/tests/i18nLocaleFallback.spec.ts  |  47 ++++++++
 browser_tests/tests/templates.spec.ts           |  59 +++++-----
 src/i18n.test.ts                                | 109 +++++++++++++++---
 src/i18n.ts                                     | 145 ++++++++----------------
 src/locales/CONTRIBUTING.md                     |  44 +------
 src/locales/localeConfig.ts                     |  82 ++++++++++++++
 src/platform/settings/constants/coreSettings.ts |  21 +---
 src/views/GraphView.vue                         |  20 ++--
```

Backport-of: #11712
Companion: #12178 (core/1.44)
Fixes #10563 on the 1.44 cloud release line


## Screenshots

![Cloud 1.44 backport — sidebar with navigator.language=de-DE shows real
labels (Assets, Node Library, Workflows...) instead of literal i18n
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/08da2130835cfe011512360b413c21eae085ac145ae94852e9dc841da09d0411/pr-images/1778567031488-dce52eb2-e07c-48ec-9c5e-409ffc45c49c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12179-backport-cloud-1-44-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-11-35e6d73d365081c8882cfd086ee8b90b)
by [Unito](https://www.unito.io)
2026-05-12 18:35:05 +09:00
Comfy Org PR Bot
4a30b51bee [backport cloud/1.44] fix: handle annotated output media paths in missing media scan (#12122)
Backport of #12069 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12122-backport-cloud-1-44-fix-handle-annotated-output-media-paths-in-missing-media-scan-35d6d73d36508174bfc1f2590edd1a03)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-11 01:35:14 +00:00
Comfy Org PR Bot
d891fafc3d [backport cloud/1.44] fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12084)
Backport of #12072 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12084-backport-cloud-1-44-fix-make-credits-help-icon-a-tooltip-button-in-cloud-user-popover-35a6d73d3650816eb973f20c0954696c)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-08 23:20:35 +09:00
Comfy Org PR Bot
b585afcd9c [backport cloud/1.44] fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12081)
Backport of #12051 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12081-backport-cloud-1-44-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragge-35a6d73d365081e5becbcff78471d87f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-08 20:54:47 +09:00
Comfy Org PR Bot
5cb36fea91 [backport cloud/1.44] fix: remove asset hash verification (#12079)
Backport of #12061 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12079-backport-cloud-1-44-fix-remove-asset-hash-verification-35a6d73d36508157bec2d1259f32d142)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-08 09:11:57 +00:00
Comfy Org PR Bot
6d1221bc2f [backport cloud/1.44] refactor: align asset pagination schema (#12065)
Backport of #11899 to `cloud/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12065-backport-cloud-1-44-refactor-align-asset-pagination-schema-3596d73d365081a5b596c288ef8a818a)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-07 17:36:36 +00:00
102 changed files with 6712 additions and 1384 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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[]

View File

@@ -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)
}
}

View File

@@ -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'

View 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) ?? []
)
}

View 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
}

View File

@@ -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

View 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')
}
})
})

View 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

View File

@@ -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 ({

View File

@@ -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)

View 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)
})
})

View File

@@ -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')
})
})

View File

@@ -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 ({

View File

@@ -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
}) => {

View File

@@ -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
}) => {

View File

@@ -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' }, () => {

View File

@@ -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;

View File

@@ -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
}
/**

View File

@@ -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()
})
/**

View File

@@ -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')

View File

@@ -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
View 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)

View File

@@ -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() {

View File

@@ -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()]

View File

@@ -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)

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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)
})

View File

@@ -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)

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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()
})
})
})

View File

@@ -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,

View File

@@ -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'))

View 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()
})
})

View File

@@ -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
}

View File

@@ -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')
})
})
})

View File

@@ -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,

View File

@@ -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

View File

@@ -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."
},

View 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)
}

View 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
}
}

View File

@@ -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()
})
})
})

View File

@@ -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>()

View File

@@ -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(),

View File

@@ -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'
)
})
})

View File

@@ -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,

View File

@@ -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
}

View 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()
})
})

View 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)
}
}

View 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])
})
})

View 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
}

View File

@@ -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()
})
})

View 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)
}
}

View 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
)
})
})

View 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]
}

View 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')
})
})

View 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
}
}
}

View File

@@ -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()
})
})

View File

@@ -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. */

View File

@@ -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
}

View File

@@ -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([

View File

@@ -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, '/')
}

View File

@@ -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', () => {

View File

@@ -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
}
}
/**

View File

@@ -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',

View File

@@ -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: [

View File

@@ -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 ''

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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)
})
})
})

View File

@@ -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)
}
/**

View File

@@ -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)', () => {

View File

@@ -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]',

View File

@@ -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()

View File

@@ -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)
})
})

View File

@@ -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"

View File

@@ -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
)
})
})

View File

@@ -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
)
}
})

View File

@@ -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)
})
})
})

View File

@@ -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

View File

@@ -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).

View File

@@ -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'])
})
})

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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
}
})

View 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()
})
})

View File

@@ -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,

View File

@@ -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