Files
ComfyUI_frontend/browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts
Alexander Brown b4ae6344d7 Brand local node IDs (#13085)
## Summary

Adds a branded local `NodeId` helper and starts separating local node
identity from serialized workflow IDs.

## Changes

- **What**: Adds central `NodeId` parsing/branding helpers, migrates
nearby widget identity types, keeps queue results at the serialized
boundary, and removes misleading workflow `NodeId` usage from execution
error maps.

## Review Focus

Check that the first migration slice keeps serialized/API IDs as raw
`number | string` while local UI/store IDs use the branded string type.

## Caveat

`SUBGRAPH_INPUT_ID` and `SUBGRAPH_OUTPUT_ID` are now branded local
`NodeId` string values internally instead of numeric sentinels.
Reviewers should double-check extension compatibility for callers that
import `Constants` and compare those values numerically.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:54:04 +00:00

479 lines
15 KiB
TypeScript

import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
AssetCreated,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectNoMissingModelUi,
loadPromotedMissingModelAndOpenErrorsTab,
selectLegacyPromotedAssetModel,
selectSectionAssetPromotedModel,
selectVueAssetPromotedModel
} from '@e2e/fixtures/utils/promotedMissingModel'
import { TestIds } from '@e2e/fixtures/selectors'
import { toNodeId } from '@/types/nodeId'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: LOTUS_MODEL_NAME
}
}
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
id: 'test-existing-cloud-importable-model',
name: 'asset-record-display-name.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
size: 2_048,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
}
}
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
id: 'test-resolved-promoted-model',
name: RESOLVED_PROMOTED_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: RESOLVED_PROMOTED_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
const promotedModelTest = createCloudAssetsFixture([
RESOLVED_PROMOTED_MODEL_ASSET
])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
new URL(requestUrl).searchParams
.get('include_tags')
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
function filterAssetsByRequest(
assets: ReadonlyArray<Asset>,
requestUrl: string
): Asset[] {
const includeTags = getRequestedIncludeTags(requestUrl)
return includeTags.length
? assets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: [...assets]
}
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
model_upload_button_enabled: true,
private_models_enabled: true
}
})
}
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableMissingModelImportFeatures(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('keeps installed models resolved after returning from a nested subgraph', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const panel = new PropertiesPanelHelper(comfyPage.page)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
const errorsTab = panel.root.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect
.poll(
() => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
{ timeout: 10_000 }
)
.toBeGreaterThan(0)
await expect(errorOverlay).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(errorsTab).toBeHidden()
await panel.close()
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(errorOverlay).toBeHidden()
const requestCountBeforeRootReturn = countAssetRequestsByTag(
cloudAssetRequests,
'diffusion_models'
)
await comfyPage.subgraph.exitViaBreadcrumb()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect
.poll(
() =>
countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
requestCountBeforeRootReturn,
{ timeout: 10_000 }
)
.toBe(true)
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
test('uses the synced asset filename when applying an already imported cloud model', async ({
comfyPage
}) => {
let isImportedAssetAvailable = false
const visibleAssets = () =>
isImportedAssetAvailable
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
: [LOTUS_DIFFUSION_MODEL]
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
const assets = filterAssetsByRequest(
visibleAssets(),
route.request().url()
)
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 2048,
final_url:
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
content_type: 'application/octet-stream',
filename: CLOUD_IMPORTABLE_MODEL_NAME,
tags: ['checkpoints']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/download', (route) => {
isImportedAssetAvailable = true
const response: AssetCreated = {
...EXISTING_CLOUD_IMPORTABLE_MODEL,
created_new: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
const urlInput = uploadDialog.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
)
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await uploadDialog
.locator('[data-attr="upload-model-step2-confirm-button"]')
.click()
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
}, toNodeId(1))
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)
promotedModelTest.describe(
'Errors tab - Cloud promoted subgraph missing models',
{ tag: '@cloud' },
() => {
promotedModelTest.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
promotedModelTest.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
promotedModelTest(
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectVueAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectLegacyPromotedAssetModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_ASSET.id
)
await expectNoMissingModelUi(comfyPage)
}
)
}
)