Compare commits
3 Commits
regen-scre
...
feat/use-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5eb30f437 | ||
|
|
06686a1f50 | ||
|
|
693b8383d6 |
BIN
apps/website/public/images/homepage/use-case-left-1.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/website/public/images/homepage/use-case-left-2.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/website/public/images/homepage/use-case-left-3.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/homepage/use-case-left-4.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/homepage/use-case-left-5.webp
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
apps/website/public/images/homepage/use-case-right-1.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/website/public/images/homepage/use-case-right-2.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/website/public/images/homepage/use-case-right-3.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
apps/website/public/images/homepage/use-case-right-4.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/homepage/use-case-right-5.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||