Compare commits

..

3 Commits

Author SHA1 Message Date
Yourz
b5eb30f437 feat: add use case section images with blob clip-paths and crossfade transitions 2026-04-15 22:53:09 +08:00
pythongosssss
06686a1f50 test: App mode - additional app mode coverage (#11194)
## Summary

Adds additional test coverage for empty state/welcome screen/connect
outputs/vue nodes auto switch

## Changes

- **What**: 
- add tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11194-test-App-mode-additional-app-mode-coverage-3416d73d365081ca91d0ed61de19f840)
by [Unito](https://www.unito.io)
2026-04-15 11:42:22 +00:00
jaeone94
693b8383d6 fix: missing-asset correctness follow-ups from #10856 (#11233)
Follow-up to #10856. Four correctness issues and their regression tests.

## Bugs fixed

### 1. ErrorOverlay model count reflected node selection

`useErrorGroups` exposed `filteredMissingModelGroups` under the public
name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute
its model count label, so selecting a node shrank the overlay total. The
overlay must always show the whole workflow's errors.

Exposed both shapes explicitly: `missingModelGroups` /
`missingMediaGroups` (unfiltered totals) and
`filteredMissingModelGroups` / `filteredMissingMediaGroups`
(selection-scoped). `TabErrors.vue` destructures the filtered variant
with an alias.


Before 


https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003

After 


https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120



### 2. Bypass → un-bypass dropped url/hash metadata

Realtime `scanNodeModelCandidates` only reads widget values, so
un-bypass produced a fresh candidate without the url that
`enrichWithEmbeddedMetadata` had previously attached from
`graphData.models`. `MissingModelRow`'s download/copy-url buttons
disappeared after a bypass/un-bypass cycle.

Added `enrichCandidateFromNodeProperties` that copies
`url`/`hash`/`directory` from the node's own `properties.models` — which
persists across mode toggles — into each scanned candidate. Applied to
every call site of the per-node scan. A later fix in the same branch
also enforces directory agreement to prevent a same-name /
different-directory collision from stamping the wrong metadata.

Before 


https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b

After 


https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833



### 3. Initial full scan surfaced interior errors of a muted/bypassed
subgraph container

`scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based
missing-node scan only check each node's own mode. Interior nodes whose
parent container was bypassed passed the filter.

Added `isAncestorPathActive(rootGraph, executionId)` to
`graphTraversalUtil` and post-filter the three pipelines in `app.ts`
after the live rootGraph is configured. The filter uses the execution-ID
path (`"65:63"` → check node 65's mode) so it handles both
live-scan-produced and JSON-enrichment-produced candidates.

Before


https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602

After 


https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8




### 4. Bypassed subgraph entry re-surfaced interior errors

`useGraphNodeManager` replays `graph.onNodeAdded` for each existing
interior node when the Vue node manager initializes on subgraph entry.
That chain reached `scanSingleNodeErrors` via
`installErrorClearingHooks`' `onNodeAdded` override. Each interior
node's own mode was active, so the caller guards passed and the scan
re-introduced the error that the initial pipeline had correctly
suppressed.

Added an ancestor-activity gate at the top of `scanSingleNodeErrors`,
the single entry point shared by paste, un-bypass, subgraph entry, and
subgraph container activation. A later commit also hardens this guard
against detached nodes (null execution ID → skip) and applies the same
ancestor check to `isCandidateStillActive` in the realtime verification
callback.

Before


https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441

After


https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4



## Tests

- 6 unit tests for `isAncestorPathActive` (root, active,
immediate-bypass, deep-nested mute, unresolvable ancestor, null
rootGraph)
- 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment,
no-overwrite, name mismatch, directory mismatch)
- 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry
replaying onNodeAdded)
- 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract
- 4 E2E tests:
- ErrorOverlay model count stays constant when a node is selected (new
fixture `missing_models_distinct.json`)
- Bypass/un-bypass cycle preserves Copy URL button (uses
`missing_models_from_node_properties`)
- Loading a workflow with bypassed subgraph suppresses interior missing
model error (new fixture `missing_models_in_bypassed_subgraph.json`)
- Entering a bypassed subgraph does not resurface interior missing model
error (shares the above fixture)

`pnpm typecheck`, `pnpm lint`, 206 related unit tests passing.

## Follow-up

Several items raised by code review are deferred as pre-existing tech
debt or scope-avoided refactors. Tracked via comments on #11215 and
#11216.

---
Follows up on #10856.
2026-04-15 10:58:24 +00:00
40 changed files with 1618 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, useId } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -11,11 +11,31 @@ import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = [
t('useCase.vfx', locale),
t('useCase.advertising', locale),
t('useCase.gaming', locale),
t('useCase.ecommerce', locale),
t('useCase.more', locale)
{
label: t('useCase.vfx', locale),
leftImg: '/images/homepage/use-case-left-1.webp',
rightImg: '/images/homepage/use-case-right-1.webp'
},
{
label: t('useCase.advertising', locale),
leftImg: '/images/homepage/use-case-left-2.webp',
rightImg: '/images/homepage/use-case-right-2.webp'
},
{
label: t('useCase.gaming', locale),
leftImg: '/images/homepage/use-case-left-3.webp',
rightImg: '/images/homepage/use-case-right-3.webp'
},
{
label: t('useCase.ecommerce', locale),
leftImg: '/images/homepage/use-case-left-4.webp',
rightImg: '/images/homepage/use-case-right-4.webp'
},
{
label: t('useCase.more', locale),
leftImg: '/images/homepage/use-case-left-5.webp',
rightImg: '/images/homepage/use-case-right-5.webp'
}
]
const sectionRef = ref<HTMLElement>()
@@ -29,6 +49,14 @@ const { activeIndex: activeCategory } = usePinScrub(
{ itemCount: categories.length }
)
const activeLeft = computed(() => categories[activeCategory.value].leftImg)
const activeRight = computed(() => categories[activeCategory.value].rightImg)
const activeLabel = computed(() => categories[activeCategory.value].label)
const uid = useId()
const leftBlobId = `left-blob-${uid}`
const rightBlobId = `right-blob-${uid}`
useParallax([leftImgRef, rightImgRef], { trigger: sectionRef })
</script>
@@ -37,16 +65,54 @@ useParallax([leftImgRef, rightImgRef], { trigger: sectionRef })
ref="sectionRef"
class="bg-primary-comfy-ink relative flex flex-col items-center overflow-hidden px-8 py-20 lg:h-screen lg:px-0 lg:py-24"
>
<!-- Clip-path definitions for shaped images -->
<svg class="absolute" width="0" height="0" aria-hidden="true">
<defs>
<clipPath :id="leftBlobId" clipPathUnits="objectBoundingBox">
<path
d="M0.314,0.988 C0.337,0.997 0.366,0.999 0.398,0.993 L0.600,0.949 L0.877,0.890 C0.945,0.876 1.000,0.828 1.000,0.784 L1.000,0.206 L1.000,0.195 L0.999,0.061 C0.999,0.040 0.986,0.021 0.962,0.011 C0.939,0.001 0.910,-0.001 0.879,0.007 L0.675,0.050 L0.398,0.109 C0.331,0.123 0.277,0.171 0.277,0.215 L0.277,0.314 C0.277,0.324 0.266,0.333 0.251,0.337 L0.121,0.365 C0.054,0.379 0.000,0.427 0.000,0.471 L0.000,0.504 L0.000,0.802 C0.000,0.823 0.014,0.841 0.037,0.851 C0.060,0.861 0.089,0.863 0.121,0.856 L0.229,0.833 C0.240,0.830 0.252,0.831 0.261,0.835 C0.270,0.839 0.275,0.845 0.275,0.852 L0.276,0.939 C0.276,0.960 0.289,0.978 0.314,0.988 Z"
/>
</clipPath>
<clipPath :id="rightBlobId" clipPathUnits="objectBoundingBox">
<path
d="M1,0.129 L0.187,0.005 C0.084,0 0,0.015 0,0.066 L0,0.104 L0,0.447 C0,0.472 0.022,0.500 0.058,0.523 C0.094,0.547 0.139,0.563 0.188,0.571 L0.356,0.599 C0.373,0.602 0.391,0.609 0.405,0.618 C0.419,0.627 0.427,0.637 0.427,0.645 L0.427,0.745 C0.427,0.770 0.448,0.798 0.485,0.821 C0.521,0.845 0.566,0.861 0.615,0.869 L0.734,0.890 L0.934,0.923 L1,0.934 Z"
/>
</clipPath>
</defs>
</svg>
<!-- Left image -->
<div
ref="leftImgRef"
class="bg-primary-comfy-canvas/20 rounded-r-5xl absolute top-80 left-0 h-40 w-1/8 lg:h-160 lg:w-1/4"
/>
<!-- Right image -->
class="absolute top-80 left-0 h-50 w-1/4 -translate-x-2/5 overflow-hidden lg:h-240 lg:max-h-3/4"
:style="`clip-path: url(#${leftBlobId})`"
>
<Transition name="crossfade">
<img
:key="activeLeft"
:src="activeLeft"
:alt="activeLabel"
class="absolute inset-0 size-full object-cover"
loading="lazy"
decoding="async"
/>
</Transition>
</div>
<div
ref="rightImgRef"
class="bg-primary-comfy-canvas/20 rounded-l-5xl absolute top-30 right-0 h-40 w-1/8 lg:h-160 lg:w-1/4"
/>
class="absolute top-30 right-0 h-50 w-1/4 translate-x-2/5 overflow-hidden lg:h-240 lg:max-h-3/4"
:style="`clip-path: url(#${rightBlobId})`"
>
<Transition name="crossfade">
<img
:key="activeRight"
:src="activeRight"
:alt="activeLabel"
class="absolute inset-0 size-full object-cover"
loading="lazy"
decoding="async"
/>
</Transition>
</div>
<div
ref="contentRef"
class="flex flex-col items-center will-change-transform"
@@ -64,7 +130,7 @@ useParallax([leftImgRef, rightImgRef], { trigger: sectionRef })
>
<button
v-for="(category, index) in categories"
:key="category"
:key="category.label"
class="lg:text-6.5xl cursor-pointer text-center text-4xl font-light whitespace-pre-line transition-colors"
:class="
index === activeCategory
@@ -73,7 +139,7 @@ useParallax([leftImgRef, rightImgRef], { trigger: sectionRef })
"
@click="activeCategory = index"
>
{{ category }}
{{ category.label }}
</button>
</nav>

View File

@@ -103,6 +103,16 @@
animation: marquee-reverse 30s linear infinite;
}
.crossfade-enter-active,
.crossfade-leave-active {
transition: opacity 0.3s ease;
}
.crossfade-enter-from,
.crossfade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View 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
}

View File

@@ -34,7 +34,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
]
},

View File

@@ -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
}

View File

@@ -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
*/

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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'

View 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'
)
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})
})

View File

@@ -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', () => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -293,8 +293,8 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)

View File

@@ -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
)
})
})
})

View File

@@ -833,8 +833,10 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups: filteredMissingModelGroups,
missingMediaGroups: filteredMissingMediaGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups,
filteredMissingMediaGroups,
swapNodeGroups
}
}

View File

@@ -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', () => {

View File

@@ -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)
)

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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)', () => {

View File

@@ -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

View File

@@ -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>

View File

@@ -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 })
}

View File

@@ -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')

View File

@@ -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.
*