Compare commits

..

10 Commits

Author SHA1 Message Date
bymyself
967bbd6ff1 chore: fix lint and typecheck issues exposed by main rebase
- Replace .locator('[data-testid="error-overlay"]') with
  getByTestId('error-overlay') to satisfy the
  playwright/prefer-native-locators rule.
- Replace relative ../fixtures/ComfyPage imports with the @e2e/
  path alias to satisfy no-restricted-imports.
- Migrate vueNode.titleInput usage to the new vueNode.titleEditor
  API after the VueNodeFixture refactor on main.
- Replace .not.toBeVisible() with .toBeHidden() to satisfy the
  playwright/no-useless-not rule.

Pre-existing in the PR but newly enforced after the merge with main.
2026-05-03 23:15:38 -07:00
bymyself
b8376e2a17 test: add data-testid to error message and use it in errorsTab spec
Add a data-testid='error-card-message' to the error message <p> element
in ErrorNodeCard.vue and update the spec to locate it via getByTestId
instead of the .whitespace-pre-wrap Tailwind utility class. The previous
selector coupled the test to a styling decision; the testid is a
behavioral selector that survives style changes.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9557#discussion_r2982269131
2026-05-03 23:10:01 -07:00
bymyself
55324d0bf7 test: replace icon utility-class selectors with semantic locators
Use getByRole('button', { name: 'Locate node on canvas' }) instead of
matching the .icon-[lucide--locate] UnoCSS/Iconify utility class. The
locate button already has an aria-label, so the role-based locator is
behavioral rather than coupling the test to icon implementation
details.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9557#discussion_r2982269120
2026-05-03 23:09:40 -07:00
bymyself
3e195e6650 chore: resolve merge conflicts with main
Take origin/main's version of queueNotificationBanners.spec.ts, which has
been superseded by a more comprehensive test suite that uses the
TestIds.queue.notificationBanner selector and dispatches promptQueueing/
promptQueued events directly via the API.
2026-05-03 23:08:15 -07:00
Christian Byrne
74997e556d Merge branch 'main' into playwright-coverage/wave-3-medium 2026-04-19 18:52:03 -07:00
bymyself
684261d592 fix: enable ShowErrorsTab setting for queue notification tests
The error overlay only appears when Comfy.RightSidePanel.ShowErrorsTab
is enabled. Matches the pattern used in execution.spec.ts.
2026-03-16 07:27:51 -07:00
GitHub Action
1d231bd988 [automated] Apply ESLint and Oxfmt fixes 2026-03-16 13:57:30 +00:00
bymyself
d370b182e7 fix: correct toast notification test setup for wave 3 2026-03-16 06:54:40 -07:00
GitHub Action
1a8a2be4c3 [automated] Apply ESLint and Oxfmt fixes 2026-03-16 06:54:40 -07:00
bymyself
026bd4d757 test: add 24 Playwright tests for medium-priority UI areas
- Errors tab interactions (6 tests)
- Vue node header actions — collapse/expand/rename (6 tests)
- Queue notification banners (6 tests)
- Settings sidebar button (6 tests)
2026-03-16 06:54:40 -07:00
15 changed files with 496 additions and 479 deletions

View File

@@ -0,0 +1,150 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test.describe('Errors tab in right side panel', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
test('Errors tab appears after execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
// Dismiss the error overlay
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
await expect(
propertiesPanel.root.getByRole('tab', { name: 'Errors' })
).toBeVisible()
})
test('Error card shows locate button', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const locateButton = propertiesPanel.root.getByRole('button', {
name: 'Locate node on canvas'
})
await expect(locateButton.first()).toBeVisible()
})
test('Clicking locate button focuses canvas', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const locateButton = propertiesPanel.root
.getByRole('button', { name: 'Locate node on canvas' })
.first()
await locateButton.click()
await expect(comfyPage.canvas).toBeVisible()
})
test('Collapse all button collapses error groups', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const collapseButton = propertiesPanel.root.getByRole('button', {
name: 'Collapse all'
})
// The collapse toggle only appears when there are multiple groups.
// If only one group exists, this test verifies the button is not shown.
const count = await collapseButton.count()
if (count > 0) {
await collapseButton.click()
const expandButton = propertiesPanel.root.getByRole('button', {
name: 'Expand all'
})
await expect(expandButton).toBeVisible()
}
})
test('Search filters errors', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
// Search for a term that won't match any error
await propertiesPanel.searchBox.fill('zzz_nonexistent_zzz')
await expect(
propertiesPanel.root.getByRole('button', {
name: 'Locate node on canvas'
})
).toHaveCount(0)
// Clear the search to restore results
await propertiesPanel.searchBox.fill('')
await expect(
propertiesPanel.root
.getByRole('button', { name: 'Locate node on canvas' })
.first()
).toBeVisible()
})
test('Errors tab shows error message text', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId('error-overlay')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.root).toBeVisible()
const errorMessage = propertiesPanel.root
.getByTestId('error-card-message')
.first()
await expect(errorMessage).toBeVisible()
await expect(errorMessage).not.toHaveText('')
})
})

