mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 12:11:06 +00:00
Compare commits
2 Commits
feat/websi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06686a1f50 | ||
|
|
693b8383d6 |
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_a.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_b.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model_a.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
{
|
||||
"name": "fake_model_b.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-bypassed-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -139,6 +139,27 @@ export class Topbar {
|
||||
await this.menuLocator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Nodes 2.0 on or off via the Comfy logo menu switch (no-op if already
|
||||
* in the requested state).
|
||||
*/
|
||||
async setVueNodesEnabled(enabled: boolean) {
|
||||
await this.openTopbarMenu()
|
||||
const nodes2Switch = this.page.getByRole('switch', { name: 'Nodes 2.0' })
|
||||
await nodes2Switch.waitFor({ state: 'visible' })
|
||||
if ((await nodes2Switch.isChecked()) !== enabled) {
|
||||
await nodes2Switch.click()
|
||||
await this.page.waitForFunction(
|
||||
(wantEnabled) =>
|
||||
window.app!.ui.settings.getSettingValue('Comfy.VueNodes.Enabled') ===
|
||||
wantEnabled,
|
||||
enabled,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
await this.closeTopbarMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a submenu by hovering over a menu item
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,17 @@ export class AppModeHelper {
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The "Switch to Outputs" button inside the connect-output popover. */
|
||||
public readonly connectOutputSwitchButton: Locator
|
||||
/** The empty-workflow dialog shown when entering builder on an empty graph. */
|
||||
public readonly emptyWorkflowDialog: Locator
|
||||
/** "Back to workflow" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowBackButton: Locator
|
||||
/** "Load template" button on the empty-workflow dialog. */
|
||||
public readonly emptyWorkflowLoadTemplateButton: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
@@ -39,6 +48,18 @@ export class AppModeHelper {
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
public readonly arrangePreview: Locator
|
||||
/** Arrange-step state shown when no outputs have been configured. */
|
||||
public readonly arrangeNoOutputs: Locator
|
||||
/** "Switch to Outputs" button inside the arrange no-outputs state. */
|
||||
public readonly arrangeSwitchToOutputsButton: Locator
|
||||
/** The Vue Node switch notification popup shown on entering builder. */
|
||||
public readonly vueNodeSwitchPopup: Locator
|
||||
/** The "Dismiss" button inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
@@ -47,9 +68,22 @@ export class AppModeHelper {
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.connectOutputSwitchButton = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputSwitch
|
||||
)
|
||||
this.emptyWorkflowDialog = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowDialog
|
||||
)
|
||||
this.emptyWorkflowBackButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowBack
|
||||
)
|
||||
this.emptyWorkflowLoadTemplateButton = this.page.getByTestId(
|
||||
TestIds.builder.emptyWorkflowLoadTemplate
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
@@ -75,6 +109,22 @@ export class AppModeHelper {
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
)
|
||||
this.arrangePreview = this.page.getByTestId(TestIds.appMode.arrangePreview)
|
||||
this.arrangeNoOutputs = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeNoOutputs
|
||||
)
|
||||
this.arrangeSwitchToOutputsButton = this.page.getByTestId(
|
||||
TestIds.appMode.arrangeSwitchToOutputs
|
||||
)
|
||||
this.vueNodeSwitchPopup = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchPopup
|
||||
)
|
||||
this.vueNodeSwitchDismissButton = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDismiss
|
||||
)
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -92,6 +142,22 @@ export class AppModeHelper {
|
||||
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
|
||||
/** Set preference so the Vue node switch popup does not appear in builder. */
|
||||
async suppressVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Allow the Vue node switch popup so tests can assert its behavior. */
|
||||
async allowVueNodeSwitchPopup() {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
|
||||
@@ -13,18 +13,30 @@ export class BuilderStepsHelper {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get inputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Inputs' })
|
||||
}
|
||||
|
||||
get outputsButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Outputs' })
|
||||
}
|
||||
|
||||
get previewButton(): Locator {
|
||||
return this.toolbar.getByRole('button', { name: 'Preview' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.inputsButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.outputsButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.previewButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,11 @@ export const TestIds = {
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label',
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
connectOutputPopover: 'builder-connect-output-popover',
|
||||
connectOutputSwitch: 'builder-connect-output-switch',
|
||||
emptyWorkflowDialog: 'builder-empty-workflow-dialog',
|
||||
emptyWorkflowBack: 'builder-empty-workflow-back',
|
||||
emptyWorkflowLoadTemplate: 'builder-empty-workflow-load-template'
|
||||
},
|
||||
outputHistory: {
|
||||
outputs: 'linear-outputs',
|
||||
@@ -163,7 +167,13 @@ export const TestIds = {
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
backToWorkflow: 'linear-welcome-back-to-workflow',
|
||||
loadTemplate: 'linear-welcome-load-template'
|
||||
loadTemplate: 'linear-welcome-load-template',
|
||||
arrangePreview: 'linear-arrange-preview',
|
||||
arrangeNoOutputs: 'linear-arrange-no-outputs',
|
||||
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
|
||||
vueNodeSwitchPopup: 'linear-vue-node-switch-popup',
|
||||
vueNodeSwitchDismiss: 'linear-vue-node-switch-dismiss',
|
||||
vueNodeSwitchDontShowAgain: 'linear-vue-node-switch-dont-show-again'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
70
browser_tests/tests/appModeArrange.spec.ts
Normal file
70
browser_tests/tests/appModeArrange.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Placeholder is shown when outputs are configured but no run has happened', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupBuilder(comfyPage)
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.steps.previewButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangePreview).toBeVisible()
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('No-outputs state navigates to the Outputs step via "Switch to Outputs"', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await expect(appMode.arrangeNoOutputs).toBeVisible()
|
||||
await expect(appMode.arrangePreview).toBeHidden()
|
||||
|
||||
await appMode.arrangeSwitchToOutputsButton.click()
|
||||
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
await expect(appMode.arrangeNoOutputs).toBeHidden()
|
||||
})
|
||||
|
||||
test('Connect-output popover from preview step navigates to the Outputs step', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await appMode.enterBuilder()
|
||||
// From a non-select step (preview/arrange), the popover surfaces a
|
||||
// "Switch to Outputs" shortcut alongside cancel.
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeVisible()
|
||||
await expect(appMode.connectOutputSwitchButton).toBeVisible()
|
||||
|
||||
await appMode.connectOutputSwitchButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeHidden()
|
||||
await expect(appMode.steps.outputsButton).toHaveAttribute(
|
||||
'aria-current',
|
||||
'step'
|
||||
)
|
||||
})
|
||||
})
|
||||
84
browser_tests/tests/appModeVueNodeSwitch.spec.ts
Normal file
84
browser_tests/tests/appModeVueNodeSwitch.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function enterBuilderExpectVueNodeSwitchPopup(comfyPage: ComfyPage) {
|
||||
const { appMode } = comfyPage
|
||||
await appMode.enterBuilder()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectVueNodesEnabled(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>('Comfy.VueNodes.Enabled')
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Vue node switch notification popup', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.allowVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Popup appears when entering builder; dismiss closes without persisting and shows again on a later entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
// "Don't show again" was not checked → preference remains false
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeVisible()
|
||||
})
|
||||
|
||||
test('"Don\'t show again" persists the dismissal and suppresses future popups', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await enterBuilderExpectVueNodeSwitchPopup(comfyPage)
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
|
||||
// Dismiss with dont show again checked
|
||||
await appMode.vueNodeSwitchDontShowAgainCheckbox.check()
|
||||
await appMode.vueNodeSwitchDismissButton.click()
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting<boolean>(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed'
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Disable vue nodes and re-enter builder
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.menu.topbar.setVueNodesEnabled(false)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expectVueNodesEnabled(comfyPage)
|
||||
await expect(appMode.vueNodeSwitchPopup).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
@@ -58,4 +59,37 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowBackButton).toBeVisible()
|
||||
await expect(appMode.emptyWorkflowLoadTemplateButton).toBeVisible()
|
||||
|
||||
// Back to workflow dismisses the dialog and returns to graph mode
|
||||
await appMode.emptyWorkflowBackButton.click()
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
})
|
||||
|
||||
test('Empty workflow dialog "Load template" opens the template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await appMode.enterBuilder()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeVisible()
|
||||
await appMode.emptyWorkflowLoadTemplateButton.click()
|
||||
|
||||
await expect(appMode.emptyWorkflowDialog).toBeHidden()
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,4 +214,34 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Count independence from node selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('missing model count stays constant when a node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,6 +113,40 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
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.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
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
|
||||
}) => {
|
||||
@@ -476,6 +510,52 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
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', () => {
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
data-testid="builder-connect-output-switch"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
{{ t('builderToolbar.switchToOutputs') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<BuilderDialog :show-close="false">
|
||||
<BuilderDialog
|
||||
data-testid="builder-empty-workflow-dialog"
|
||||
:show-close="false"
|
||||
>
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.emptyWorkflowTitle') }}
|
||||
</template>
|
||||
@@ -17,11 +20,17 @@
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-back"
|
||||
@click="$emit('backToWorkflow')"
|
||||
>
|
||||
{{ $t('linearMode.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="builder-empty-workflow-load-template"
|
||||
@click="$emit('loadTemplate')"
|
||||
>
|
||||
{{ $t('linearMode.loadTemplate') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
data-testid="linear-vue-node-switch-popup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@@ -15,6 +16,7 @@
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
data-testid="linear-vue-node-switch-dont-show-again"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
@@ -25,6 +27,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="linear-vue-node-switch-dismiss"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
|
||||
@@ -293,8 +293,8 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -754,4 +756,48 @@ describe('useErrorGroups', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +833,10 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups: filteredMissingModelGroups,
|
||||
missingMediaGroups: filteredMissingMediaGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +728,109 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
|
||||
// Workflow A has a pending candidate on node id=1. A is replaced
|
||||
// by workflow B (fresh LGraph, potentially has a node with the
|
||||
// same id). Late verification from A must not leak into B.
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'stale_from_A.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
// Workflow swap: app.rootGraph now points at graphB.
|
||||
const graphB = new LGraph()
|
||||
const nodeB = new LGraphNode('CheckpointLoaderSimple')
|
||||
graphB.add(nodeB)
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// A's verification finished but rootGraph is now B — the late
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not surface interior missing model when entering a bypassed subgraph', async () => {
|
||||
// Repro: root has a bypassed subgraph container, interior node is
|
||||
// itself active. useGraphNodeManager replays `onNodeAdded` for each
|
||||
// interior node on subgraph entry, which previously reached
|
||||
// scanSingleNodeErrors without an ancestor check and resurfaced the
|
||||
// error that the initial pipeline post-filter had correctly dropped.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode.mode = LGraphEventMode.BYPASS
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
// Any scanner output would surface the error if the ancestor guard
|
||||
// didn't short-circuit first — return a concrete missing candidate.
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(subgraph)
|
||||
|
||||
// Simulate useGraphNodeManager replaying onNodeAdded for existing
|
||||
// interior nodes after Vue node manager init on subgraph entry.
|
||||
subgraph.onNodeAdded?.(interiorNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph,
|
||||
getNodeByExecutionId
|
||||
getNodeByExecutionId,
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
@@ -172,6 +173,14 @@ function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
// Skip when any enclosing subgraph is muted/bypassed. Callers only
|
||||
// verify each node's own mode; entering a bypassed subgraph (via
|
||||
// useGraphNodeManager replaying onNodeAdded for existing interior
|
||||
// nodes) reaches this point without the ancestor check. A null
|
||||
// execId means the node has no current graph (e.g. detached mid
|
||||
// lifecycle) — also skip, since we cannot verify its scope.
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
@@ -237,16 +246,27 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
*/
|
||||
function isCandidateStillActive(nodeId: unknown): boolean {
|
||||
if (!app.rootGraph || nodeId == null) return false
|
||||
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
|
||||
const execId = String(nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (!node) return false
|
||||
return !isNodeInactive(node.mode)
|
||||
if (isNodeInactive(node.mode)) return false
|
||||
// Also reject if any enclosing subgraph was bypassed between scan
|
||||
// kick-off and verification resolving — mirrors the pipeline-level
|
||||
// ancestor post-filter so realtime and initial-load paths stay
|
||||
// symmetric.
|
||||
return isAncestorPathActive(app.rootGraph, execId)
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingModels(
|
||||
pending: MissingModelCandidate[]
|
||||
): Promise<void> {
|
||||
// Capture rootGraph at scan time so a late verification for workflow
|
||||
// A cannot leak into workflow B after a switch — execution IDs (esp.
|
||||
// root-level like "1") collide across workflows.
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyAssetSupportedCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
@@ -259,8 +279,10 @@ async function verifyAndAddPendingModels(
|
||||
async function verifyAndAddPendingMedia(
|
||||
pending: MissingMediaCandidate[]
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
verifyAssetSupportedCandidates,
|
||||
MODEL_FILE_EXTENSIONS
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
@@ -156,6 +158,134 @@ describe('scanNodeModelCandidates', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('enriches candidates with url/hash/directory from node.properties.models', () => {
|
||||
// Regression: bypass/un-bypass cycle previously lost url metadata
|
||||
// because realtime scan only reads widget values. Per-node embedded
|
||||
// metadata in `properties.models` persists across mode toggles, so
|
||||
// the scan now enriches candidates from that source.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
|
||||
'other_model.safetensors'
|
||||
])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/missing_model',
|
||||
directory: 'checkpoints',
|
||||
hash: 'abc123',
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBe('https://example.com/missing_model')
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
expect(result[0].hash).toBe('abc123')
|
||||
expect(result[0].hashType).toBe('sha256')
|
||||
})
|
||||
|
||||
it('preserves existing candidate fields when enriching (no overwrite)', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/new_url',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
// scanComboWidget already sets directory via getDirectory; enrichment
|
||||
// does not overwrite it.
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// url was not set by scan, so enrichment fills it in.
|
||||
expect(result[0].url).toBe('https://example.com/new_url')
|
||||
})
|
||||
|
||||
it('skips enrichment when candidate and embedded model directories differ', () => {
|
||||
// A node can list the same model name under multiple directories
|
||||
// (e.g. a LoRA present in both `loras` and `loras/subdir`). Name-only
|
||||
// matching would stamp the wrong url/hash onto the candidate, so
|
||||
// enrichment must agree on directory when the candidate already has
|
||||
// one.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'collision_model.safetensors', [])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collision_model.safetensors',
|
||||
url: 'https://example.com/wrong_dir_url',
|
||||
directory: 'wrong_dir'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// Directory mismatch — enrichment should not stamp the wrong url.
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not enrich candidates with mismatched model names', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'different_model.safetensors',
|
||||
url: 'https://example.com/different',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllModelCandidates', () => {
|
||||
@@ -925,6 +1055,86 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
|
||||
// Interior properties.models references the workflow-level model
|
||||
// but its widget value does not — forcing the workflow-level entry
|
||||
// down the unmatched path where isModelReferencedByActiveNode
|
||||
// decides. Previously the helper ignored the bypassed container.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
|
||||
// Positive control for the bypassed case above: identical fixture
|
||||
// with container mode=0 must still surface the unmatched workflow-
|
||||
// level model. Guards against a regression where the ancestor gate
|
||||
// drops every workflow-level entry regardless of context.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('rare_model.safetensors')
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when interior reference is under a different directory', async () => {
|
||||
// Same name, different directory: the interior's properties.models
|
||||
// entry is not the same asset as the workflow-level entry, so the
|
||||
// fallback helper must not treat it as a reference that keeps the
|
||||
// workflow-level model alive.
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
directory: 'loras'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: ['unrelated_widget.safetensors']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
url: 'https://example.com/collide',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
@@ -30,6 +32,39 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills url/hash/directory onto a candidate from the node's embedded
|
||||
* `properties.models` metadata when the names match. The full pipeline
|
||||
* does this via enrichWithEmbeddedMetadata + graphData.models, but the
|
||||
* realtime single-node scan (paste, un-bypass) otherwise loses these
|
||||
* fields — making the Missing Model row's download/copy-url buttons
|
||||
* disappear after a bypass/un-bypass cycle.
|
||||
*/
|
||||
function enrichCandidateFromNodeProperties(
|
||||
candidate: MissingModelCandidate,
|
||||
embeddedModels: readonly ModelFile[] | undefined
|
||||
): MissingModelCandidate {
|
||||
if (!embeddedModels?.length) return candidate
|
||||
// Require directory agreement when the candidate already has one —
|
||||
// a single node can reference two models with the same name under
|
||||
// different directories (e.g. a LoRA present in multiple folders);
|
||||
// name-only matching would stamp the wrong url/hash onto the
|
||||
// candidate. Mirrors the directory check in enrichWithEmbeddedMetadata.
|
||||
const match = embeddedModels.find(
|
||||
(m) =>
|
||||
m.name === candidate.name &&
|
||||
(!candidate.directory || candidate.directory === m.directory)
|
||||
)
|
||||
if (!match) return candidate
|
||||
return {
|
||||
...candidate,
|
||||
directory: candidate.directory ?? match.directory,
|
||||
url: candidate.url ?? match.url,
|
||||
hash: candidate.hash ?? match.hash,
|
||||
hashType: candidate.hashType ?? match.hash_type
|
||||
}
|
||||
}
|
||||
|
||||
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
@@ -107,6 +142,8 @@ export function scanNodeModelCandidates(
|
||||
if (!executionId) return []
|
||||
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const embeddedModels = (node as { properties?: { models?: ModelFile[] } })
|
||||
.properties?.models
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
|
||||
@@ -122,7 +159,11 @@ export function scanNodeModelCandidates(
|
||||
)
|
||||
}
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
if (candidate) {
|
||||
candidates.push(
|
||||
enrichCandidateFromNodeProperties(candidate, embeddedModels)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
@@ -231,9 +272,18 @@ export async function enrichWithEmbeddedMetadata(
|
||||
// model — not merely because any unrelated active node exists. A
|
||||
// reference is any widget value (or node.properties.models entry)
|
||||
// that matches the model name on an active node.
|
||||
// Hoist the id→node map once; isModelReferencedByActiveNode would
|
||||
// otherwise rebuild it on every unmatched entry.
|
||||
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
|
||||
const activeUnmatched = unmatched.filter(
|
||||
(m) =>
|
||||
m.sourceNodeType !== '' || isModelReferencedByActiveNode(m.name, allNodes)
|
||||
m.sourceNodeType !== '' ||
|
||||
isModelReferencedByActiveNode(
|
||||
m.name,
|
||||
m.directory,
|
||||
allNodes,
|
||||
flattenedNodeById
|
||||
)
|
||||
)
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
@@ -276,7 +326,9 @@ export async function enrichWithEmbeddedMetadata(
|
||||
|
||||
function isModelReferencedByActiveNode(
|
||||
modelName: string,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>
|
||||
modelDirectory: string | undefined,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
@@ -284,12 +336,30 @@ function isModelReferencedByActiveNode(
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
|
||||
|
||||
// Require directory agreement when both sides specify one, so a
|
||||
// same-name entry under a different folder does not keep an
|
||||
// unrelated workflow-level model alive as missing.
|
||||
const embeddedModels = (
|
||||
node.properties as { models?: Array<{ name: string }> } | undefined
|
||||
node.properties as
|
||||
| { models?: Array<{ name: string; directory?: string }> }
|
||||
| undefined
|
||||
)?.models
|
||||
if (embeddedModels?.some((m) => m.name === modelName)) return true
|
||||
if (
|
||||
embeddedModels?.some(
|
||||
(m) =>
|
||||
m.name === modelName &&
|
||||
(modelDirectory === undefined ||
|
||||
m.directory === undefined ||
|
||||
m.directory === modelDirectory)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// widgets_values carries only the name, so directory cannot be
|
||||
// checked here — fall back to filename matching.
|
||||
const values = node.widgets_values
|
||||
if (!values) continue
|
||||
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
||||
@@ -300,6 +370,22 @@ function isModelReferencedByActiveNode(
|
||||
return false
|
||||
}
|
||||
|
||||
function isAncestorPathActiveInFlattened(
|
||||
executionId: string,
|
||||
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
|
||||
): boolean {
|
||||
for (const ancestorId of getParentExecutionIds(executionId)) {
|
||||
const ancestor = nodeById.get(ancestorId)
|
||||
if (!ancestor) continue
|
||||
if (
|
||||
ancestor.mode === LGraphEventMode.NEVER ||
|
||||
ancestor.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: ComfyWorkflowJSON
|
||||
|
||||
@@ -39,7 +39,7 @@ const existingOutput = computed(() => {
|
||||
<div
|
||||
v-else-if="hasOutputs"
|
||||
role="article"
|
||||
data-testid="arrange-preview"
|
||||
data-testid="linear-arrange-preview"
|
||||
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
|
||||
>
|
||||
<div
|
||||
@@ -54,7 +54,7 @@ const existingOutput = computed(() => {
|
||||
<div
|
||||
v-else
|
||||
role="article"
|
||||
data-testid="arrange-no-outputs"
|
||||
data-testid="linear-arrange-no-outputs"
|
||||
class="mx-auto flex h-full w-lg flex-col items-center justify-center gap-6 p-8 text-center"
|
||||
>
|
||||
<p class="m-0 text-base-foreground">
|
||||
@@ -75,7 +75,12 @@ const existingOutput = computed(() => {
|
||||
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
data-testid="linear-arrange-switch-to-outputs"
|
||||
@click="setMode('builder:outputs')"
|
||||
>
|
||||
{{ t('linearMode.arrange.switchToOutputsButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,8 @@ import {
|
||||
collectAllNodes,
|
||||
forEachNode,
|
||||
getNodeByExecutionId,
|
||||
isAncestorPathActive,
|
||||
isMissingCandidateActive,
|
||||
triggerCallbackOnAllNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
@@ -1436,10 +1438,21 @@ export class ComfyApp {
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
// Drop missing-node entries whose enclosing subgraph is
|
||||
// muted/bypassed. The initial JSON scan only checks each node's
|
||||
// own mode; the cascade from an inactive container is applied here
|
||||
// using the now-configured live graph.
|
||||
const activeMissingNodeTypes = missingNodeTypes.filter(
|
||||
(n) =>
|
||||
typeof n === 'string' ||
|
||||
n.nodeId == null ||
|
||||
isAncestorPathActive(this.rootGraph, String(n.nodeId))
|
||||
)
|
||||
|
||||
if (!skipAssetScans) {
|
||||
await this.runMissingModelPipeline(
|
||||
graphData,
|
||||
missingNodeTypes,
|
||||
activeMissingNodeTypes,
|
||||
silentAssetErrors
|
||||
)
|
||||
|
||||
@@ -1482,7 +1495,7 @@ export class ComfyApp {
|
||||
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
const enrichedCandidates = await enrichWithEmbeddedMetadata(
|
||||
const enrichedAll = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
async (name, directory) => {
|
||||
@@ -1498,6 +1511,19 @@ export class ComfyApp {
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
|
||||
// scans only checked each node's own mode; the cascade from an
|
||||
// inactive container to its interior happens here.
|
||||
// Asymmetric on purpose: a candidate dropped here is not resurrected if
|
||||
// the user un-bypasses the container mid-verification. The realtime
|
||||
// mode-change path (handleNodeModeChange → scanAndAddNodeErrors) is
|
||||
// responsible for surfacing errors after an un-bypass.
|
||||
const enrichedCandidates = enrichedAll.filter(
|
||||
(c) =>
|
||||
c.nodeId == null ||
|
||||
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
||||
)
|
||||
|
||||
const missingModels: ModelFile[] = enrichedCandidates
|
||||
.filter((c) => c.isMissing === true && c.url)
|
||||
.map((c) => ({
|
||||
@@ -1535,8 +1561,10 @@ export class ComfyApp {
|
||||
)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = enrichedCandidates.filter(
|
||||
(c) => c.isMissing === true
|
||||
// Re-check ancestor: user may have bypassed a container
|
||||
// while verification was in flight.
|
||||
const confirmed = enrichedCandidates.filter((c) =>
|
||||
isMissingCandidateActive(this.rootGraph, c)
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed, {
|
||||
@@ -1643,7 +1671,11 @@ export class ComfyApp {
|
||||
): Promise<void> {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
// Drop candidates whose enclosing subgraph is muted/bypassed.
|
||||
const candidates = allCandidates.filter((c) =>
|
||||
isAncestorPathActive(this.rootGraph, String(c.nodeId))
|
||||
)
|
||||
|
||||
if (!candidates.length) {
|
||||
this.cacheMediaCandidates(activeWf, [])
|
||||
@@ -1655,7 +1687,10 @@ export class ComfyApp {
|
||||
void verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
// Re-check ancestor after async verification (see model pipeline).
|
||||
const confirmed = candidates.filter((c) =>
|
||||
isMissingCandidateActive(this.rootGraph, c)
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
||||
}
|
||||
|
||||
@@ -29,8 +29,11 @@ import {
|
||||
triggerCallbackOnAllNodes,
|
||||
visitGraphNodes,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph
|
||||
getExecutionIdForNodeInGraph,
|
||||
isAncestorPathActive,
|
||||
isMissingCandidateActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import { createMockLGraphNode } from './__tests__/litegraphTestUtils'
|
||||
|
||||
@@ -723,6 +726,141 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAncestorPathActive', () => {
|
||||
function makeActiveSubgraph(id: string, nodes: LGraphNode[]) {
|
||||
return createMockSubgraph(id, nodes)
|
||||
}
|
||||
|
||||
it('returns true for root-level nodes (no ancestors)', () => {
|
||||
const node = createMockNode('42')
|
||||
const rootGraph = createMockGraph([node])
|
||||
expect(isAncestorPathActive(rootGraph, '42')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when all ancestor containers are active', () => {
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = makeActiveSubgraph('sub', [interior])
|
||||
const container = createMockNode('65', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
// container mode defaults to ALWAYS (active)
|
||||
const rootGraph = createMockGraph([container])
|
||||
|
||||
expect(isAncestorPathActive(rootGraph, '65:63')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the immediate parent container is bypassed', () => {
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = makeActiveSubgraph('sub', [interior])
|
||||
const container = createMockLGraphNode({
|
||||
id: 65,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph,
|
||||
mode: LGraphEventMode.BYPASS
|
||||
}) satisfies Partial<LGraphNode> as LGraphNode
|
||||
const rootGraph = createMockGraph([container])
|
||||
|
||||
expect(isAncestorPathActive(rootGraph, '65:63')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when an outer ancestor is muted (deeply nested)', () => {
|
||||
const interior = createMockNode('999')
|
||||
const deep = makeActiveSubgraph('deep', [interior])
|
||||
const midNode = createMockNode('456', {
|
||||
isSubgraph: true,
|
||||
subgraph: deep
|
||||
})
|
||||
const mid = makeActiveSubgraph('mid', [midNode])
|
||||
const topNode = createMockLGraphNode({
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mid,
|
||||
mode: LGraphEventMode.NEVER
|
||||
}) satisfies Partial<LGraphNode> as LGraphNode
|
||||
const rootGraph = createMockGraph([topNode])
|
||||
|
||||
expect(isAncestorPathActive(rootGraph, '123:456:999')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when ancestor node cannot be resolved (defensive)', () => {
|
||||
const rootGraph = createMockGraph([])
|
||||
// Unknown ancestor ID "99" — not found, treated as active.
|
||||
expect(isAncestorPathActive(rootGraph, '99:63')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when rootGraph is null/undefined', () => {
|
||||
expect(isAncestorPathActive(null, '65:63')).toBe(true)
|
||||
expect(isAncestorPathActive(undefined, '65:63')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMissingCandidateActive', () => {
|
||||
function makeBypassedContainer(interiorId: string) {
|
||||
const interior = createMockNode(interiorId)
|
||||
const subgraph = createMockSubgraph('sub', [interior])
|
||||
const container = createMockLGraphNode({
|
||||
id: 65,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph,
|
||||
mode: LGraphEventMode.BYPASS
|
||||
}) satisfies Partial<LGraphNode> as LGraphNode
|
||||
return createMockGraph([container])
|
||||
}
|
||||
|
||||
it('surfaces confirmed missing candidates under active ancestors', () => {
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = createMockSubgraph('sub', [interior])
|
||||
const container = createMockNode('65', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
const rootGraph = createMockGraph([container])
|
||||
expect(
|
||||
isMissingCandidateActive(rootGraph, {
|
||||
nodeId: '65:63',
|
||||
isMissing: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('drops confirmed missing candidates whose ancestor is bypassed (cloud .then race)', () => {
|
||||
// Mirrors the reopen gap: pipeline-start filter passed, then
|
||||
// the user bypassed the container during verification, and the
|
||||
// async resolver must not resurface the candidate.
|
||||
const rootGraph = makeBypassedContainer('63')
|
||||
expect(
|
||||
isMissingCandidateActive(rootGraph, {
|
||||
nodeId: '65:63',
|
||||
isMissing: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('drops unverified candidates (isMissing !== true)', () => {
|
||||
const rootGraph = createMockGraph([])
|
||||
expect(
|
||||
isMissingCandidateActive(rootGraph, {
|
||||
nodeId: '1',
|
||||
isMissing: undefined
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isMissingCandidateActive(rootGraph, { nodeId: '1', isMissing: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps workflow-level candidates (nodeId == null) when confirmed missing', () => {
|
||||
const rootGraph = makeBypassedContainer('63')
|
||||
expect(
|
||||
isMissingCandidateActive(rootGraph, {
|
||||
nodeId: undefined,
|
||||
isMissing: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExecutionIdFromNodeData', () => {
|
||||
it('should return the correct execution ID for a normal node', () => {
|
||||
const node = createMockNode('123')
|
||||
|
||||
@@ -3,9 +3,11 @@ import type {
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeLocatorId,
|
||||
getParentExecutionIds,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
@@ -362,6 +364,58 @@ export function getExecutionIdByNode(
|
||||
return `${parentPath}:${node.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every ancestor container in the execution path is active
|
||||
* (not muted, not bypassed). Self is not checked — caller is expected to
|
||||
* have already verified the target node's own mode.
|
||||
*
|
||||
* For root-level nodes (single-segment execution ID) there are no
|
||||
* ancestors and the result is always true.
|
||||
*
|
||||
* Use after an initial full-graph scan to suppress missing-asset entries
|
||||
* whose enclosing subgraph is muted/bypassed. At scan time only each
|
||||
* node's own mode is checked; ancestor context is applied here so the
|
||||
* effect cascades to interior nodes without requiring every scanner to
|
||||
* carry the ancestor flag.
|
||||
*/
|
||||
export function isAncestorPathActive(
|
||||
rootGraph: LGraph | null | undefined,
|
||||
executionId: string
|
||||
): boolean {
|
||||
if (!rootGraph) return true
|
||||
for (const ancestorId of getParentExecutionIds(executionId)) {
|
||||
const ancestor = getNodeByExecutionId(rootGraph, ancestorId)
|
||||
if (!ancestor) continue
|
||||
if (
|
||||
ancestor.mode === LGraphEventMode.NEVER ||
|
||||
ancestor.mode === LGraphEventMode.BYPASS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate used after async verification resolves: a missing-asset
|
||||
* candidate is surfaceable when it is confirmed missing and its
|
||||
* enclosing subgraph is still active. Null `nodeId` (workflow-level
|
||||
* models) bypasses the ancestor check since it has no scope to
|
||||
* validate. Unified helper so the initial pipeline post-filter and the
|
||||
* three async-resolution call sites cannot drift.
|
||||
*/
|
||||
export function isMissingCandidateActive(
|
||||
rootGraph: LGraph | null | undefined,
|
||||
candidate: {
|
||||
nodeId?: string | number | null | undefined
|
||||
isMissing?: boolean | undefined
|
||||
}
|
||||
): boolean {
|
||||
if (candidate.isMissing !== true) return false
|
||||
if (candidate.nodeId == null) return true
|
||||
return isAncestorPathActive(rootGraph, String(candidate.nodeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the execution ID for a node identified by its (graph, nodeId) pair.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user