mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 08:38:13 +00:00
## Summary Redesign missing-model detection for ADR 0009 promoted subgraph widgets so candidates are created from the widget value the user can actually edit, while still using the concrete interior widget as the schema/options source. ## Why This PR Exists This PR comes from the follow-up missing-model detection work for the ADR 0009 / 1.46 subgraph widget changes introduced by [#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197). [#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197) intentionally changed promoted subgraph widgets to be represented through subgraph input links. After that change, the promoted widget on the host `SubgraphNode` is the editable value owner, and linked interior widgets are no longer guaranteed to mirror that value. The old missing-model contract still treated the concrete interior node widget as the effective source of truth in subgraph cases: - recursive scans entered the subgraph and scanned the interior widget value; - candidates were keyed by the interior node/widget identity; - the parent subgraph host mostly received propagated highlight/navigation behavior; - subgraph container widgets were not treated as first-class candidate sources. That contract breaks after ADR 0009. A user can resolve a missing model by changing the promoted host widget to an installed model, while the linked interior widget can still hold the old stale value. If detection keeps scanning the linked interior value, entering the subgraph or reloading the workflow can re-create a false missing-model error that no longer corresponds to the value the user can edit. ADR 0009 also means the same subgraph definition can be reused by multiple `SubgraphNode` hosts. Once missing-model detection moves from the interior definition widget to the promoted host widget, the selected value is no longer a property of the shared definition alone. It is a property of a specific host instance. That makes the old interior-node identity insufficient for mode changes, removal handling, and re-scan behavior: a single interior leaf definition can be reachable through multiple host execution paths, and only the affected host path should add, remove, or restore a candidate. This PR also builds on [#12990](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12990), which narrowed workflow-level `models[]` and embedded model data to metadata enrichment only. Together, the intended boundary is: - live widgets create missing-model candidates; - workflow/root-level `models[]` and node metadata only enrich candidates that already came from a live widget; - promoted widget values are read from the editable host widget, not inferred from stale interior `widgets_values`. ## Changes - **What**: - Introduces promoted-widget scan targets that split: - host promoted widget value and candidate identity; - concrete leaf widget/node definition data. - Scans the outermost unlinked promoted widget on a `SubgraphNode` host as the selected value owner. - Skips linked interior widgets as candidate sources, preventing stale linked widget values from producing duplicate or false missing-model candidates. - Resolves the concrete leaf widget for combo options, asset-widget support, node type, directory lookup, and embedded metadata enrichment. - Keys promoted missing-model candidates by the host execution id and host promoted widget name. - Adds `sourceExecutionId` to promoted candidates so liveness still follows the concrete source execution path, including nested inactive subgraph containers. - Uses source-scope activity for pipeline filtering and async verification, while keeping highlight/store/clearing identity host-keyed. - Removes host-keyed promoted candidates when their source execution scope is removed or bypassed. - Re-scans ancestor subgraph hosts when an interior source path is un-bypassed, so host-keyed promoted errors can reappear correctly. - Handles shared subgraph definitions by deriving promoted source paths from the concrete host instance path, rather than treating the shared definition node id as globally unique. - Shares promoted source resolution between Vue node processing and the right-side panel to avoid drift. - Aligns missing-model clearing across Vue node widgets, legacy canvas widgets, and right-side panel Parameters/Nodes section widgets. - Adds unit coverage for scan identity, source-scope liveness, dynamic mode changes, source-scope removal, shared-definition host isolation, and right-side panel clearing. - Adds nested promoted-widget E2E coverage for OSS and Cloud flows across Vue, Parameters tab, and legacy widget surfaces. - **Breaking**: None expected. - **Dependencies**: None. ## New Detection Contract A missing-model candidate is created from an unlinked final editable value owner. That value owner can be: - a normal node model widget; or - the outermost promoted model widget displayed on a `SubgraphNode` host. For promoted widgets: - the host promoted widget supplies the selected value; - the host execution id and host widget name are the candidate identity; - the concrete leaf widget supplies definition data such as combo options and asset-browser support; - the concrete source execution path is retained as `sourceExecutionId` for liveness only; - linked interior widgets are skipped as candidate sources because their values are not authoritative when driven by a promoted input. For a nested chain: `Outer promoted widget A -> inner promoted widget B -> concrete widget C` only `A` creates the candidate. `B` and `C` are linked along the promoted-input path and are skipped as selected-value sources, while `C` still provides the concrete widget definition used to evaluate `A`. ## Shared Definition And Source-Scope Liveness ADR 0009 promoted widgets make subgraphs behave more like reusable definitions with host-owned inputs. Two host `SubgraphNode`s can point at the same interior subgraph definition while carrying different promoted widget values. In that shape, the missing-model candidate must be keyed to the editable host surface, but the activity check cannot use the host id alone. For example, if two outer hosts share the same nested subgraph definition, one host can select a valid model while the other still selects a missing model. The result should be one missing-model reference, not a single definition-level error and not two errors after one host is fixed. Likewise, bypassing or un-bypassing an interior nested container should affect only the host execution paths that actually pass through that container. This PR therefore separates two concepts: - **candidate identity**: host execution id + host promoted widget name, used for storage, highlight, navigation, and clearing; - **candidate liveness**: concrete source execution path, used for scan-time activity checks, pipeline filtering, async verification, source-scope removal, and re-exposure after mode changes. That separation is the reason this PR updates more than the scan itself. Moving the detection target to the subgraph host also requires the mode-change and removal paths to understand that a host-keyed candidate can be invalidated by a descendant source path, and can need to be restored by re-scanning an ancestor host when an interior source path becomes active again. ## Review Focus Please review the identity split carefully: - candidate/store/highlight/clearing identity should remain host-keyed for promoted widgets; - liveness should use `sourceExecutionId` when present, falling back to `nodeId` for normal candidates; - scan-time activity checks should account for the source node itself and all ancestor subgraph containers; - source-scope removal should remove host-keyed candidates whose concrete source path was removed or bypassed; - un-bypassing an interior source path should re-scan affected ancestor subgraph hosts so host-keyed candidates can reappear; - shared subgraph definitions should not merge errors across different host instances; - linked interior widgets should not produce their own missing-model candidates; - asset-browser eligibility should be resolved from the concrete leaf node type and widget name, not the synthetic subgraph host type; - right-side panel edits should clear host missing-model errors and source validation errors consistently. The E2E matrix intentionally keeps nested promoted workflows only. Nested promoted widgets cover the same editable host path as single promoted widgets while also exercising the `A -> B -> C` chain that can break source-scope liveness and re-scan behavior. The nested fixture also includes multiple host instances that share the same subgraph definition, so it verifies that fixing one host does not accidentally clear or suppress another host's missing-model candidate. Direct/single promoted behavior is still covered at the unit level. ## Non-Goals - This PR does not reintroduce workflow-level `models[]` candidate creation. - This PR does not infer selected model values from `widgets_values`. - This PR does not synchronize linked interior widget values back from promoted host widgets. - This PR does not redesign missing-media scanning; missing media still skips subgraph containers and remains keyed by concrete interior paths. The shared async post-verification active-scope filter is intentionally stricter, so a pending missing-media candidate is no longer surfaced if its own node is bypassed or removed while verification is in flight. ## Validation - `pnpm exec vitest run src/components/rightSidePanel/parameters/SectionWidgets.test.ts src/platform/missingModel/missingModelScan.test.ts src/composables/graph/useErrorClearingHooks.test.ts src/platform/missingModel/missingModelPipeline.test.ts src/platform/missingModel/missingModelStore.test.ts src/utils/graphTraversalUtil.test.ts src/composables/graph/useGraphNodeManager.test.ts src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts --reporter=dot` - 8 files passed, 294 tests passed. - `pnpm exec vitest run src/platform/missingModel/missingModelScan.test.ts src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts src/components/rightSidePanel/parameters/SectionWidgets.test.ts` - 3 files passed, 71 tests passed. - `pnpm typecheck` - `pnpm typecheck:browser` - `pnpm format:check` - targeted ESLint for changed production/unit/E2E files - `git diff --check` - `pnpm build` - `pnpm build:cloud` - OSS affected E2E on the 8188 build: - `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm exec playwright test browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts --project=chromium --grep "Changing an OSS .*promoted|Refreshing a resolved promoted|Reloading a resolved nested"` - 5 passed. - Cloud affected E2E on the 8188 cloud build: - `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:8188 pnpm exec playwright test browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts --project=cloud --grep "Changing a Cloud .*promoted"` - 2 passed; the Cloud legacy promoted asset-modal case still fails until [#13075](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13075) is merged. - Full OSS `errorsTabModeAware.spec.ts` on the 8188 build: - 23 passed; 3 existing paste/clipboard cases failed before the promoted subgraph section with node count remaining at 1 after `clipboard.paste()`. - Commit hooks ran `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`, and browser typecheck where applicable. - Pre-push hook ran `pnpm knip --cache`. ## Screenshots Before https://github.com/user-attachments/assets/6380c1da-1d92-4b70-888e-3ade572c4b5b After https://github.com/user-attachments/assets/4cfc24d6-3dc3-4e36-9b31-72fea6b3d9d5
864 lines
29 KiB
TypeScript
864 lines
29 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
import type { Locator } from '@playwright/test'
|
|
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
import {
|
|
cleanupFakeModel,
|
|
openErrorsTab,
|
|
loadWorkflowAndOpenErrorsTab
|
|
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
|
import {
|
|
appendComboInputOptions,
|
|
routeObjectInfoFromSetupApi
|
|
} from '@e2e/fixtures/utils/objectInfo'
|
|
import {
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
expectMissingModelReferenceCount,
|
|
expectNoMissingModelUi,
|
|
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
|
|
expectSingleMissingModelReference,
|
|
getMissingModelLabel,
|
|
loadPromotedMissingModelAndOpenErrorsTab,
|
|
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
|
|
selectSectionComboPromotedModel,
|
|
selectVueComboPromotedModelByTitle,
|
|
setLegacyPromotedComboModel
|
|
} from '@e2e/fixtures/utils/promotedMissingModel'
|
|
|
|
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
|
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
|
|
|
const promotedModelTest = test.extend({
|
|
page: async ({ page }, use) => {
|
|
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
|
page,
|
|
(objectInfo) =>
|
|
appendComboInputOptions(
|
|
objectInfo,
|
|
'CheckpointLoaderSimple',
|
|
'ckpt_name',
|
|
[RESOLVED_PROMOTED_MODEL_NAME]
|
|
)
|
|
)
|
|
try {
|
|
await use(page)
|
|
} finally {
|
|
await unrouteObjectInfo()
|
|
}
|
|
}
|
|
})
|
|
|
|
async function expectReferenceBadge(group: Locator, count: number) {
|
|
await expect(
|
|
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
|
).toHaveText(String(count))
|
|
}
|
|
|
|
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
|
await comfyPage.settings.setSetting(
|
|
'Comfy.RightSidePanel.ShowErrorsTab',
|
|
true
|
|
)
|
|
})
|
|
|
|
test.describe('Missing nodes', () => {
|
|
test('Deleting a missing node removes its error from the errors tab', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
|
|
|
const missingNodeGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingNodePacksGroup
|
|
)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.delete()
|
|
|
|
await expect(missingNodeGroup).toBeHidden()
|
|
})
|
|
|
|
test('Undo after bypass restores error without showing overlay', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
|
|
|
const missingNodeGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingNodePacksGroup
|
|
)
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingNodeGroup).toBeHidden()
|
|
|
|
await comfyPage.keyboard.undo()
|
|
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
|
await expect(errorOverlay).toBeHidden()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
|
|
await comfyPage.keyboard.redo()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingNodeGroup).toBeHidden()
|
|
})
|
|
})
|
|
|
|
test.describe('Missing models', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await cleanupFakeModel(comfyPage)
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await cleanupFakeModel(comfyPage)
|
|
})
|
|
|
|
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeHidden()
|
|
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(
|
|
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
|
).toBeHidden()
|
|
})
|
|
|
|
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingModelGroup).toBeHidden()
|
|
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
})
|
|
|
|
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Regression: on un-bypass, the realtime scan produced a fresh
|
|
// candidate without url/hash/directory — those fields were only
|
|
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
|
|
// row's Copy URL button (v-if gated on representative.url) then
|
|
// disappeared. Per-node scan now enriches from node.properties.models
|
|
// which persists across mode toggles. Uses the `_from_node_properties`
|
|
// fixture because the enrichment source is per-node metadata, not
|
|
// the workflow-level `models[]` array (which the realtime scan
|
|
// path does not see).
|
|
await loadWorkflowAndOpenErrorsTab(
|
|
comfyPage,
|
|
'missing/missing_models_from_node_properties'
|
|
)
|
|
|
|
const copyUrlButton = comfyPage.page.getByRole('button', {
|
|
name: 'Copy URL'
|
|
})
|
|
await expect(copyUrlButton.first()).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(copyUrlButton.first()).toBeVisible()
|
|
})
|
|
|
|
test('Pasting a node with missing model increases referencing node count', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
await expect(
|
|
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
|
).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.clipboard.copy()
|
|
await comfyPage.clipboard.paste()
|
|
|
|
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
|
|
|
await comfyPage.canvas.click()
|
|
await expectReferenceBadge(missingModelGroup, 2)
|
|
})
|
|
|
|
test('Pasting a bypassed node does not add a new error', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingModelGroup).toBeHidden()
|
|
|
|
await comfyPage.clipboard.copy()
|
|
await comfyPage.clipboard.paste()
|
|
|
|
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
|
await expect(missingModelGroup).toBeHidden()
|
|
})
|
|
|
|
test('Deleting a node with missing model removes its error', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.delete()
|
|
|
|
await expect(missingModelGroup).toBeHidden()
|
|
})
|
|
|
|
test('Undo after bypass restores error without showing overlay', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingModelGroup).toBeHidden()
|
|
|
|
await comfyPage.keyboard.undo()
|
|
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
|
await expect(errorOverlay).toBeHidden()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
|
|
await comfyPage.keyboard.redo()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingModelGroup).toBeHidden()
|
|
})
|
|
|
|
test('Selecting a node filters errors tab to only that node', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(
|
|
comfyPage,
|
|
'missing/missing_models_with_nodes'
|
|
)
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await expectReferenceBadge(missingModelGroup, 2)
|
|
|
|
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await node1.click('title')
|
|
await expect(
|
|
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
|
).toBeVisible()
|
|
await expect(
|
|
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
|
).toHaveCount(1)
|
|
|
|
await comfyPage.canvas.click()
|
|
await expectReferenceBadge(missingModelGroup, 2)
|
|
})
|
|
})
|
|
|
|
test.describe('Missing media', () => {
|
|
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeHidden()
|
|
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(
|
|
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
|
).toBeHidden()
|
|
})
|
|
|
|
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(
|
|
comfyPage,
|
|
'missing/missing_media_single'
|
|
)
|
|
|
|
const missingMediaGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingMediaGroup
|
|
)
|
|
await expect(missingMediaGroup).toBeVisible()
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingMediaGroup).toBeHidden()
|
|
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingMediaGroup).toBeVisible()
|
|
})
|
|
|
|
test('Pasting a bypassed node does not add a new error', async ({
|
|
comfyPage
|
|
}) => {
|
|
await loadWorkflowAndOpenErrorsTab(
|
|
comfyPage,
|
|
'missing/missing_media_single'
|
|
)
|
|
|
|
const missingMediaGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingMediaGroup
|
|
)
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
|
await node.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
|
await expect(missingMediaGroup).toBeHidden()
|
|
|
|
await comfyPage.clipboard.copy()
|
|
await comfyPage.clipboard.paste()
|
|
|
|
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
|
await expect(missingMediaGroup).toBeHidden()
|
|
})
|
|
|
|
test('Selecting a node filters errors tab to only that node', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const mediaRows = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingMediaRow
|
|
)
|
|
|
|
await openErrorsTab(comfyPage)
|
|
await expect(mediaRows).toHaveCount(2)
|
|
|
|
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
|
await node.click('title')
|
|
await expect(mediaRows).toHaveCount(1)
|
|
|
|
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
|
await expect(mediaRows).toHaveCount(2)
|
|
})
|
|
})
|
|
|
|
test.describe('Subgraph', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await cleanupFakeModel(comfyPage)
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await cleanupFakeModel(comfyPage)
|
|
})
|
|
|
|
promotedModelTest(
|
|
'Changing an OSS Vue promoted model clears a nested subgraph error',
|
|
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
|
async ({ comfyPage }) => {
|
|
let missingModelGroup: Locator
|
|
|
|
await test.step('A: shared-definition active host reports the missing model', async () => {
|
|
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
FAKE_MODEL_NAME
|
|
)
|
|
})
|
|
|
|
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
|
|
const siblingHostNodeId =
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
|
if (siblingHostNodeId === undefined) {
|
|
throw new Error('Expected a shared-definition sibling host')
|
|
}
|
|
|
|
const siblingHost =
|
|
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
|
|
await siblingHost.centerOnNode()
|
|
await siblingHost.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
|
|
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
|
|
await openErrorsTab(comfyPage)
|
|
await expectSingleMissingModelReference(
|
|
missingModelGroup,
|
|
FAKE_MODEL_NAME
|
|
)
|
|
})
|
|
|
|
await test.step('C: changing the active host promoted widget resolves the model', async () => {
|
|
const activeHost = await comfyPage.nodeOps.getNodeRefById(
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
|
|
)
|
|
await activeHost.centerOnNode()
|
|
await selectVueComboPromotedModelByTitle(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
|
RESOLVED_PROMOTED_MODEL_NAME
|
|
)
|
|
})
|
|
|
|
await test.step('D: the missing model UI clears', async () => {
|
|
await expectNoMissingModelUi(comfyPage)
|
|
})
|
|
|
|
await test.step('E: two missing shared-definition hosts report two references', async () => {
|
|
const siblingHostNodeId =
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
|
if (siblingHostNodeId === undefined) {
|
|
throw new Error('Expected a shared-definition sibling host')
|
|
}
|
|
|
|
missingModelGroup =
|
|
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
{
|
|
[siblingHostNodeId]: FAKE_MODEL_NAME,
|
|
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
|
|
FAKE_MODEL_NAME
|
|
},
|
|
FAKE_MODEL_NAME,
|
|
2
|
|
)
|
|
})
|
|
|
|
await test.step('F: changing one missing host leaves the other missing reference', async () => {
|
|
await selectVueComboPromotedModelByTitle(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
|
RESOLVED_PROMOTED_MODEL_NAME
|
|
)
|
|
await expectMissingModelReferenceCount(
|
|
missingModelGroup,
|
|
FAKE_MODEL_NAME,
|
|
1
|
|
)
|
|
})
|
|
|
|
await test.step('G: changing the remaining missing host clears the model error', async () => {
|
|
const siblingHostTitle =
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
|
|
if (siblingHostTitle === undefined) {
|
|
throw new Error('Expected a shared-definition sibling host title')
|
|
}
|
|
|
|
await selectVueComboPromotedModelByTitle(
|
|
comfyPage,
|
|
siblingHostTitle,
|
|
RESOLVED_PROMOTED_MODEL_NAME
|
|
)
|
|
await expectNoMissingModelUi(comfyPage)
|
|
})
|
|
}
|
|
)
|
|
|
|
promotedModelTest(
|
|
'Changing an OSS Vue promoted model 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 selectSectionComboPromotedModel(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
RESOLVED_PROMOTED_MODEL_NAME
|
|
)
|
|
|
|
await expectNoMissingModelUi(comfyPage)
|
|
}
|
|
)
|
|
|
|
promotedModelTest(
|
|
'Changing an OSS legacy promoted model clears a nested subgraph error',
|
|
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
|
await loadPromotedMissingModelAndOpenErrorsTab(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
FAKE_MODEL_NAME
|
|
)
|
|
|
|
await setLegacyPromotedComboModel(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
RESOLVED_PROMOTED_MODEL_NAME
|
|
)
|
|
|
|
await expectNoMissingModelUi(comfyPage)
|
|
}
|
|
)
|
|
|
|
promotedModelTest(
|
|
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
|
{ tag: ['@widget', '@subgraph'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
await loadWorkflowAndOpenErrorsTab(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
|
|
)
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await expect(
|
|
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
|
).toBeVisible()
|
|
|
|
const promotedModelCombo = comfyPage.vueNodes
|
|
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
|
|
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
|
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
|
|
|
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
|
comfyPage.page,
|
|
(objectInfo) =>
|
|
appendComboInputOptions(
|
|
objectInfo,
|
|
'CheckpointLoaderSimple',
|
|
'ckpt_name',
|
|
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
|
|
)
|
|
)
|
|
|
|
try {
|
|
await comfyPage.page
|
|
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
|
.click()
|
|
|
|
await expect(missingModelGroup).toBeHidden()
|
|
await expect(promotedModelCombo).toBeVisible()
|
|
await expect(promotedModelCombo).not.toHaveAttribute(
|
|
'aria-invalid',
|
|
'true'
|
|
)
|
|
} finally {
|
|
await unrouteObjectInfo()
|
|
}
|
|
}
|
|
)
|
|
|
|
promotedModelTest(
|
|
'Reloading a resolved nested promoted model ignores stale interior values',
|
|
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
|
async ({ comfyPage }) => {
|
|
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
|
comfyPage,
|
|
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
|
[
|
|
{
|
|
subgraphNodeIdToEnter: '4',
|
|
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
|
|
},
|
|
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
|
|
],
|
|
RESOLVED_PROMOTED_MODEL_NAME,
|
|
FAKE_MODEL_NAME
|
|
)
|
|
}
|
|
)
|
|
|
|
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'missing/missing_models_in_subgraph'
|
|
)
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
const errorsTab = comfyPage.page.getByTestId(
|
|
TestIds.propertiesPanel.errorsTab
|
|
)
|
|
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
|
|
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(errorsTab).toBeHidden()
|
|
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.keyboard.bypass()
|
|
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
})
|
|
|
|
test('Deleting a node inside a subgraph removes its missing model error', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Regression: before the execId fix, onNodeRemoved fell back to the
|
|
// interior node's local id (e.g. "1") when node.graph was already
|
|
// null, so the error keyed under "2:1" was never removed.
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'missing/missing_models_in_subgraph'
|
|
)
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
// Select-all + Delete: interior node IDs may be reassigned during
|
|
// subgraph configure when they collide with root-graph IDs, so
|
|
// looking up by static id can fail.
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.page.keyboard.press('Delete')
|
|
|
|
await expect(missingModelGroup).toBeHidden()
|
|
})
|
|
|
|
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const missingNodeGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingNodePacksGroup
|
|
)
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
// Select-all + Delete: interior node IDs may be reassigned during
|
|
// subgraph configure when they collide with root-graph IDs, so
|
|
// looking up by static id can fail.
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.page.keyboard.press('Delete')
|
|
|
|
await expect(missingNodeGroup).toBeHidden()
|
|
})
|
|
|
|
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'missing/missing_models_in_subgraph'
|
|
)
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const missingModelGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingModelsGroup
|
|
)
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.keyboard.bypass()
|
|
|
|
const errorsTab = comfyPage.page.getByTestId(
|
|
TestIds.propertiesPanel.errorsTab
|
|
)
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(errorsTab).toBeHidden()
|
|
|
|
await comfyPage.keyboard.selectAll()
|
|
await comfyPage.keyboard.bypass()
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingModelGroup).toBeVisible()
|
|
})
|
|
|
|
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Regression: the initial scan pipeline only checked each node's
|
|
// own mode, so interior nodes of a bypassed subgraph container
|
|
// surfaced errors even though the container was excluded from
|
|
// execution. The pipeline now post-filters candidates whose
|
|
// ancestor path is not fully active.
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'missing/missing_models_in_bypassed_subgraph'
|
|
)
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeHidden()
|
|
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(
|
|
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
|
).toBeHidden()
|
|
})
|
|
|
|
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Regression: useGraphNodeManager replays graph.onNodeAdded for
|
|
// each interior node on subgraph entry; without an ancestor-aware
|
|
// guard in scanSingleNodeErrors, that re-scan reintroduced the
|
|
// error that the initial pipeline had correctly suppressed.
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'missing/missing_models_in_bypassed_subgraph'
|
|
)
|
|
|
|
const errorsTab = comfyPage.page.getByTestId(
|
|
TestIds.propertiesPanel.errorsTab
|
|
)
|
|
await comfyPage.actionbar.propertiesButton.click()
|
|
await expect(errorsTab).toBeHidden()
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
await expect(errorsTab).toBeHidden()
|
|
})
|
|
})
|
|
|
|
test.describe('Workflow switching', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting(
|
|
'Comfy.Workflow.WorkflowTabsPosition',
|
|
'Sidebar'
|
|
)
|
|
await comfyPage.menu.workflowsTab.open()
|
|
})
|
|
|
|
test('Restores missing nodes in errors tab when switching back to workflow', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
|
|
|
const errorOverlay = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.errorOverlay
|
|
)
|
|
await expect(errorOverlay).toBeVisible()
|
|
await errorOverlay
|
|
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
|
.click()
|
|
|
|
const missingNodeGroup = comfyPage.page.getByTestId(
|
|
TestIds.dialogs.missingNodePacksGroup
|
|
)
|
|
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
|
|
await comfyPage.menu.workflowsTab.open()
|
|
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
|
await expect(missingNodeGroup).toBeHidden()
|
|
|
|
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
|
await openErrorsTab(comfyPage)
|
|
await expect(missingNodeGroup).toBeVisible()
|
|
})
|
|
})
|
|
})
|