View File

@@ -1,60 +1,9 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { STABLE_INPUT_IMAGE } from '@e2e/fixtures/data/assetFixtures'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const test = comfyPageFixture
const PUBLIC_INPUT_ASSET_HASH = 'nonexistent_test_image_12345.png'
const PUBLIC_INPUT_ASSET: Asset = {
...STABLE_INPUT_IMAGE,
id: 'test-public-input-001',
name: 'public_reference_photo.png',
asset_hash: PUBLIC_INPUT_ASSET_HASH
}
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const cloudTest = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const assetsRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const includeTags = url.searchParams.get('include_tags') ?? ''
const tags = includeTags.split(',').filter(Boolean)
const includePublic = url.searchParams.get('include_public') === 'true'
const assets =
tags.includes('input') && includePublic ? [PUBLIC_INPUT_ASSET] : []
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(assets))
})
}
await page.route(assetsRoutePattern, assetsRouteHandler)
await use()
await page.unroute(assetsRoutePattern, assetsRouteHandler)
},
{ auto: true }
]
})
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -254,49 +203,3 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
})
})
})
cloudTest.describe(
'Errors tab - Missing media cloud assets',
{
tag: '@cloud'
},
() => {
cloudTest.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
cloudTest(
'does not report public input assets as missing',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect
.poll(() =>
cloudAssetRequests.some((requestUrl) => {
const url = new URL(requestUrl)
const includeTags = url.searchParams.get('include_tags') ?? ''
return (
includeTags.split(',').includes('input') &&
url.searchParams.get('include_public') === 'true'
)
})
)
.toBe(true)
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
}
)
}
)

View File

@@ -0,0 +1,57 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Settings Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Settings button is visible in sidebar', async ({ comfyPage }) => {
await expect(comfyPage.menu.sideToolbar).toBeVisible()
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await expect(settingsButton).toBeVisible()
})
test('Clicking settings button opens settings dialog', async ({
comfyPage
}) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
})
test('Settings dialog shows categories', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.categories.first()).toBeVisible()
expect(await comfyPage.settingDialog.categories.count()).toBeGreaterThan(0)
})
test('Settings dialog can be closed with Escape', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.settingDialog.root).toBeHidden()
})
test('Settings search box is functional', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.settingDialog.searchBox.fill('color')
await expect(comfyPage.settingDialog.searchBox).toHaveValue('color')
})
test('Settings dialog can navigate to About panel', async ({ comfyPage }) => {
const settingsButton = comfyPage.menu.sideToolbar.getByLabel(/settings/i)
await settingsButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await comfyPage.settingDialog.goToAboutPanel()
await expect(comfyPage.page.locator('.about-container')).toBeVisible()
})
})

View File

@@ -0,0 +1,69 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Vue Node Header Actions', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Collapse button is visible on node header', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.collapseButton).toBeVisible()
})
test('Clicking collapse button hides node body', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.body).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).toBeHidden()
})
test('Clicking collapse button again expands node', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).toBeHidden()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(vueNode.body).toBeVisible()
})
test('Double-click header enters title edit mode', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.header.dblclick()
await vueNode.titleEditor.expectVisible()
})
test('Title edit saves on Enter', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.setTitle('My Custom Sampler')
expect(await vueNode.getTitle()).toBe('My Custom Sampler')
})
test('Title edit cancels on Escape', async ({ comfyPage }) => {
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await vueNode.setTitle('Renamed Sampler')
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
await vueNode.header.dblclick()
await vueNode.titleEditor.input.fill('This Should Be Cancelled')
await vueNode.titleEditor.cancel()
await comfyPage.nextFrame()
expect(await vueNode.getTitle()).toBe('Renamed Sampler')
})
})

View File

