test: add E2E and expand unit tests for missing media pipeline

E2E tests (10 scenarios):
- Detection: overlay, errors tab group, row count, dropzone/library visibility
- Upload flow: file picker → status card → confirm → row removed
- Library select: dropdown → status card → confirm → row removed
- Cancel: pending selection → returns to upload/library UI
- All resolved: Missing Inputs group disappears
- Locate: canvas pans to missing media node

Unit test improvements:
- Add makeCandidate helper to scan tests (DRY)
- Add 5 new verifyCloudMediaCandidates tests (spy on updateInputs,
  mid-flight abort, isMissing:false skip, no-pending skip,
  multi-name grouping within same media type)
- Add data-testid attributes to MissingMediaRow components
- Add TestIds for missing media selectors
This commit is contained in:
jaeone94
2026-03-25 23:01:29 +09:00
parent 4947ba74b4
commit c97d9605d1
8 changed files with 466 additions and 96 deletions

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 12,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"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": ["nonexistent_test_image_aaa.png", "image"]
},
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,
"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": ["nonexistent_test_image_bbb.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"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": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -36,7 +36,15 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
missingModelsGroup: 'error-group-missing-model',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -0,0 +1,222 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await expect(errorOverlay.getByText(/Missing Inputs/)).toBeVisible()
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
})
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow (2-step confirm)', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).not.toBeVisible()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).not.toBeVisible()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).not.toBeVisible()
})
})
test.describe('Locate node', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLocateButton
)
await expect(locateButton).toBeVisible()
await locateButton.click()
await comfyPage.page.waitForTimeout(500)
const offsetAfter = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
expect(offsetBefore).not.toBeNull()
expect(offsetAfter).not.toBeNull()
expect(offsetAfter).not.toEqual(offsetBefore)
})
})
})

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full flex-col pb-3">
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
@@ -35,6 +35,7 @@
<!-- Confirm button (visible when pending selection exists) -->
<Button
data-testid="missing-media-confirm-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
@@ -57,6 +58,7 @@
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
@@ -129,6 +131,7 @@
<TransitionCollapse>
<div
v-if="isPending || isUploading"
data-testid="missing-media-status-card"
role="status"
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
@@ -165,6 +168,7 @@
</div>
<Button
data-testid="missing-media-cancel-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
@@ -183,6 +187,7 @@
<!-- Upload dropzone -->
<div class="flex w-full flex-col py-1">
<button
data-testid="missing-media-upload-dropzone"
type="button"
:class="
cn(
@@ -205,6 +210,7 @@
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
data-testid="missing-media-library-select"
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"

View File

@@ -6,33 +6,28 @@ import {
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
function makeCandidate(
nodeId: string,
name: string,
overrides: Partial<MissingMediaCandidate> = {}
): MissingMediaCandidate {
return {
nodeId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name,
isMissing: true,
...overrides
}
}
describe('groupCandidatesByName', () => {
it('groups candidates with the same name', () => {
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
},
{
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
},
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'other.png',
isMissing: true
}
const candidates = [
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'other.png')
]
const result = groupCandidatesByName(candidates)
@@ -53,31 +48,18 @@ describe('groupCandidatesByName', () => {
describe('groupCandidatesByMediaType', () => {
it('groups by media type in order: image, video, audio', () => {
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
const candidates = [
makeCandidate('1', 'sound.mp3', {
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio',
name: 'sound.mp3',
isMissing: true
},
{
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
},
{
nodeId: '3',
mediaType: 'audio'
}),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
name: 'clip.mp4',
isMissing: true
}
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
@@ -88,42 +70,41 @@ describe('groupCandidatesByMediaType', () => {
})
it('omits media types with no candidates', () => {
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
const candidates = [
makeCandidate('1', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
name: 'clip.mp4',
isMissing: true
}
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('video')
})
it('groups multiple names within the same media type', () => {
const candidates = [
makeCandidate('1', 'a.png'),
makeCandidate('2', 'b.png'),
makeCandidate('3', 'a.png')
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image')
expect(result[0].items).toHaveLength(2)
expect(
result[0].items.find((i) => i.name === 'a.png')?.referencingNodes
).toHaveLength(2)
})
})
describe('verifyCloudMediaCandidates', () => {
it('marks candidates missing when not in input assets', async () => {
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'abc123.png',
isMissing: undefined
},
{
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'def456.png',
isMissing: undefined
}
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
makeCandidate('2', 'def456.png', { isMissing: undefined })
]
const mockStore = {
@@ -137,38 +118,53 @@ describe('verifyCloudMediaCandidates', () => {
expect(candidates[1].isMissing).toBe(false)
})
it('respects abort signal', async () => {
it('calls updateInputs before checking assets', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(true)
})
it('respects abort signal before execution', async () => {
const controller = new AbortController()
controller.abort()
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'abc123.png',
isMissing: undefined
}
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates, controller.signal)
// Should not have been modified
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved', async () => {
const candidates: MissingMediaCandidate[] = [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'abc123.png',
isMissing: true
}
]
it('respects abort signal after updateInputs', async () => {
const controller = new AbortController()
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
const mockStore = {
updateInputs: async () => {
controller.abort()
},
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
}
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const mockStore = {
updateInputs: async () => {},
@@ -177,7 +173,35 @@ describe('verifyCloudMediaCandidates', () => {
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
// Already resolved, should remain unchanged
expect(candidates[0].isMissing).toBe(true)
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(false)
})
it('skips entirely when no pending candidates', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(false)
})
})

View File

@@ -17,7 +17,7 @@ import {
import { resolveComboValues } from '@/utils/litegraphUtil'
/** Map of node types to their media widget name and media type. */
export const MEDIA_NODE_WIDGETS: Record<
const MEDIA_NODE_WIDGETS: Record<
string,
{ widgetName: string; mediaType: MediaType }
> = {