diff --git a/.github/actions/start-comfyui-server/action.yaml b/.github/actions/start-comfyui-server/action.yaml index 2af727508d6..c7accf7125b 100644 --- a/.github/actions/start-comfyui-server/action.yaml +++ b/.github/actions/start-comfyui-server/action.yaml @@ -19,5 +19,8 @@ runs: run: | set -euo pipefail cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/ - cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" & + # TODO(FE-729): remove --enable-assets once BE-786 lands in the CI ComfyUI image + # (BE-786 removes the gate so /api/assets is always on). Until then, FE-729 + # routes modelStore through assetService, which 503s without this flag. + cd /ComfyUI && python3 main.py --cpu --multi-user --enable-assets --front-end-root "${{ inputs.front_end_root }}" & wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }} diff --git a/browser_tests/assets/missing/fe746_load_image_bare_filename.json b/browser_tests/assets/missing/fe746_load_image_bare_filename.json new file mode 100644 index 00000000000..6aedef0cca0 --- /dev/null +++ b/browser_tests/assets/missing/fe746_load_image_bare_filename.json @@ -0,0 +1,42 @@ +{ + "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": ["fe746_photo.png", "image"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/helpers/BuilderSelectHelper.ts b/browser_tests/fixtures/helpers/BuilderSelectHelper.ts index 7d86039a879..60fa8b19885 100644 --- a/browser_tests/fixtures/helpers/BuilderSelectHelper.ts +++ b/browser_tests/fixtures/helpers/BuilderSelectHelper.ts @@ -119,9 +119,22 @@ export class BuilderSelectHelper { )[0] if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`) await nodeRef.centerOnNode() - const widgetLocator = this.comfyPage.vueNodes - .getNodeLocator(String(nodeRef.id)) - .getByLabel(widgetName, { exact: true }) + const node = this.comfyPage.vueNodes.getNodeLocator(String(nodeRef.id)) + // Grid-mode widgets (WidgetSelectDefault) and number widgets expose + // aria-label on a wrapper/input. Asset-mode widgets (WidgetSelectDropdown) + // do not — the widget name lives in a sibling + // [data-testid="widget-layout-field-label"] div, so fall back to clicking + // the dropdown trigger button in the same row. + const byAriaLabel = node.getByLabel(widgetName, { exact: true }) + const widgetLocator = + (await byAriaLabel.count()) > 0 + ? byAriaLabel + : node + .getByTestId('widget-layout-field-label') + .filter({ hasText: widgetName }) + .locator('..') + .locator('button') + .first() // oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability await widgetLocator.click({ force: true }) await this.comfyPage.nextFrame() diff --git a/browser_tests/tests/assetDeleteClearsLoadImage.spec.ts b/browser_tests/tests/assetDeleteClearsLoadImage.spec.ts index c6a90fb1645..9fa3156301e 100644 --- a/browser_tests/tests/assetDeleteClearsLoadImage.spec.ts +++ b/browser_tests/tests/assetDeleteClearsLoadImage.spec.ts @@ -2,13 +2,15 @@ * FE-230: Deleting an asset must clear the Load Image node preview, widget * value, and mark the workflow dirty. * - * Local run (requires cloud build of the frontend): - * pnpm build:cloud - * pnpm exec playwright test --project=cloud \ - * browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list + * FE-732: Input-asset deletion is no longer gated on `isCloud`; the same + * teardown flow now applies to both Cloud and OSS builds. Cloud and OSS + * variants below cover both Playwright projects against the shared mock. * - * The cloud project is required because input-asset deletion is gated on - * `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`). + * Local run examples: + * pnpm build:cloud && pnpm exec playwright test --project=cloud \ + * browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list + * pnpm build && pnpm exec playwright test --project=chromium \ + * browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list */ import type { Page, Route } from '@playwright/test' import { expect } from '@playwright/test' @@ -119,81 +121,88 @@ const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({ } }) +function registerDeleteFlowTest() { + baseTest( + 'deleting an input asset clears widget value, preview cache, and marks workflow modified', + async ({ comfyPage, assetMock }) => { + await comfyPage.workflow.loadWorkflow('widgets/load_image_widget') + + // Drive the production drag-and-drop flow to point the Load Image + // widget at the asset we are about to delete and populate the preview + // cache. FE-230 is asserting that the deletion tears these down. + const loadImageNode = ( + await comfyPage.nodeOps.getNodeRefsByType('LoadImage') + )[0] + const { x, y } = await loadImageNode.getPosition() + await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, { + dropPosition: { x, y }, + waitForUpload: true + }) + const imageWidget = await loadImageNode.getWidget(0) + await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE) + + // Re-baseline the change tracker so the deletion-side mutation is the + // only thing that can flip `isModified` later. + await comfyPage.page.evaluate(() => { + const tracker = + window.app?.extensionManager?.workflow?.activeWorkflow?.changeTracker + tracker?.reset?.() + }) + await expect + .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) + .toBe(false) + + // Drive the real production flow: assets sidebar → Imported tab → + // right-click asset card → Delete → confirm dialog. + const sidebar = comfyPage.menu.assetsTab + // The default `open()` waits for assets on the Generated tab; we seed + // only an input asset, so skip that wait and let `waitForAssets(1)` + // gate on the Imported tab instead. + await sidebar.open({ waitForAssets: false }) + await sidebar.switchToImported() + await sidebar.waitForAssets(1) + await sidebar.rightClickAsset(TARGET_CARD_TEXT) + + const deleteMenuItem = sidebar.contextMenuItem('Delete') + await expect(deleteMenuItem).toBeVisible() + await deleteMenuItem.click() + + await comfyPage.confirmDialog.click('delete') + + // Mocked DELETE was issued. + await expect + .poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id)) + .toBe(true) + + // Widget value was cleared. + await expect.poll(() => imageWidget.getValue()).toBe('') + + // Preview cache was cleared. + await expect + .poll(() => + comfyPage.page.evaluate((nodeId) => { + const node = window.app!.graph.getNodeById(nodeId) + return node?.imgs?.length ?? 0 + }, loadImageNode.id) + ) + .toBe(0) + + // Workflow was marked dirty by changeTracker.captureCanvasState(). + await expect + .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) + .toBe(true) + } + ) +} + baseTest.describe( - 'FE-230 asset delete clears Load Image preview', + 'FE-230 asset delete clears Load Image preview (cloud)', { tag: '@cloud' }, - () => { - baseTest( - 'deleting an input asset clears widget value, preview cache, and marks workflow modified', - async ({ comfyPage, assetMock }) => { - await comfyPage.workflow.loadWorkflow('widgets/load_image_widget') - - // Drive the production drag-and-drop flow to point the Load Image - // widget at the asset we are about to delete and populate the preview - // cache. FE-230 is asserting that the deletion tears these down. - const loadImageNode = ( - await comfyPage.nodeOps.getNodeRefsByType('LoadImage') - )[0] - const { x, y } = await loadImageNode.getPosition() - await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, { - dropPosition: { x, y }, - waitForUpload: true - }) - const imageWidget = await loadImageNode.getWidget(0) - await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE) - - // Re-baseline the change tracker so the deletion-side mutation is the - // only thing that can flip `isModified` later. - await comfyPage.page.evaluate(() => { - const tracker = - window.app?.extensionManager?.workflow?.activeWorkflow - ?.changeTracker - tracker?.reset?.() - }) - await expect - .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) - .toBe(false) - - // Drive the real production flow: assets sidebar → Imported tab → - // right-click asset card → Delete → confirm dialog. - const sidebar = comfyPage.menu.assetsTab - // The default `open()` waits for assets on the Generated tab; we seed - // only an input asset, so skip that wait and let `waitForAssets(1)` - // gate on the Imported tab instead. - await sidebar.open({ waitForAssets: false }) - await sidebar.switchToImported() - await sidebar.waitForAssets(1) - await sidebar.rightClickAsset(TARGET_CARD_TEXT) - - const deleteMenuItem = sidebar.contextMenuItem('Delete') - await expect(deleteMenuItem).toBeVisible() - await deleteMenuItem.click() - - await comfyPage.confirmDialog.click('delete') - - // Mocked DELETE was issued. - await expect - .poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id)) - .toBe(true) - - // Widget value was cleared. - await expect.poll(() => imageWidget.getValue()).toBe('') - - // Preview cache was cleared. - await expect - .poll(() => - comfyPage.page.evaluate((nodeId) => { - const node = window.app!.graph.getNodeById(nodeId) - return node?.imgs?.length ?? 0 - }, loadImageNode.id) - ) - .toBe(0) - - // Workflow was marked dirty by changeTracker.captureCanvasState(). - await expect - .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) - .toBe(true) - } - ) - } + registerDeleteFlowTest +) + +baseTest.describe( + 'FE-230 asset delete clears Load Image preview (oss)', + { tag: '@oss' }, + registerDeleteFlowTest ) diff --git a/browser_tests/tests/defaultKeybindings.spec.ts b/browser_tests/tests/defaultKeybindings.spec.ts index 54ea64f90d6..b5bd02a94a9 100644 --- a/browser_tests/tests/defaultKeybindings.spec.ts +++ b/browser_tests/tests/defaultKeybindings.spec.ts @@ -27,7 +27,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => { const sidebarTabs = [ { key: 'KeyW', tabId: 'workflows', label: 'workflows' }, { key: 'KeyN', tabId: 'node-library', label: 'node library' }, - { key: 'KeyM', tabId: 'model-library', label: 'model library' }, { key: 'KeyA', tabId: 'assets', label: 'assets' } ] as const diff --git a/browser_tests/tests/maskEditor.spec.ts b/browser_tests/tests/maskEditor.spec.ts index badf78e6210..e6c8d7a68af 100644 --- a/browser_tests/tests/maskEditor.spec.ts +++ b/browser_tests/tests/maskEditor.spec.ts @@ -218,21 +218,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { }) => { const dialog = await maskEditor.openDialog() - let maskUploadCount = 0 let imageUploadCount = 0 - await comfyPage.page.route('**/upload/mask', (route) => { - maskUploadCount++ - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - name: `test-mask-${maskUploadCount}.png`, - subfolder: 'clipspace', - type: 'input' - }) - }) - }) await comfyPage.page.route('**/upload/image', (route) => { imageUploadCount++ return route.fulfill({ @@ -252,20 +239,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => { await expect(dialog).toBeHidden() - // The save pipeline uploads multiple layers (mask + image variants) + // The save pipeline uploads four layers (masked, paint, painted, paintedMasked) + // through the unified /upload/image endpoint. expect( - maskUploadCount + imageUploadCount, - 'save should trigger upload calls' - ).toBeGreaterThan(0) + imageUploadCount, + 'save should upload all four layers via /upload/image' + ).toBe(4) }) test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => { const dialog = await maskEditor.openDialog() - // Fail all upload routes - await comfyPage.page.route('**/upload/mask', (route) => - route.fulfill({ status: 500 }) - ) await comfyPage.page.route('**/upload/image', (route) => route.fulfill({ status: 500 }) ) diff --git a/browser_tests/tests/missingMediaAssetUnion.spec.ts b/browser_tests/tests/missingMediaAssetUnion.spec.ts new file mode 100644 index 00000000000..920db9e3c11 --- /dev/null +++ b/browser_tests/tests/missingMediaAssetUnion.spec.ts @@ -0,0 +1,197 @@ +import type { Page } from '@playwright/test' +import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types' + +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' +import type { WorkspaceStore } from '@e2e/types/globals' + +// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset +// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated +// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the +// post-BE field without an `any` cast. +type PostBEAsset = Asset & { + file_path?: string | null + display_name?: string | null +} + +const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png' + +async function mockAssetListing( + page: Page, + assets: PostBEAsset[] +): Promise { + await page.route(/\/api\/assets(?=\?|$)/, async (route) => { + const response: ListAssetsResponse = { + assets: assets as Asset[], + total: assets.length, + has_more: false + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) +} + +async function mockAssetListingFailure( + page: Page, + status: number +): Promise { + await page.route(/\/api\/assets(?=\?|$)/, async (route) => { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify({ detail: `forced ${status} for FE-746 test` }) + }) + }) +} + +async function getCachedMissingMediaNames( + 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 + ) ?? [] + ) + }) +} + +test.describe( + 'Missing media detection — file_path union (FE-746)', + { tag: '@cloud' }, + () => { + test.use({ + initialSettings: { + 'Comfy.RightSidePanel.ShowErrorsTab': true + } + }) + + test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({ + comfyPage + }) => { + // BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted + // file_path that differs from the bare `name` the user originally chose. + // The workflow widget value (`fe746_photo.png`) predates the rollout, so + // it must still match via the `name` arm of the detection-key union. + // Case A (file_path-only early return) would mark this as missing. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-asset-1', + name: WORKFLOW_WIDGET_VALUE, + asset_hash: 'blake3:fe7460000000000000000000000000000', + file_path: 'input/sub/fe746_photo.png', + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([]) + }) + + test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({ + comfyPage + }) => { + // BE-933 hash-only null case: asset registered via POST /assets/from-hash + // has no on-disk path, so `file_path` (and `display_name`) come back null. + // Detection must still succeed via the legacy `name` arm. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-asset-hash-only', + name: WORKFLOW_WIDGET_VALUE, + asset_hash: 'blake3:fe7460000000000000000000000000001', + file_path: null, + display_name: null, + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([]) + }) + + test('surfaces missing media when no asset in the listing covers the widget value', async ({ + comfyPage + }) => { + // Sanity: with the union still in place, an asset listing that does not + // include the widget value via any key (file_path / asset_hash / name) + // must still report missing. Guards against accidental "match + // everything" regressions when the early-return was removed. + await mockAssetListing(comfyPage.page, [ + { + id: 'fe746-unrelated-asset', + name: 'unrelated.png', + asset_hash: 'blake3:fe7460000000000000000000000000002', + file_path: 'input/unrelated.png', + size: 1024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + last_access_time: '2026-05-22T00:00:00Z' + } + ]) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect + .poll(() => getCachedMissingMediaNames(comfyPage)) + .toContain(WORKFLOW_WIDGET_VALUE) + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeVisible() + }) + + test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({ + comfyPage + }) => { + // Promise.allSettled + per-branch soft-degrade (Finding 2): when the + // input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial + // BE-934 deploys, transient network errors), the verifier must finish + // — marking the candidate missing — rather than leaving isMissing + // stuck at undefined behind a silent toast. + await mockAssetListingFailure(comfyPage.page, 500) + + await comfyPage.workflow.loadWorkflow( + 'missing/fe746_load_image_bare_filename' + ) + + await expect + .poll(() => getCachedMissingMediaNames(comfyPage)) + .toContain(WORKFLOW_WIDGET_VALUE) + }) + } +) diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts index 41b40cf977a..ffad3073fab 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts @@ -115,42 +115,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh) ).toBeVisible() }) - - test('Should clear resolved missing model when Refresh is clicked', async ({ - comfyPage - }) => { - await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models') - await comfyPage.page.route(/\/object_info$/, async (route) => { - const response = await route.fetch() - const objectInfo = await response.json() - const ckptName = - objectInfo.CheckpointLoaderSimple.input.required.ckpt_name - ckptName[0] = [...ckptName[0], 'fake_model.safetensors'] - await route.fulfill({ response, json: objectInfo }) - }) - - const objectInfoResponse = comfyPage.page.waitForResponse((response) => { - const url = new URL(response.url()) - return url.pathname.endsWith('/object_info') && response.ok() - }) - const modelFoldersResponse = comfyPage.page.waitForResponse( - (response) => { - const url = new URL(response.url()) - return url.pathname.endsWith('/experiment/models') && response.ok() - } - ) - const refreshButton = comfyPage.page.getByTestId( - TestIds.dialogs.missingModelRefresh - ) - - await Promise.all([ - objectInfoResponse, - modelFoldersResponse, - refreshButton.click() - ]) - await expect( - comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup) - ).toBeHidden() - }) }) }) diff --git a/browser_tests/tests/sidebar/assets-filter.spec.ts b/browser_tests/tests/sidebar/assets-filter.spec.ts index 7bdecdca48d..ad2a20981e3 100644 --- a/browser_tests/tests/sidebar/assets-filter.spec.ts +++ b/browser_tests/tests/sidebar/assets-filter.spec.ts @@ -8,13 +8,14 @@ import type { import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper' -// The assets sidebar's media-type filter menu only renders in cloud mode -// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`). -// We tag tests `@cloud` so they run against the cloud Playwright project, -// and register both `/api/assets` and `/api/jobs` route handlers as auto -// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's -// internal `setup()`, so the page first-loads with mocks already in place. -// See cloud-asset-default.spec.ts for the same pattern. +// Post-FE-732 the media-type filter menu renders unconditionally on both +// Cloud and OSS builds. These tests keep the `@cloud` tag because the +// `/api/jobs` dependency is still cloud-only; once OSS exposes equivalent +// jobs data we can drop the tag. Auto fixtures register `/api/assets` and +// `/api/jobs` route handlers — Playwright runs auto fixtures before the +// `comfyPage` fixture's internal `setup()`, so the page first-loads with +// mocks already in place. See cloud-asset-default.spec.ts for the same +// pattern. const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D']) diff --git a/browser_tests/tests/sidebar/assets-sort.spec.ts b/browser_tests/tests/sidebar/assets-sort.spec.ts index ed296067517..f64a46ab53c 100644 --- a/browser_tests/tests/sidebar/assets-sort.spec.ts +++ b/browser_tests/tests/sidebar/assets-sort.spec.ts @@ -8,13 +8,14 @@ import type { import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper' -// The assets sidebar's sort options live inside the settings popover and are -// only rendered in cloud mode (`MediaAssetFilterBar.vue`: -// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against -// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and -// `/internal/files/input` route handlers as auto fixtures — Playwright runs -// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the -// page first-loads with mocks already in place. +// Post-FE-732 the sort options inside the settings popover render +// unconditionally on both Cloud and OSS builds. These tests keep the +// `@cloud` tag because the `/api/jobs` dependency (used by the generation- +// time sort) is still cloud-only; once OSS exposes equivalent jobs data we +// can drop the tag. Auto fixtures register `/api/assets`, `/api/jobs`, and +// `/internal/files/input` route handlers — Playwright runs auto fixtures +// before the `comfyPage` fixture's internal `setup()`, so the page first- +// loads with mocks already in place. // Three jobs whose `(create_time, duration)` axes are intentionally // misaligned so newest/oldest and longest/fastest sorts produce *different* diff --git a/browser_tests/tests/sidebar/assets.spec.ts b/browser_tests/tests/sidebar/assets.spec.ts index c229780468c..efd3ff2121a 100644 --- a/browser_tests/tests/sidebar/assets.spec.ts +++ b/browser_tests/tests/sidebar/assets.spec.ts @@ -1,5 +1,9 @@ import { expect } from '@playwright/test' +import { + STABLE_INPUT_IMAGE, + STABLE_INPUT_IMAGE_2 +} from '@e2e/fixtures/data/assetFixtures' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { createMockJob, @@ -708,6 +712,30 @@ test.describe('Assets sidebar - bulk actions', () => { await expect(tab.deleteSelectedButton).toBeVisible() }) + test('Footer shows delete button when input assets selected (FE-732)', async ({ + comfyPage + }) => { + // Pre-FE-732 the input-tab Delete footer button was gated by isCloud and + // hidden on OSS builds. Post-FE-732 it must render in both modes. This + // test runs on the default chromium project — i.e. the OSS build — and + // asserts the gate is gone. + await comfyPage.assets.mockCloudAssets({ + assets: [STABLE_INPUT_IMAGE, STABLE_INPUT_IMAGE_2], + total: 2, + has_more: false + }) + await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES) + + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.switchToImported() + await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1) + + await tab.assetCards.first().click() + + await expect(tab.deleteSelectedButton).toBeVisible() + }) + test('Selection count displays correct number', async ({ comfyPage }) => { const tab = comfyPage.menu.assetsTab await tab.open() diff --git a/browser_tests/tests/sidebar/modelLibrary.spec.ts b/browser_tests/tests/sidebar/modelLibrary.spec.ts deleted file mode 100644 index b49adc7cd58..00000000000 --- a/browser_tests/tests/sidebar/modelLibrary.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { expect } from '@playwright/test' - -import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' - -const MOCK_FOLDERS: Record = { - checkpoints: [ - 'sd_xl_base_1.0.safetensors', - 'dreamshaper_8.safetensors', - 'realisticVision_v51.safetensors' - ], - loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'], - vae: ['sdxl_vae.safetensors'] -} - -// ========================================================================== -// 1. Tab open/close -// ========================================================================== - -test.describe('Model library sidebar - tab', () => { - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS) - await comfyPage.setup() - }) - - test.afterEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.clearMocks() - }) - - test('Opens model library tab and shows tree', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await expect(tab.modelTree).toBeVisible() - await expect(tab.searchInput).toBeVisible() - }) - - test('Shows refresh and load all folders buttons', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await expect(tab.refreshButton).toBeVisible() - await expect(tab.loadAllFoldersButton).toBeVisible() - }) -}) - -// ========================================================================== -// 2. Folder display -// ========================================================================== - -test.describe('Model library sidebar - folders', () => { - // Mocks are set up before setup(), so app.ts's loadModelFolders() - // call during initialization hits the mock and populates the store. - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS) - await comfyPage.setup() - }) - - test.afterEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.clearMocks() - }) - - test('Displays model folders after opening tab', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await expect(tab.getFolderByLabel('checkpoints')).toBeVisible() - await expect(tab.getFolderByLabel('loras')).toBeVisible() - await expect(tab.getFolderByLabel('vae')).toBeVisible() - }) - - test('Expanding a folder loads and shows models', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - // Click the folder to expand it - await tab.getFolderByLabel('checkpoints').click() - - // Models should appear as leaf nodes - await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible() - await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible() - await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible() - }) - - test('Expanding a different folder shows its models', async ({ - comfyPage - }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await tab.getFolderByLabel('loras').click() - - await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible() - await expect(tab.getLeafByLabel('add_brightness')).toBeVisible() - }) -}) - -// ========================================================================== -// 3. Search -// ========================================================================== - -test.describe('Model library sidebar - search', () => { - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS) - await comfyPage.setup() - }) - - test.afterEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.clearMocks() - }) - - test('Search filters models by filename', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await tab.searchInput.fill('dreamshaper') - - // Wait for debounce (300ms) + load + render - await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible() - - // Other models should not be visible - await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeHidden() - }) - - test('Clearing search restores folder view', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await tab.searchInput.fill('dreamshaper') - await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible() - - // Clear the search - await tab.searchInput.fill('') - - // Folders should be visible again (collapsed) - await expect(tab.getFolderByLabel('checkpoints')).toBeVisible() - await expect(tab.getFolderByLabel('loras')).toBeVisible() - }) - - test('Search with no matches shows empty tree', async ({ comfyPage }) => { - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - // Expand a folder and verify models are present before searching - await tab.getFolderByLabel('checkpoints').click() - await expect(tab.leafNodes).not.toHaveCount(0) - - await tab.searchInput.fill('nonexistent_model_xyz') - - // Wait for debounce, then verify no leaf nodes - await expect.poll(() => tab.leafNodes.count()).toBe(0) - }) -}) - -// ========================================================================== -// 4. Refresh and load all -// ========================================================================== - -test.describe('Model library sidebar - refresh', () => { - test.afterEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.clearMocks() - }) - - test('Refresh button reloads folder list', async ({ comfyPage }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles({ - checkpoints: ['model_a.safetensors'] - }) - await comfyPage.setup() - - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await expect(tab.getFolderByLabel('checkpoints')).toBeVisible() - - // Update mock to include a new folder - await comfyPage.modelLibrary.clearMocks() - await comfyPage.modelLibrary.mockFoldersWithFiles({ - checkpoints: ['model_a.safetensors'], - loras: ['lora_b.safetensors'] - }) - - // Wait for the refresh request to complete - const refreshRequest = comfyPage.page.waitForRequest( - (req) => req.url().endsWith('/experiment/models'), - { timeout: 5000 } - ) - await tab.refreshButton.click() - await refreshRequest - - await expect(tab.getFolderByLabel('loras')).toBeVisible() - }) - - test('Load all folders button triggers loading all model data', async ({ - comfyPage - }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS) - await comfyPage.setup() - - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - // Wait for a per-folder model files request triggered by load all - const folderRequest = comfyPage.page.waitForRequest( - (req) => - /\/api\/experiment\/models\/[^/]+$/.test(req.url()) && - req.method() === 'GET', - { timeout: 5000 } - ) - - await tab.loadAllFoldersButton.click() - await folderRequest - }) -}) - -// ========================================================================== -// 5. Empty state -// ========================================================================== - -test.describe('Model library sidebar - empty state', () => { - test.afterEach(async ({ comfyPage }) => { - await comfyPage.modelLibrary.clearMocks() - }) - - test('Shows empty tree when no model folders exist', async ({ - comfyPage - }) => { - await comfyPage.modelLibrary.mockFoldersWithFiles({}) - await comfyPage.setup() - - const tab = comfyPage.menu.modelLibraryTab - await tab.open() - - await expect(tab.modelTree).toBeVisible() - await expect(tab.folderNodes).toHaveCount(0) - await expect(tab.leafNodes).toHaveCount(0) - }) -}) diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 9770d0ffa1a..a17ad4a8c09 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -141,7 +141,6 @@