@@ -58,6 +58,7 @@
<p
v-if="error.message"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
data-testid="error-card-message"
>
{{ error.message }}
</p>

View File

@@ -1,4 +1,3 @@
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -22,9 +21,9 @@ const zAsset = z.object({
})
const zAssetResponse = z.object({
assets: z.array(zAsset),
total: zListAssetsResponse.shape.total,
has_more: zListAssetsResponse.shape.has_more
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zModelFolder = z.object({

View File

@@ -64,16 +64,6 @@ function buildResponse(
} as unknown as Response
}
function buildAssetListResponse(
assets: AssetItem[],
{
hasMore = false,
total = assets.length
}: { hasMore?: boolean; total?: number } = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
@@ -228,7 +218,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -250,7 +240,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -311,7 +301,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -337,7 +327,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -433,13 +423,15 @@ describe(assetService.getAssetModelFolders, () => {
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
]
})
)
const folders = await assetService.getAssetModelFolders()
@@ -500,10 +492,12 @@ describe(assetService.getAssetsByTag, () => {
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
])
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
]
})
)
const assets = await assetService.getAssetsByTag('input')
@@ -524,16 +518,17 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
[
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
)
]
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -558,18 +553,17 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
[
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
],
{ hasMore: true }
)
]
})
)
.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -590,22 +584,23 @@ describe(assetService.getAllAssetsByTag, () => {
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
[
buildResponse({
assets: [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true }
)
has_more: true
})
)
.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
limit: 3
})
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
@@ -619,23 +614,12 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
it('rejects tagged asset pages without has_more', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1
})
)
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 2 })
).rejects.toThrow(/Invalid asset response/)
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
)
await assetService.getAllAssetsByTag('input', true, {
@@ -652,13 +636,12 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildAssetListResponse(
[
return buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
)
]
})
})
await expect(
@@ -683,7 +666,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -702,8 +685,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -737,7 +720,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -767,7 +750,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -787,12 +770,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildAssetListResponse(assets))
resolveResponse(buildResponse({ assets }))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -805,9 +788,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -826,9 +809,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -844,7 +827,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -866,7 +849,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()

View File

@@ -473,8 +473,11 @@ function createAssetService() {
}
const data = await res.json()
const result = assetItemSchema.safeParse(data)
if (result.success) return result.data
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const error = result.error
? fromZodError(result.error)
@@ -545,7 +548,10 @@ function createAssetService() {
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
if (batch.length === 0 || !data.has_more) {
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
return assets
}

View File

@@ -1,140 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { verifyCandidatesByAssetHash } from './assetHashVerification'
interface Candidate {
id: string
hash: string | null
}
function candidate(id: string, hash: string | null): Candidate {
return { id, hash }
}
describe(verifyCandidatesByAssetHash, () => {
it('deduplicates hash checks and groups existing and missing candidates', async () => {
const existingHash = 'blake3:existing'
const missingHash = 'blake3:missing'
const candidates = [
candidate('a', existingHash),
candidate('b', existingHash),
candidate('c', missingHash)
]
const checkAssetHash = vi.fn(async (hash: string) =>
hash === existingHash ? ('exists' as const) : ('missing' as const)
)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash
})
expect(result.aborted).toBe(false)
expect(result.existing.map((candidate) => candidate.id)).toEqual(['a', 'b'])
expect(result.missing.map((candidate) => candidate.id)).toEqual(['c'])
expect(result.fallback).toEqual([])
expect(checkAssetHash).toHaveBeenCalledTimes(2)
})
it('routes candidates without hashes and invalid hashes to fallback', async () => {
const invalidHash = 'blake3:invalid'
const candidates = [candidate('a', null), candidate('b', invalidHash)]
const checkAssetHash = vi.fn(async () => 'invalid' as const)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash
})
expect(result.existing).toEqual([])
expect(result.missing).toEqual([])
expect(result.fallback.map((candidate) => candidate.id)).toEqual(['a', 'b'])
expect(checkAssetHash).toHaveBeenCalledOnce()
})
it('routes non-abort verification failures to fallback', async () => {
const candidates = [candidate('a', 'blake3:network-failure')]
const error = new Error('network failed')
const onError = vi.fn()
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash: async () => {
throw error
},
onError
})
expect(result.fallback).toEqual(candidates)
expect(result.aborted).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
})
it('returns aborted without resolving candidates when the signal is aborted', async () => {
const controller = new AbortController()
controller.abort()
const candidates = [candidate('a', 'blake3:aborted')]
const checkAssetHash = vi.fn(async () => 'exists' as const)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash,
signal: controller.signal
})
expect(result).toEqual({
existing: [],
missing: [],
fallback: [],
aborted: true
})
expect(checkAssetHash).not.toHaveBeenCalled()
})
it('silences abort errors from hash verification', async () => {
const candidates = [candidate('a', 'blake3:aborted')]
const onError = vi.fn()
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash: async () => {
throw new DOMException('aborted', 'AbortError')
},
onError
})
expect(result.aborted).toBe(true)
expect(result.existing).toEqual([])
expect(result.missing).toEqual([])
expect(result.fallback).toEqual([])
expect(onError).not.toHaveBeenCalled()
})
it('caps concurrent hash checks', async () => {
let activeChecks = 0
let maxActiveChecks = 0
const candidates = Array.from({ length: 6 }, (_, index) =>
candidate(String(index), `blake3:${index}`)
)
await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
maxConcurrent: 2,
checkAssetHash: async () => {
activeChecks++
maxActiveChecks = Math.max(maxActiveChecks, activeChecks)
await new Promise((resolve) => setTimeout(resolve, 1))
activeChecks--
return 'missing'
}
})
expect(maxActiveChecks).toBeLessThanOrEqual(2)
})
})

