diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index ef1f9e09d8..91d98b054d 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -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' diff --git a/browser_tests/fixtures/sharedWorkflowImportFixture.ts b/browser_tests/fixtures/sharedWorkflowImportFixture.ts new file mode 100644 index 0000000000..6b6c2894fc --- /dev/null +++ b/browser_tests/fixtures/sharedWorkflowImportFixture.ts @@ -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 + +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 +} + +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 { + let isRecording = false + let importEndpointCalled = false + let importBody: ImportPublishedAssetsRequest | undefined + let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {} + let publicInclusiveInputAssetResponseAfterImport = new Promise( + (resolve) => { + resolvePublicInclusiveInputAssetResponseAfterImport = resolve + } + ) + const requestEvents: SharedWorkflowRequestEvent[] = [] + + function resetPublicInclusiveInputAssetResponseWaiter() { + publicInclusiveInputAssetResponseAfterImport = new Promise( + (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) ?? [] + ) +} diff --git a/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts b/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts new file mode 100644 index 0000000000..6a1a14801b --- /dev/null +++ b/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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) + }) +}) diff --git a/browser_tests/tests/templates.spec.ts b/browser_tests/tests/templates.spec.ts index 06b53a9bbc..fecd1dd78c 100644 --- a/browser_tests/tests/templates.spec.ts +++ b/browser_tests/tests/templates.spec.ts @@ -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() diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 3d4cc8dd96..22a88af40f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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." }, diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts index a6320fe13c..1fd7e8f9c5 100644 --- a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts +++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts @@ -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: [ diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue index 0d6750a1b6..eb3130c8de 100644 --- a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue +++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue @@ -1,12 +1,24 @@