fix: avoid false missing media errors after importing shared workflow assets (#12333)

## Summary

Import published media assets for shared workflows before loading the
graph so the first missing-media scan sees the user's newly imported
references instead of surfacing a false missing asset error. cc FE-773

## Changes

- **What**: Moves the shared workflow import step ahead of
`loadGraphData` for the copy-and-open flow, while still allowing the
workflow to open with a warning path if asset import fails.
- **What**: Clears the shared workflow URL intent consistently on
failure paths, including graph load failure after an import attempt, so
reloads do not repeatedly replay the same shared workflow side effects.
- **What**: Invalidates the input asset cache after published asset
import so graph loading and missing-media resolution can observe the
refreshed media state.
- **What**: Adds a global loading spinner while shared workflow asset
import and graph load are in progress, with `role="status"`,
`aria-live`, reduced-motion-safe animation, and body teleporting so it
stays visible above blocking UI.
- **What**: Adds stable TestIds for the shared workflow dialog and
updates existing shared workflow E2E selectors away from copy-dependent
role text.
- **What**: Adds a cloud E2E regression fixture and spec covering the
critical flow: shared URL opens the dialog, the user confirms asset
import, published media is imported before the public-inclusive input
asset scan, the workflow loads, the share query is removed, and missing
media UI is not surfaced.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

Shared workflow graph loading triggered the missing-media pipeline
before the user-selected published media import had completed. Because
`include_public=true` does not include published assets, the pre-import
scan could classify shared media as missing even when the user was about
to import those assets into their own library.

## Review Focus

- The ordering in `useSharedWorkflowUrlLoader`: import published assets
first, then load the graph, while keeping import failure non-fatal for
workflow opening.
- The failure cleanup behavior: the shared URL/preserved query intent is
now cleared for graph load failures too, avoiding repeated
reload-triggered imports.
- The spinner behavior in `App.vue`: it uses the existing
`workspaceStore.spinner` boolean and intentionally keeps broader
ref-counted spinner ownership as follow-up work.
- The E2E sentinel in `sharedWorkflowMissingMedia.spec.ts`: it asserts
no public-inclusive input asset scan occurs before `/api/assets/import`,
then waits for a settling window to ensure the missing-media overlay
does not appear.

## Validation

- `pnpm format`
- `pnpm lint` (passed with existing unrelated warnings only)
- `pnpm typecheck`
- `pnpm test:unit`
- Commit hook: lint-staged formatting/linting, `pnpm typecheck`, `pnpm
typecheck:browser`
- Push hook: `pnpm knip --cache` (passed with existing tag hint only)

## Follow-Up

- Consider a ref-counted or scoped global spinner API so long-running
flows do not directly toggle `workspaceStore.spinner`.
- Consider separating shared workflow load status into orthogonal result
fields instead of encoding partial success in a single string union.
- Consider moving published asset import/cache invalidation behind an
asset-service-owned API boundary.
- Backend follow-up remains needed for `include_public=true` not
including published assets; this PR only removes the frontend false
positive when the user explicitly imports the shared media.

## Screenshots

Before 


https://github.com/user-attachments/assets/dc790046-237c-4dd8-b773-2507f9a66650

After 


https://github.com/user-attachments/assets/6517cd38-2c3d-4bfe-a990-35892b7e50ae



https://github.com/user-attachments/assets/d89dc3d3-75d9-4251-998b-0c354414e25b




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12333-fix-avoid-false-missing-media-errors-after-importing-shared-workflow-assets-3656d73d365081b38634dcb7625cfc32)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-05-20 11:59:44 +09:00
committed by GitHub
parent 95b5207c06
commit 98a8a614e8
13 changed files with 786 additions and 68 deletions

View File

@@ -76,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,147 @@
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.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
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

@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -3225,6 +3225,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

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

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,67 +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),
payload.shareId
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[] = [
{
@@ -348,6 +356,7 @@ describe(useWorkflowShareService, () => {
share_id: 'share-id-1'
})
})
expect(mockInvalidateInputAssetsIncludingPublic).toHaveBeenCalledTimes(1)
})
it('omits share_id from the payload when not provided', async () => {
@@ -384,6 +393,7 @@ describe(useWorkflowShareService, () => {
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

@@ -6,6 +6,7 @@ import type {
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'
@@ -275,6 +276,8 @@ export function useWorkflowShareService() {
if (!response.ok) {
throw new Error(`Failed to import assets: ${response.status}`)
}
assetService.invalidateInputAssetsIncludingPublic()
}
return {

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

@@ -93,6 +93,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[]>([])
@@ -269,6 +275,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,
@@ -276,6 +304,7 @@ export const useDialogStore = defineStore('dialog', () => {
closeDialog,
showExtensionDialog,
isDialogOpen,
updateDialog,
activeKey
}
})