View File

@@ -1,111 +0,0 @@
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import { isAbortError } from '@/utils/typeGuardUtil'
export type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
interface AssetHashVerificationResult<T> {
existing: T[]
missing: T[]
fallback: T[]
aborted: boolean
}
interface VerifyCandidatesByAssetHashOptions<T> {
candidates: readonly T[]
getAssetHash: (candidate: T) => string | null
checkAssetHash: AssetHashVerifier
signal?: AbortSignal
maxConcurrent?: number
onError?: (error: unknown) => void
}
const DEFAULT_MAX_CONCURRENT_HASH_CHECKS = 12
export async function verifyCandidatesByAssetHash<T>({
candidates,
getAssetHash,
checkAssetHash,
signal,
maxConcurrent = DEFAULT_MAX_CONCURRENT_HASH_CHECKS,
onError
}: VerifyCandidatesByAssetHashOptions<T>): Promise<
AssetHashVerificationResult<T>
> {
const result: AssetHashVerificationResult<T> = {
existing: [],
missing: [],
fallback: [],
aborted: false
}
if (signal?.aborted) return { ...result, aborted: true }
const candidatesByHash = new Map<string, T[]>()
for (const candidate of candidates) {
const assetHash = getAssetHash(candidate)
if (!assetHash) {
result.fallback.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
}
const entries = [...candidatesByHash.entries()]
let nextIndex = 0
const workerCount = Math.min(
entries.length,
Math.max(1, Math.floor(maxConcurrent))
)
async function verifyNextHash(): Promise<void> {
while (!result.aborted && nextIndex < entries.length) {
const entry = entries[nextIndex++]
if (!entry) return
const [assetHash, hashCandidates] = entry
if (signal?.aborted) {
result.aborted = true
return
}
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
result.aborted = true
return
}
onError?.(error)
result.fallback.push(...hashCandidates)
continue
}
if (signal?.aborted) {
result.aborted = true
return
}
if (status === 'exists') {
result.existing.push(...hashCandidates)
} else if (status === 'missing') {
result.missing.push(...hashCandidates)
} else {
result.fallback.push(...hashCandidates)
}
}
}
await Promise.all(
Array.from({ length: workerCount }, async () => await verifyNextHash())
)
return result
}

View File

@@ -403,7 +403,8 @@ describe('verifyCloudMediaCandidates', () => {
})
it('silences aborts while loading legacy fallback input assets', async () => {
const abortError = new DOMException('aborted', 'AbortError')
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
@@ -426,7 +427,8 @@ describe('verifyCloudMediaCandidates', () => {
})
it('silences aborts from the default legacy fallback input asset store path', async () => {
const abortError = new DOMException('aborted', 'AbortError')
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })

View File

@@ -19,13 +19,11 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
isBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
import { isAbortError } from '@/utils/typeGuardUtil'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -114,8 +112,70 @@ export function scanNodeMediaCandidates(
return candidates
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
candidatesByHash: Map<string, MissingMediaCandidate[]>
legacyCandidates: MissingMediaCandidate[]
} {
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
const legacyCandidates: MissingMediaCandidate[] = []
for (const candidate of candidates) {
if (!isBlake3AssetHash(candidate.name)) {
legacyCandidates.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(candidate.name)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(candidate.name, [candidate])
}
return { candidatesByHash, legacyCandidates }
}
async function verifyCandidatesByHash(
candidatesByHash: Map<string, MissingMediaCandidate[]>,
legacyCandidates: MissingMediaCandidate[],
signal: AbortSignal | undefined,
checkAssetHash: AssetHashVerifier
): Promise<void> {
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Media Pipeline] Failed to verify asset hash:',
err
)
legacyCandidates.push(...hashCandidates)
return
}
if (status === 'invalid') {
legacyCandidates.push(...hashCandidates)
return
}
for (const candidate of hashCandidates) {
candidate.isMissing = status === 'missing'
}
})
)
}
/**
* Verify cloud media candidates by probing the asset hash endpoint first.
* Invalid hash values fall back to the legacy input asset list check.
@@ -131,26 +191,16 @@ export async function verifyCloudMediaCandidates(
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
const verification = await verifyCandidatesByAssetHash({
candidates: pending,
getAssetHash: (candidate) =>
isBlake3AssetHash(candidate.name) ? candidate.name : null,
const { candidatesByHash, legacyCandidates } =
groupCandidatesForHashLookup(pending)
await verifyCandidatesByHash(
candidatesByHash,
legacyCandidates,
signal,
checkAssetHash,
onError: (err) => {
console.warn('[Missing Media Pipeline] Failed to verify asset hash:', err)
}
})
if (verification.aborted) return
checkAssetHash
)
for (const candidate of verification.existing) {
candidate.isMissing = false
}
for (const candidate of verification.missing) {
candidate.isMissing = true
}
if (signal?.aborted || verification.fallback.length === 0) return
if (signal?.aborted || legacyCandidates.length === 0) return
let inputAssets: AssetItem[]
try {
@@ -166,7 +216,7 @@ export async function verifyCloudMediaCandidates(
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
)
for (const candidate of verification.fallback) {
for (const candidate of legacyCandidates) {
candidate.isMissing = !assetHashes.has(candidate.name)
}
}
@@ -177,6 +227,15 @@ async function fetchMissingInputAssets(
return await assetService.getInputAssetsIncludingPublic(signal)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -1557,7 +1557,8 @@ describe('verifyAssetSupportedCandidates', () => {
it('should not warn or fall back when hash verification is aborted', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const abortError = new DOMException('aborted', 'AbortError')
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const hash =
'4444444444444444444444444444444444444444444444444444444444444444'
const candidates = [

View File

@@ -24,12 +24,11 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -451,6 +450,11 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
@@ -464,25 +468,47 @@ export async function verifyAssetSupportedCandidates(
)
if (pendingCandidates.length === 0) return
const verification = await verifyCandidatesByAssetHash({
candidates: pendingCandidates,
getAssetHash: getBlake3AssetHash,
signal,
checkAssetHash,
onError: (err) => {
console.warn('[Missing Model Pipeline] Failed to verify asset hash:', err)
}
})
if (verification.aborted) return
const pendingNodeTypes = new Set<string>()
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
for (const candidate of verification.existing) {
candidate.isMissing = false
for (const candidate of pendingCandidates) {
const assetHash = getBlake3AssetHash(candidate)
if (!assetHash) {
pendingNodeTypes.add(candidate.nodeType)
continue
}
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
}
const pendingNodeTypes = new Set(
[...verification.missing, ...verification.fallback].map(
(candidate) => candidate.nodeType
)
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
try {
const status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
if (status === 'exists') {
for (const candidate of hashCandidates) {
candidate.isMissing = false
}
return
}
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Model Pipeline] Failed to verify asset hash:',
err
)
}
for (const candidate of hashCandidates) {
pendingNodeTypes.add(candidate.nodeType)
}
})
)
if (signal?.aborted) return
@@ -523,6 +549,15 @@ function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
return toBlake3AssetHash(candidate.hash)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}

View File

@@ -6,7 +6,6 @@ import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { isAbortError } from '@/utils/typeGuardUtil'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -274,6 +273,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
fileSizes.value = {}
}
function isAbortError(error: unknown) {
return error instanceof Error && error.name === 'AbortError'
}
async function refreshMissingModels() {
if (isRefreshingMissingModels.value) return