Compare commits

..

1 Commits

Author SHA1 Message Date
Kelly Yang
cdb9b16574 test: regenerate screenshot expectations 2026-04-14 21:15:10 -07:00
93 changed files with 524 additions and 3759 deletions

View File

@@ -1,66 +0,0 @@
{
"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": "checkpoints"
"directory": "text_encoders"
}
]
},

View File

@@ -1,141 +0,0 @@
{
"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

@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
import { sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
@@ -336,7 +336,9 @@ export class ComfyPage {
}
async nextFrame() {
await nextFrame(this.page)
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async delay(ms: number) {
@@ -391,27 +393,6 @@ export class ComfyPage {
return this.page.locator('.dom-widget')
}
async expectScreenshot(
locator: Locator,
name: string | string[],
options?: {
animations?: 'disabled' | 'allow'
caret?: 'hide' | 'initial'
mask?: Array<Locator>
maskColor?: string
maxDiffPixelRatio?: number
maxDiffPixels?: number
omitBackground?: boolean
scale?: 'css' | 'device'
stylePath?: string | Array<string>
threshold?: number
timeout?: number
}
): Promise<void> {
await this.nextFrame()
await comfyExpect(locator).toHaveScreenshot(name, options)
}
async setFocusMode(focusMode: boolean) {
await this.page.evaluate((focusMode) => {
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode

View File

@@ -139,27 +139,6 @@ 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,17 +17,8 @@ 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). */
@@ -48,18 +39,6 @@ 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)
@@ -68,22 +47,9 @@ 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
)
@@ -109,22 +75,6 @@ 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 {
@@ -142,33 +92,8 @@ 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() {
// Wait for any workflow-tab popover to dismiss before clicking —
// the popover overlay can intercept the "Workflow actions" click.
// Best-effort: the popover may or may not exist; if it stays visible
// past the timeout we still proceed with the click.
await this.page
.locator('.workflow-popover-fade')
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
@@ -183,6 +108,7 @@ export class AppModeHelper {
async toggleAppMode() {
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
/**

View File

@@ -13,30 +13,18 @@ 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.inputsButton.click()
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
async goToOutputs() {
await this.outputsButton.click()
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
async goToPreview() {
await this.previewButton.click()
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -2,7 +2,6 @@ import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position } from '@e2e/fixtures/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CanvasHelper {
constructor(
@@ -11,12 +10,18 @@ export class CanvasHelper {
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await nextFrame(this.page)
await this.nextFrame()
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
@@ -24,7 +29,7 @@ export class CanvasHelper {
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await nextFrame(this.page)
await this.nextFrame()
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
@@ -33,7 +38,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await nextFrame(this.page)
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
@@ -51,22 +56,22 @@ export class CanvasHelper {
type: 'touchEnd',
touchPoints: []
})
await nextFrame(this.page)
await this.nextFrame()
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await nextFrame(this.page)
await this.nextFrame()
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await nextFrame(this.page)
await this.nextFrame()
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await nextFrame(this.page)
await this.nextFrame()
}
/**
@@ -102,7 +107,7 @@ export class CanvasHelper {
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await nextFrame(this.page)
await this.nextFrame()
}
/**
@@ -111,12 +116,12 @@ export class CanvasHelper {
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await nextFrame(this.page)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await nextFrame(this.page)
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
@@ -124,7 +129,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await nextFrame(this.page)
await this.nextFrame()
}
async moveMouseToEmptyArea(): Promise<void> {
@@ -147,7 +152,7 @@ export class CanvasHelper {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await nextFrame(this.page)
await this.nextFrame()
}
async convertOffsetToCanvas(
@@ -231,12 +236,12 @@ export class CanvasHelper {
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await nextFrame(this.page)
await this.nextFrame()
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await nextFrame(this.page)
await this.nextFrame()
}
await this.page.mouse.up({ button: 'middle' })

View File

@@ -1,7 +1,6 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '@/platform/keybindings/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CommandHelper {
constructor(private readonly page: Page) {}
@@ -21,7 +20,6 @@ export class CommandHelper {
},
{ commandId, metadata }
)
await nextFrame(this.page)
}
async registerCommand(

View File

@@ -5,11 +5,18 @@ import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class DragDropHelper {
constructor(private readonly page: Page) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
@@ -138,7 +145,7 @@ export class DragDropHelper {
await uploadResponsePromise
}
await nextFrame(this.page)
await this.nextFrame()
}
async dragAndDropFile(

View File

@@ -1,21 +1,13 @@
import type { Locator, Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
async press(key: string, locator?: Locator | null): Promise<void> {
const target = locator ?? this.canvas
await target.press(key)
await nextFrame(this.page)
}
async delete(locator?: Locator | null): Promise<void> {
await this.press('Delete', locator)
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
}
async ctrlSend(
@@ -24,7 +16,7 @@ export class KeyboardHelper {
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await nextFrame(this.page)
await this.nextFrame()
}
async selectAll(locator?: Locator | null): Promise<void> {

View File

@@ -140,11 +140,13 @@ export class NodeOperationsHelper {
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
@@ -156,6 +158,7 @@ export class NodeOperationsHelper {
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
async fillPromptDialog(value: string): Promise<void> {
@@ -189,6 +192,7 @@ export class NodeOperationsHelper {
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {

View File

@@ -1,7 +1,5 @@
import type { Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class SettingsHelper {
constructor(private readonly page: Page) {}
@@ -12,7 +10,6 @@ export class SettingsHelper {
},
{ id: settingId, value: settingValue }
)
await nextFrame(this.page)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {

View File

@@ -465,7 +465,11 @@ export class SubgraphHelper {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
@@ -473,12 +477,14 @@ export class SubgraphHelper {
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,

View File

@@ -70,19 +70,10 @@ export class WorkflowHelper {
)
}
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
await this.comfyPage.page.evaluate(
(wf) => window.app!.loadGraphData(wf),
workflow
)
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
)
await this.waitForWorkflowIdle()
await this.comfyPage.nextFrame()
if (test.info().tags.includes('@vue-nodes')) {
await this.comfyPage.vueNodes.waitForNodes()

View File

@@ -137,11 +137,7 @@ export const TestIds = {
widgetItem: 'builder-widget-item',
widgetLabel: 'builder-widget-label',
outputPlaceholder: 'builder-output-placeholder',
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'
connectOutputPopover: 'builder-connect-output-popover'
},
outputHistory: {
outputs: 'linear-outputs',
@@ -167,13 +163,7 @@ export const TestIds = {
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
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'
loadTemplate: 'linear-welcome-load-template'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
@@ -198,16 +188,6 @@ export const TestIds = {
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
batchNav: 'batch-nav',
beforeBatch: 'before-batch',
afterBatch: 'after-batch',
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
}
} as const
@@ -241,4 +221,3 @@ export type TestIdValue =
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -388,6 +388,7 @@ export class NodeReference {
async copy() {
await this.click('title')
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
@@ -433,6 +434,7 @@ export class NodeReference {
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)

View File

@@ -1,9 +1,3 @@
import type { Page } from '@playwright/test'
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function nextFrame(page: Page): Promise<number> {
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
}

View File

@@ -1,70 +0,0 @@
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

@@ -1,84 +0,0 @@
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,7 +6,6 @@ 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 ({
@@ -59,37 +58,4 @@ 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

@@ -157,10 +157,7 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
test('Reordering inputs in one app does not corrupt another app', async ({
comfyPage
}, testInfo) => {
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
// times — the default 15s timeout is insufficient in CI.
testInfo.setTimeout(45_000)
}) => {
const { appMode } = comfyPage
const app2Widgets = ['seed', 'steps']
const app1Reordered = ['steps', 'cfg', 'seed']

View File

@@ -110,7 +110,8 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})

View File

@@ -29,6 +29,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
await comfyPage.nextFrame()
})
test.describe('Trigger button', () => {
@@ -45,6 +46,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(mode.iconPattern)
@@ -101,6 +103,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
}) => {
if (!mode.isReadOnly) {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
}
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
@@ -153,6 +156,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -204,6 +208,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -224,7 +229,8 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts unlocked'
).toBe(false)
await comfyPage.keyboard.press('KeyH')
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
@@ -235,11 +241,13 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts locked'
).toBe(true)
await comfyPage.keyboard.press('KeyV')
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()

View File

@@ -223,7 +223,8 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.keyboard.press('KeyP')
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await expect(node).toBePinned()
await afterChange(comfyPage)
}

View File

@@ -74,23 +74,18 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
return node!.id
})
// Wait for the asset widget to mount AND its value to resolve.
// The widget type becomes 'asset' before the value is populated,
// so poll for both conditions together to avoid a race where the
// type check passes but the value is still the placeholder.
await expect
.poll(
() =>
comfyPage.page.evaluate((id) => {
async () => {
return await comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
{ timeout: 15_000 }
return String(widget?.value ?? '')
}, nodeId)
},
{ timeout: 10_000 }
)
.toBe(CLOUD_ASSETS[0].name)
})

View File

@@ -157,15 +157,18 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
@@ -178,6 +181,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -186,6 +190,7 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -207,12 +212,15 @@ test.describe(
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
@@ -221,8 +229,8 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.page.mouse.move(0, 0)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
})
@@ -232,6 +240,7 @@ test.describe(
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
@@ -270,6 +279,7 @@ test.describe(
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-lightened-colors.png'
)

View File

@@ -155,6 +155,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,

View File

@@ -52,7 +52,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.keyboard.press('Alt+Equal')
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -62,7 +63,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.keyboard.press('Alt+Minus')
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -80,7 +82,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Period')
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -90,7 +93,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'h' locks canvas", async ({ comfyPage }) => {
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.keyboard.press('KeyH')
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
})
@@ -98,9 +102,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.keyboard.press('KeyV')
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
@@ -115,13 +121,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.keyboard.press('Alt+KeyC')
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.keyboard.press('Alt+KeyC')
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(false)
})
@@ -131,6 +140,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
@@ -140,11 +150,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect.poll(() => getMode()).toBe(0)
await comfyPage.keyboard.press('Control+KeyM')
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
await expect.poll(() => getMode()).toBe(2)
await comfyPage.keyboard.press('Control+KeyM')
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await expect.poll(() => getMode()).toBe(0)
})
})
@@ -227,14 +239,16 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.keyboard.press('Control+s')
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
@@ -251,7 +265,8 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
}
})
await comfyPage.keyboard.press('Control+o')
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
@@ -273,9 +288,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
// Select all nodes
await comfyPage.keyboard.press('Control+a')
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Shift+KeyE')
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)

View File

@@ -145,27 +145,15 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Wait for the search filter to fully settle — PrimeVue re-renders
// the entire settings list after typing, and the combobox element is
// replaced during re-render. Wait until the filtered list stabilises
// before interacting with the combobox.
const settingItems = dialog.root.locator('[data-setting-id]')
await expect
.poll(() => settingItems.count(), { timeout: 5000 })
.toBeLessThanOrEqual(5)
const select = settingRow.getByRole('combobox')
await expect(select).toBeVisible()
await expect(select).toBeEnabled()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may still re-render after the
// filter settles, causing the first click to land on a stale element.
// Retry because the PrimeVue Select may re-render during search
// filtering, causing the first click to land on a stale element.
const select = settingRow.getByRole('combobox')
await expect(async () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 10_000 })
}).toPass({ timeout: 5000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

@@ -214,34 +214,4 @@ 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

@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
.toBe(hiddenLinkRenderMode)
await button.click()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'canvas-with-visible-links.png'
)
await expect

View File

@@ -170,6 +170,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
@@ -335,9 +336,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.workflow.loadGraphData(
currentGraphState as ComfyWorkflowJSON
await comfyPage.page.evaluate(
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
currentGraphState
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})

View File

@@ -60,6 +60,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -83,6 +84,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -105,6 +107,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')

View File

@@ -3,7 +3,6 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -88,6 +87,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -95,7 +98,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
await expect(node.locator('img')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
}
@@ -123,6 +126,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -157,6 +164,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
@@ -172,7 +183,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
const viewport = node.getByTestId('image-compare-viewport')
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -213,7 +224,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
const viewport = node.getByTestId('image-compare-viewport')
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -250,7 +261,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
const compareArea = node.getByTestId('image-compare-viewport')
await expect(compareArea).toBeVisible()
await expect
@@ -281,6 +292,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBeCloseTo(100, 0)
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
@@ -309,6 +324,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
@@ -323,21 +342,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const beforeBatch = node.getByTestId('before-batch')
await expect(
node.getByTestId(TestIds.imageCompare.batchNav)
).toBeVisible()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 3')
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(
node.getByTestId(TestIds.imageCompare.afterBatch)
).toBeHidden()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeDisabled()
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
}
)
@@ -351,7 +362,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
await expect(node.getByTestId('batch-nav')).toBeHidden()
})
test(
@@ -367,10 +378,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
@@ -396,10 +407,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
await nextBtn.click()
await nextBtn.click()
@@ -425,18 +436,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 3')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
@@ -447,9 +454,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
})
// ---------------------------------------------------------------------------
// Node sizing
// ---------------------------------------------------------------------------
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
if (!graphNode?.size) return null
@@ -463,13 +472,17 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(
size.width,
'ImageCompare node minimum width'
).toBeGreaterThanOrEqual(minWidth)
).toBeGreaterThanOrEqual(400)
expect(
size.height,
'ImageCompare node minimum height'
).toBeGreaterThanOrEqual(minHeight)
).toBeGreaterThanOrEqual(350)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
@@ -487,7 +500,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
const viewport = node.getByTestId('image-compare-viewport')
await waitForImagesLoaded(node)
await expect(viewport).toBeVisible()
await moveToPercentage(comfyPage.page, viewport, pct)
@@ -503,6 +516,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget handles image load failure gracefully', async ({
comfyPage
}) => {
@@ -569,14 +586,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchNext)
.click()
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await expect(
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
@@ -589,9 +601,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
green1Url
)
await expect(
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
node.getByTestId('before-batch').getByTestId('batch-counter')
).toHaveText('1 / 2')
})
@@ -646,35 +656,23 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
const beforeNext = beforeBatch.getByTestId('batch-next')
const afterNext = afterBatch.getByTestId('batch-next')
for (let i = 0; i < 19; i++) {
await beforeNext.click()
await afterNext.click()
}
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(beforeNext).toBeDisabled()
await expect(afterNext).toBeDisabled()
})

View File

@@ -31,9 +31,11 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.keyboard.press('KeyP')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
await comfyPage.keyboard.press('KeyP')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
})
})
@@ -74,11 +76,13 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
}
)
@@ -170,7 +174,8 @@ test.describe('Node Interaction', () => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
maxDiffPixels: 50
})
})
@@ -180,6 +185,7 @@ test.describe('Node Interaction', () => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
@@ -279,6 +285,7 @@ test.describe('Node Interaction', () => {
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
@@ -352,8 +359,8 @@ test.describe('Node Interaction', () => {
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'batch-disconnect-links-disconnected.png'
)
}
@@ -403,8 +410,8 @@ test.describe('Node Interaction', () => {
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
}
@@ -507,7 +514,8 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.keyboard.press('Enter')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
@@ -1163,8 +1171,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-middle-drag-pan.png'
)
})
@@ -1172,14 +1180,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-out.png'
)
})
@@ -1239,8 +1247,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-middle-drag-pan.png'
)
})
@@ -1250,16 +1258,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-out.png'
)
})
@@ -1351,31 +1359,33 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.canvas.click()
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-center.png'
)
})

View File

@@ -112,8 +112,9 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.expectScreenshot(
load3d.node,
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
'load3d-uploaded-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
@@ -141,8 +142,9 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.expectScreenshot(
load3d.node,
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
'load3d-dropped-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)

View File

@@ -143,7 +143,8 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
await comfyPage.nextFrame()
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
}
)

View File

@@ -11,10 +11,8 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.expectScreenshot(
comfyPage.canvas,
'mobile-empty-canvas.png'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
@@ -26,6 +24,7 @@ test.describe(
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
@@ -39,8 +38,9 @@ test.describe(
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.expectScreenshot(
comfyPage.settingDialog.root,
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png',
{
mask: [

View File

@@ -13,6 +13,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
@@ -34,6 +35,7 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -14,6 +14,7 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -52,6 +53,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -108,7 +110,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -121,7 +124,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.keyboard.press('Delete')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -134,7 +138,8 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.keyboard.press('Backspace')
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()

View File

@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'link-release-context-menu.png'
)
}

View File

@@ -113,40 +113,6 @@ 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
}) => {
@@ -510,52 +476,6 @@ 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

@@ -19,10 +19,8 @@ test.describe(
await comfyPage.page.getByText('loaders').click()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-node-node-added.png'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
})
test('Can add group', async ({ comfyPage }) => {
@@ -30,8 +28,8 @@ test.describe(
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'add-group-group-added.png'
)
})
@@ -47,8 +45,8 @@ test.describe(
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'
)
})
@@ -62,11 +60,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel.png'
)
})
@@ -77,11 +76,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed.png'
)
})
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-collapsed-badge.png'
)
})
@@ -116,11 +116,12 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-bypassed.png'
)
})
@@ -132,7 +133,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -147,8 +149,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-pinned-node.png'
)
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
@@ -158,8 +160,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-unpinned-node.png'
)
})
@@ -204,10 +206,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-pinned.png'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'selected-nodes-unpinned.png'
)
})

View File

@@ -11,13 +11,15 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.keyboard.press('Control+a')
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.keyboard.press('Control+a')
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
@@ -68,7 +70,8 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.keyboard.press('Control+a')
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())

View File

@@ -267,7 +267,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
.click()
// Undo the colorization
await comfyPage.keyboard.press('Control+Z')
await comfyPage.page.keyboard.press('Control+Z')
await comfyPage.nextFrame()
// Node should be uncolored again
const selectedNode = (

View File

@@ -32,6 +32,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
@@ -68,6 +69,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -81,6 +83,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -157,6 +160,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -183,6 +187,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
@@ -224,6 +229,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (

View File

@@ -14,6 +14,7 @@ test.describe(
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
@@ -42,6 +43,7 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -39,6 +39,7 @@ test.describe('Sidebar splitter width independence', () => {
location: 'left' | 'right'
) {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
await comfyPage.nextFrame()
await dismissToasts(comfyPage)
await comfyPage.menu.nodeLibraryTab.open()
}

View File

@@ -14,6 +14,7 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea

View File

@@ -37,6 +37,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
@@ -48,7 +49,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
@@ -62,7 +64,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.keyboard.press('Enter')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
@@ -75,6 +78,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
@@ -86,11 +90,13 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
@@ -100,6 +106,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Navigation Hotkeys', () => {
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
@@ -128,6 +135,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -137,7 +145,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
@@ -145,7 +154,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
.toBe(true)
await comfyPage.keyboard.press('Alt+q')
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -153,6 +163,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -172,7 +183,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
@@ -180,7 +192,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -192,6 +205,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
@@ -258,6 +272,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -281,7 +296,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect
.poll(() =>
@@ -296,6 +312,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -311,8 +328,10 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(() =>

View File

@@ -18,6 +18,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

View File

@@ -38,10 +38,13 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+c')
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+v')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())

View File

@@ -102,6 +102,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -149,6 +150,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
@@ -316,6 +318,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
@@ -400,6 +403,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
@@ -451,6 +455,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
await expect
@@ -471,6 +476,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')

View File

@@ -68,6 +68,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
@@ -87,7 +88,8 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()

View File

@@ -15,7 +15,8 @@ async function exitSubgraphAndPublish(
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
blueprintName: string
) {
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {

View File

@@ -40,6 +40,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
@@ -58,6 +59,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await expect
.poll(async () => {
@@ -71,17 +73,20 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -116,6 +121,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,

View File

@@ -424,6 +424,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()

View File

@@ -34,8 +34,9 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
{ message: 'All nodes should be within the visible viewport' }
)
.toBe(true)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'viewport-fits-when-saved-offscreen.png'
)
})

View File

@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-create-group.png'
)
})
@@ -131,6 +131,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)

View File

@@ -24,8 +24,8 @@ test.describe('Vue Node Bypass', { tag: '@vue-nodes' }, () => {
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)

View File

@@ -5,19 +5,17 @@ import {
const MUTE_HOTKEY = 'Control+m'
const MUTE_OPACITY = '0.5'
const SELECTED_CLASS = /outline-node-component-outline/
test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test(
'should allow toggling mute on a selected node with hotkey',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-muted-state.png'
@@ -31,14 +29,12 @@ test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await expect(ksamplerNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)

View File

@@ -1,4 +1,7 @@
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -10,8 +13,8 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.expectScreenshot(
comfyPage.canvas,
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-reroute-node-compact.png'
)
}

View File

@@ -149,6 +149,7 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
@@ -288,8 +289,10 @@ test.describe('Workflow Persistence', () => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
@@ -346,6 +349,7 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate, add a node, then save (unmodified after save)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
@@ -406,6 +410,7 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate and save
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
// Add a Note node in B to mark it as modified
@@ -482,6 +487,7 @@ test.describe('Workflow Persistence', () => {
// Create B as an unsaved workflow with a Note node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))

File diff suppressed because it is too large Load Diff

View File

@@ -47,12 +47,7 @@
</Button>
</PopoverClose>
<PopoverClose as-child>
<Button
variant="secondary"
size="md"
data-testid="builder-connect-output-switch"
@click="emit('switch')"
>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToOutputs') }}
</Button>
</PopoverClose>

View File

@@ -1,8 +1,5 @@
<template>
<BuilderDialog
data-testid="builder-empty-workflow-dialog"
:show-close="false"
>
<BuilderDialog :show-close="false">
<template #title>
{{ $t('builderToolbar.emptyWorkflowTitle') }}
</template>
@@ -20,17 +17,11 @@
<Button
variant="muted-textonly"
size="lg"
data-testid="builder-empty-workflow-back"
@click="$emit('backToWorkflow')"
>
{{ $t('linearMode.backToWorkflow') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="builder-empty-workflow-load-template"
@click="$emit('loadTemplate')"
>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('linearMode.loadTemplate') }}
</Button>
</template>

View File

@@ -1,7 +1,6 @@
<template>
<NotificationPopup
v-if="appModeStore.showVueNodeSwitchPopup"
data-testid="linear-vue-node-switch-popup"
:title="$t('appBuilder.vueNodeSwitch.title')"
show-close
position="bottom-left"
@@ -16,7 +15,6 @@
<input
v-model="dontShowAgain"
type="checkbox"
data-testid="linear-vue-node-switch-dont-show-again"
class="accent-primary-background"
/>
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
@@ -27,7 +25,6 @@
<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,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)

View File

@@ -58,10 +58,8 @@ 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(
@@ -756,48 +754,4 @@ 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,10 +833,8 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
filteredMissingModelGroups,
filteredMissingMediaGroups,
missingModelGroups: filteredMissingModelGroups,
missingMediaGroups: filteredMissingMediaGroups,
swapNodeGroups
}
}

View File

@@ -728,109 +728,6 @@ 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,8 +41,7 @@ import {
collectAllNodes,
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
getNodeByExecutionId,
isAncestorPathActive
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
@@ -173,14 +172,6 @@ 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,
@@ -246,27 +237,16 @@ function scanSingleNodeErrors(node: LGraphNode): void {
*/
function isCandidateStillActive(nodeId: unknown): boolean {
if (!app.rootGraph || nodeId == null) return false
const execId = String(nodeId)
const node = getNodeByExecutionId(app.rootGraph, execId)
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
if (!node) return false
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)
return !isNodeInactive(node.mode)
}
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)
)
@@ -279,10 +259,8 @@ 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

@@ -1,446 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { api } from '@/scripts/api'
import { usePainter } from './usePainter'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
}))
vi.mock('@vueuse/core', () => ({
useElementSize: vi.fn(() => ({
width: ref(512),
height: ref(512)
}))
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/updates/common/toastStore', () => {
const store = { addAlert: vi.fn() }
return { useToastStore: () => store }
})
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
fetchApi: vi.fn()
}
}))
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
getNodeById: vi.fn(() => ({
get widgets() {
return mockWidgets
},
get properties() {
return mockProperties
},
isInputConnected: mockIsInputConnected,
getInputNode: mockGetInputNode
}))
}
}
}
}))
type PainterResult = ReturnType<typeof usePainter>
function makeWidget(name: string, value: unknown = null): IBaseWidget {
return {
name,
value,
callback: vi.fn(),
serializeValue: undefined
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(nodeId = 'test-node', initialModelValue = '') {
let painter!: PainterResult
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursorEl = ref<HTMLElement | null>(null)
const modelValue = ref(initialModelValue)
const Wrapper = defineComponent({
setup() {
painter = usePainter(nodeId, {
canvasEl,
cursorEl,
modelValue
})
return {}
},
render() {
return null
}
})
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockWidgets.length = 0
for (const key of Object.keys(mockProperties)) {
delete mockProperties[key]
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
})
describe('syncCanvasSizeFromWidgets', () => {
it('reads width/height from widget values on initialization', () => {
mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768))
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(1024)
expect(painter.canvasHeight.value).toBe(768)
})
it('defaults to 512 when widgets are missing', () => {
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
})
describe('restoreSettingsFromProperties', () => {
it('restores tool and brush settings from node properties on init', () => {
mockProperties.painterTool = 'eraser'
mockProperties.painterBrushSize = 42
mockProperties.painterBrushColor = '#ff0000'
mockProperties.painterBrushOpacity = 0.5
mockProperties.painterBrushHardness = 0.8
const { painter } = mountPainter()
expect(painter.tool.value).toBe('eraser')
expect(painter.brushSize.value).toBe(42)
expect(painter.brushColor.value).toBe('#ff0000')
expect(painter.brushOpacity.value).toBe(0.5)
expect(painter.brushHardness.value).toBe(0.8)
})
it('restores backgroundColor from bg_color widget', () => {
mockWidgets.push(makeWidget('bg_color', '#123456'))
const { painter } = mountPainter()
expect(painter.backgroundColor.value).toBe('#123456')
})
it('keeps defaults when no properties are stored', () => {
const { painter } = mountPainter()
expect(painter.tool.value).toBe('brush')
expect(painter.brushSize.value).toBe(20)
expect(painter.brushColor.value).toBe('#ffffff')
expect(painter.brushOpacity.value).toBe(1)
expect(painter.brushHardness.value).toBe(1)
})
})
describe('saveSettingsToProperties', () => {
it('persists tool settings to node properties when they change', async () => {
const { painter } = mountPainter()
painter.tool.value = 'eraser'
painter.brushSize.value = 50
painter.brushColor.value = '#00ff00'
painter.brushOpacity.value = 0.7
painter.brushHardness.value = 0.3
await nextTick()
expect(mockProperties.painterTool).toBe('eraser')
expect(mockProperties.painterBrushSize).toBe(50)
expect(mockProperties.painterBrushColor).toBe('#00ff00')
expect(mockProperties.painterBrushOpacity).toBe(0.7)
expect(mockProperties.painterBrushHardness).toBe(0.3)
})
})
describe('syncCanvasSizeToWidgets', () => {
it('syncs canvas dimensions to widgets when size changes', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
painter.canvasWidth.value = 800
painter.canvasHeight.value = 600
await nextTick()
expect(widthWidget.value).toBe(800)
expect(heightWidget.value).toBe(600)
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
})
describe('syncBackgroundColorToWidget', () => {
it('syncs background color to widget when color changes', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
const { painter } = mountPainter()
painter.backgroundColor.value = '#ff00ff'
await nextTick()
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
})
describe('updateInputImageUrl', () => {
it('sets isImageInputConnected to false when input is not connected', () => {
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(false)
expect(painter.inputImageUrl.value).toBeNull()
})
it('sets isImageInputConnected to true when input is connected', () => {
mockIsInputConnected.mockReturnValue(true)
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(true)
})
})
describe('handleInputImageLoad', () => {
it('updates canvas size and widgets from loaded image dimensions', () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
const fakeEvent = {
target: {
naturalWidth: 1920,
naturalHeight: 1080
}
} as unknown as Event
painter.handleInputImageLoad(fakeEvent)
expect(painter.canvasWidth.value).toBe(1920)
expect(painter.canvasHeight.value).toBe(1080)
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
})
describe('cursor visibility', () => {
it('sets cursorVisible to true on pointer enter', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
expect(painter.cursorVisible.value).toBe(true)
})
it('sets cursorVisible to false on pointer leave', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
})
describe('displayBrushSize', () => {
it('scales brush size by canvas display ratio', () => {
const { painter } = mountPainter()
// canvasDisplayWidth=512, canvasWidth=512 → ratio=1
// hardness=1 → effectiveRadius = radius * 1.0
// displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('increases for soft brush hardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0
// hardness=0 → effectiveRadius = 10 * 1.5 = 15
// displayBrushSize = 15 * 2 * 1 = 30
expect(painter.displayBrushSize.value).toBe(30)
})
})
describe('activeHardness (via displayBrushSize)', () => {
it('returns 1 for eraser regardless of brushHardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0.3
painter.tool.value = 'eraser'
// eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('uses brushHardness for brush tool', () => {
const { painter } = mountPainter()
painter.tool.value = 'brush'
painter.brushHardness.value = 0.5
// hardness=0.5 → scale=1.25 → 10*1.25*2 = 25
expect(painter.displayBrushSize.value).toBe(25)
})
})
describe('registerWidgetSerialization', () => {
it('attaches serializeValue to the mask widget on init', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
expect(maskWidget.serializeValue).toBeTypeOf('function')
})
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})
it('returns existing modelValue when not dirty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
// isCanvasEmpty() is true (no strokes drawn), so returns ''
expect(result).toBe('')
})
})
describe('restoreCanvas', () => {
it('builds correct URL from modelValue on mount', () => {
const { modelValue } = mountPainter()
// Before mount, set the modelValue
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
// With empty modelValue, restoreCanvas exits early
expect(modelValue.value).toBe('')
})
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter('test-node', 'painter/my-image.png [temp]')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=my-image.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('subfolder=painter')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=temp')
)
})
})
describe('handleClear', () => {
it('does not throw when canvas element is null', () => {
const { painter } = mountPainter()
expect(() => painter.handleClear()).not.toThrow()
})
})
describe('handlePointerDown', () => {
it('ignores non-primary button clicks', () => {
const { painter } = mountPainter()
const mockSetPointerCapture = vi.fn()
const event = new PointerEvent('pointerdown', {
button: 2
})
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: mockSetPointerCapture
}
})
painter.handlePointerDown(event)
expect(mockSetPointerCapture).not.toHaveBeenCalled()
})
})
describe('handlePointerUp', () => {
it('ignores non-primary button releases', () => {
const { painter } = mountPainter()
const mockReleasePointerCapture = vi.fn()
const event = {
button: 2,
target: {
releasePointerCapture: mockReleasePointerCapture
}
} as unknown as PointerEvent
painter.handlePointerUp(event)
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
})
})
})

View File

@@ -155,7 +155,6 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -170,8 +169,7 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { isActiveSubscription } = useBillingContext()
const { syncStatusAfterCheckout } = useSubscription()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -192,20 +190,48 @@ const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
const refreshSubscriptionStatus = async () => {
try {
await syncStatusAfterCheckout()
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to refresh subscription status',
error
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
void refreshSubscriptionStatus()
startPolling()
}
}
@@ -216,6 +242,7 @@ watch(
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
@@ -225,6 +252,7 @@ watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
telemetry?.trackMonthlySubscriptionSucceeded()
emit('close', true)
}
}
@@ -235,6 +263,7 @@ const handleSubscribed = () => {
}
const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
@@ -243,6 +272,7 @@ const handleChooseTeam = () => {
}
const handleClose = () => {
stopPolling()
onClose()
}
@@ -265,6 +295,7 @@ const handleViewEnterprise = () => {
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>

View File

@@ -28,7 +28,6 @@ const {
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionSucceeded: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
},
mockUserId: { value: 'user-123' }
@@ -135,40 +134,20 @@ describe('useSubscription', () => {
vi.clearAllMocks()
mockIsLoggedIn.value = false
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockUserId.value = 'user-123'
mockIsCloud.value = true
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
localStorage.clear()
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/pending-subscription-success/')) {
return {
ok: true,
status: 204
} as Response
}
if (url.includes('/customers/pending-subscription-success')) {
return {
ok: true,
status: 204
} as Response
}
return {
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response
})
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
})
describe('computed properties', () => {
@@ -296,146 +275,6 @@ describe('useSubscription', () => {
await expect(fetchStatus()).rejects.toThrow()
})
it('syncs and consumes pending subscription success when requested', async () => {
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/cloud-subscription-status')) {
return {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response
}
if (url.endsWith('/customers/pending-subscription-success')) {
return {
ok: true,
status: 200,
json: async () => ({
id: 'event-123',
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard'
})
} as Response
}
if (
url.endsWith(
'/customers/pending-subscription-success/event-123/consume'
)
) {
return {
ok: true,
status: 204
} as Response
}
throw new Error(`Unexpected fetch URL: ${url}`)
})
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
await syncStatusAfterCheckout()
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard'
})
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/pending-subscription-success/event-123/consume'
),
expect.objectContaining({
method: 'POST'
})
)
})
it('does not retrack a subscription success already delivered in this browser', async () => {
localStorage.setItem(
'comfy.subscription_success.delivered_transactions',
JSON.stringify(['stripe-event-123'])
)
vi.mocked(global.fetch).mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/customers/cloud-subscription-status')) {
return {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response
}
if (url.endsWith('/customers/pending-subscription-success')) {
return {
ok: true,
status: 200,
json: async () => ({
id: 'event-123',
transaction_id: 'stripe-event-123',
value: 20,
currency: 'USD',
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
} as Response
}
if (
url.endsWith(
'/customers/pending-subscription-success/event-123/consume'
)
) {
return {
ok: true,
status: 204
} as Response
}
throw new Error(`Unexpected fetch URL: ${url}`)
})
const { syncStatusAfterCheckout } = useSubscriptionWithScope()
await syncStatusAfterCheckout()
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/pending-subscription-success/event-123/consume'
),
expect.objectContaining({
method: 'POST'
})
)
})
})
describe('subscribe', () => {

View File

@@ -1,4 +1,4 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
@@ -13,7 +13,6 @@ import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
@@ -25,82 +24,6 @@ export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
type TrackedSubscriptionTierKey = Exclude<TierKey, 'free'>
type PendingSubscriptionSuccessResponse = {
id: string
transaction_id: string
value: number
currency: string
tier: TrackedSubscriptionTierKey
cycle: 'monthly' | 'yearly'
checkout_type: 'new' | 'change'
previous_tier?: TrackedSubscriptionTierKey | null
}
type FetchSubscriptionStatusOptions = {
syncPendingSuccess?: boolean
}
const SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY =
'comfy.subscription_success.delivered_transactions'
function readDeliveredSubscriptionSuccessTransactions(): string[] {
if (typeof window === 'undefined') {
return []
}
try {
const rawValue = window.localStorage.getItem(
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY
)
if (!rawValue) {
return []
}
const parsedValue = JSON.parse(rawValue)
if (!Array.isArray(parsedValue)) {
return []
}
return parsedValue.filter(
(transactionId): transactionId is string =>
typeof transactionId === 'string' && transactionId.length > 0
)
} catch {
return []
}
}
function hasDeliveredSubscriptionSuccess(transactionId: string): boolean {
return readDeliveredSubscriptionSuccessTransactions().includes(transactionId)
}
function markSubscriptionSuccessAsDelivered(transactionId: string): void {
if (typeof window === 'undefined') {
return
}
const nextTransactions = [
transactionId,
...readDeliveredSubscriptionSuccessTransactions().filter(
(existingTransactionId) => existingTransactionId !== transactionId
)
].slice(0, 20)
try {
window.localStorage.setItem(
SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY,
JSON.stringify(nextTransactions)
)
} catch (error) {
console.warn(
'[Subscription] Failed to persist delivered subscription success transaction',
error
)
}
}
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
@@ -188,111 +111,8 @@ function useSubscriptionInternal() {
return getCheckoutAttribution()
}
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return {
...authHeader,
'Content-Type': 'application/json'
}
}
const fetchPendingSubscriptionSuccess = async (
headers: Record<string, string>
): Promise<PendingSubscriptionSuccessResponse | null> => {
const response = await fetch(
buildApiUrl('/customers/pending-subscription-success'),
{
headers
}
)
if (response.status === 204) {
return null
}
if (!response.ok) {
throw new Error(
`Failed to fetch pending subscription success: ${response.status}`
)
}
return response.json()
}
const consumePendingSubscriptionSuccess = async (
headers: Record<string, string>,
id: string
): Promise<void> => {
const response = await fetch(
buildApiUrl(`/customers/pending-subscription-success/${id}/consume`),
{
method: 'POST',
headers
}
)
if (!response.ok && response.status !== 404) {
throw new Error(
`Failed to consume pending subscription success: ${response.status}`
)
}
}
const syncPendingSubscriptionSuccess = async (
headers: Record<string, string>
): Promise<void> => {
const pendingSuccess = await fetchPendingSubscriptionSuccess(headers)
if (!pendingSuccess) {
return
}
if (hasDeliveredSubscriptionSuccess(pendingSuccess.transaction_id)) {
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
return
}
telemetry?.trackMonthlySubscriptionSucceeded({
...(authStore.userId ? { user_id: authStore.userId } : {}),
transaction_id: pendingSuccess.transaction_id,
value: pendingSuccess.value,
currency: pendingSuccess.currency,
tier: pendingSuccess.tier,
cycle: pendingSuccess.cycle,
checkout_type: pendingSuccess.checkout_type,
...(pendingSuccess.previous_tier
? { previous_tier: pendingSuccess.previous_tier }
: {}),
ecommerce: {
transaction_id: pendingSuccess.transaction_id,
value: pendingSuccess.value,
currency: pendingSuccess.currency,
items: [
{
item_name: pendingSuccess.tier,
item_category: 'subscription',
item_variant: pendingSuccess.cycle,
price: pendingSuccess.value,
quantity: 1
}
]
}
})
markSubscriptionSuccessAsDelivered(pendingSuccess.transaction_id)
await consumePendingSubscriptionSuccess(headers, pendingSuccess.id)
}
const fetchStatus = wrapWithErrorHandlingAsync(
() => fetchSubscriptionStatus(),
reportError
)
const syncStatusAfterCheckout = wrapWithErrorHandlingAsync(
() => fetchSubscriptionStatus({ syncPendingSuccess: true }),
fetchSubscriptionStatus,
reportError
)
@@ -368,15 +188,19 @@ function useSubscriptionInternal() {
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(
options?: FetchSubscriptionStatusOptions
): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
)
@@ -392,47 +216,15 @@ function useSubscriptionInternal() {
const statusData = await response.json()
subscriptionStatus.value = statusData
if (options?.syncPendingSuccess && statusData.is_active) {
await syncPendingSubscriptionSuccess(headers)
}
return statusData
}
const handleDeliveredSubscriptionSuccessChange = (event: StorageEvent) => {
if (
event.key !== SUBSCRIPTION_SUCCESS_DELIVERED_STORAGE_KEY ||
!isCloud ||
!isLoggedIn.value
) {
return
}
void fetchSubscriptionStatus().catch((error) => {
console.error(
'[Subscription] Failed to refresh subscription status after cross-tab success delivery:',
error
)
})
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleDeliveredSubscriptionSuccessChange)
onScopeDispose(() => {
window.removeEventListener(
'storage',
handleDeliveredSubscriptionSuccessChange
)
})
}
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn && isCloud) {
try {
await fetchSubscriptionStatus({ syncPendingSuccess: true })
await fetchSubscriptionStatus()
} catch (error) {
// Network errors are expected during navigation/component unmount
// and when offline - log for debugging but don't surface to user
@@ -451,14 +243,20 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const headers = await buildAuthHeaders()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(checkoutAttribution)
}
)
@@ -495,7 +293,6 @@ function useSubscriptionInternal() {
// Actions
subscribe,
fetchStatus,
syncStatusAfterCheckout,
showSubscriptionDialog,
manageSubscription,
requireActiveSubscription,

View File

@@ -1,83 +0,0 @@
{
"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

@@ -1,83 +0,0 @@
{
"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,8 +15,6 @@ 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'
@@ -158,134 +156,6 @@ 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', () => {
@@ -1055,86 +925,6 @@ 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,6 +1,5 @@
import type {
ComfyWorkflowJSON,
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -20,7 +19,6 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
@@ -32,39 +30,6 @@ 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'
}
@@ -142,8 +107,6 @@ 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
@@ -159,11 +122,7 @@ export function scanNodeModelCandidates(
)
}
if (candidate) {
candidates.push(
enrichCandidateFromNodeProperties(candidate, embeddedModels)
)
}
if (candidate) candidates.push(candidate)
}
return candidates
@@ -272,18 +231,9 @@ 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,
m.directory,
allNodes,
flattenedNodeById
)
m.sourceNodeType !== '' || isModelReferencedByActiveNode(m.name, allNodes)
)
const settled = await Promise.allSettled(
@@ -326,9 +276,7 @@ export async function enrichWithEmbeddedMetadata(
function isModelReferencedByActiveNode(
modelName: string,
modelDirectory: string | undefined,
allNodes: ReturnType<typeof flattenWorkflowNodes>,
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
allNodes: ReturnType<typeof flattenWorkflowNodes>
): boolean {
for (const node of allNodes) {
if (
@@ -336,30 +284,12 @@ 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; directory?: string }> }
| undefined
node.properties as { models?: Array<{ name: string }> } | undefined
)?.models
if (
embeddedModels?.some(
(m) =>
m.name === modelName &&
(modelDirectory === undefined ||
m.directory === undefined ||
m.directory === modelDirectory)
)
) {
return true
}
if (embeddedModels?.some((m) => m.name === modelName)) 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)
@@ -370,22 +300,6 @@ 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

@@ -18,7 +18,6 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryDispatcher,
@@ -81,12 +80,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.dispatch((provider) =>
provider.trackMonthlySubscriptionSucceeded?.(metadata)
)
trackMonthlySubscriptionSucceeded(): void {
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -92,34 +92,9 @@ describe('GtmTelemetryProvider', () => {
it('pushes subscription_success for subscription activation', () => {
const provider = createInitializedProvider()
provider.trackMonthlySubscriptionSucceeded({
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
tier: 'creator',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'standard',
ecommerce: {
transaction_id: 'stripe-event-123',
value: 35,
currency: 'USD',
items: [
{
item_name: 'creator',
item_category: 'subscription',
item_variant: 'monthly',
price: 35,
quantity: 1
}
]
}
})
provider.trackMonthlySubscriptionSucceeded()
expect(lastDataLayerEntry()).toMatchObject({
event: 'subscription_success',
transaction_id: 'stripe-event-123',
tier: 'creator',
checkout_type: 'change'
event: 'subscription_success'
})
})

View File

@@ -16,7 +16,6 @@ import type {
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
@@ -168,17 +167,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.pushEvent('signup_opened')
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
if (metadata?.ecommerce) {
window.dataLayer?.push({ ecommerce: null })
}
this.pushEvent(
'subscription_success',
metadata ? { ...metadata } : undefined
)
trackMonthlySubscriptionSucceeded(): void {
this.pushEvent('subscription_success')
}
trackRunButton(options?: {

View File

@@ -31,7 +31,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -236,10 +235,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
/**

View File

@@ -26,7 +26,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -256,10 +255,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -344,29 +344,6 @@ export interface BeginCheckoutMetadata
previous_tier?: TierKey
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
transaction_id: string
value: number
currency: string
tier: Exclude<TierKey, 'free'>
cycle: BillingCycle
checkout_type: 'new' | 'change'
previous_tier?: Exclude<TierKey, 'free'>
ecommerce: {
transaction_id: string
value: number
currency: string
items: Array<{
item_name: string
item_category: 'subscription'
item_variant: BillingCycle
price: number
quantity: 1
}>
}
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -383,9 +360,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
@@ -584,4 +559,3 @@ export type TelemetryEventProperties =
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata

View File

@@ -39,7 +39,7 @@ const existingOutput = computed(() => {
<div
v-else-if="hasOutputs"
role="article"
data-testid="linear-arrange-preview"
data-testid="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="linear-arrange-no-outputs"
data-testid="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,12 +75,7 @@ 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"
data-testid="linear-arrange-switch-to-outputs"
@click="setMode('builder:outputs')"
>
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
{{ t('linearMode.arrange.switchToOutputsButton') }}
</Button>
</div>

View File

@@ -108,8 +108,6 @@ import {
collectAllNodes,
forEachNode,
getNodeByExecutionId,
isAncestorPathActive,
isMissingCandidateActive,
triggerCallbackOnAllNodes
} from '@/utils/graphTraversalUtil'
import {
@@ -1438,21 +1436,10 @@ 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,
activeMissingNodeTypes,
missingNodeTypes,
silentAssetErrors
)
@@ -1495,7 +1482,7 @@ export class ComfyApp {
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedAll = await enrichWithEmbeddedMetadata(
const enrichedCandidates = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
@@ -1511,19 +1498,6 @@ 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) => ({
@@ -1561,10 +1535,8 @@ export class ComfyApp {
)
.then(() => {
if (controller.signal.aborted) return
// Re-check ancestor: user may have bypassed a container
// while verification was in flight.
const confirmed = enrichedCandidates.filter((c) =>
isMissingCandidateActive(this.rootGraph, c)
const confirmed = enrichedCandidates.filter(
(c) => c.isMissing === true
)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingModels(confirmed, {
@@ -1671,11 +1643,7 @@ export class ComfyApp {
): Promise<void> {
const missingMediaStore = useMissingMediaStore()
const activeWf = useWorkspaceStore().workflow.activeWorkflow
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))
)
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
if (!candidates.length) {
this.cacheMediaCandidates(activeWf, [])
@@ -1687,10 +1655,7 @@ export class ComfyApp {
void verifyCloudMediaCandidates(candidates, controller.signal)
.then(() => {
if (controller.signal.aborted) return
// Re-check ancestor after async verification (see model pipeline).
const confirmed = candidates.filter((c) =>
isMissingCandidateActive(this.rootGraph, c)
)
const confirmed = candidates.filter((c) => c.isMissing === true)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
}

View File

@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getWebpMetadata } from './pnginfo'
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
const strBytes = new TextEncoder().encode(fullStr)
const headerSize = 22
const buf = new Uint8Array(headerSize + strBytes.length)
const dv = new DataView(buf.buffer)
buf.set([0x49, 0x49], 0)
dv.setUint16(2, 0x002a, true)
dv.setUint32(4, 8, true)
dv.setUint16(8, 1, true)
dv.setUint16(10, 0, true)
dv.setUint16(12, 2, true)
dv.setUint32(14, strBytes.length, true)
dv.setUint32(18, 22, true)
buf.set(strBytes, 22)
return buf
}
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
const exifPayload = buildExifPayload(workflowJson)
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
const buffer = new Uint8Array(totalSize)
const dv = new DataView(buffer.buffer)
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
dv.setUint32(4, totalSize - 8, true)
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
dv.setUint32(16, precedingChunkLength, true)
const exifStart = 20 + precedingPadded
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
dv.setUint32(exifStart + 4, exifPayload.length, true)
buffer.set(exifPayload, exifStart + 8)
return new File([buffer], 'test.webp', { type: 'image/webp' })
}
describe('getWebpMetadata', () => {
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
const workflow = '{"nodes":[]}'
const file = buildWebp(3, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
it('finds workflow when preceding chunk has even length (no padding)', async () => {
const workflow = '{"nodes":[1]}'
const file = buildWebp(4, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
})

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { ErrorReportData } from './errorReportUtil'
import { generateErrorReport } from './errorReportUtil'
const baseSystemStats: SystemStats = {
system: {
os: 'linux',
comfyui_version: '1.0.0',
python_version: '3.11',
pytorch_version: '2.0',
embedded_python: false,
argv: ['main.py'],
ram_total: 0,
ram_free: 0
},
devices: []
}
const baseWorkflow = { nodes: [], links: [] } as unknown as ISerialisedGraph
function buildError(serverLogs: unknown): ErrorReportData {
return {
exceptionType: 'RuntimeError',
exceptionMessage: 'boom',
systemStats: baseSystemStats,
serverLogs: serverLogs as string,
workflow: baseWorkflow
}
}
describe('generateErrorReport', () => {
it('embeds string serverLogs verbatim', () => {
const report = generateErrorReport(buildError('line one\nline two'))
expect(report).toContain('line one\nline two')
expect(report).not.toContain('[object Object]')
})
it('stringifies object serverLogs instead of rendering [object Object]', () => {
const report = generateErrorReport(
buildError({ entries: [{ msg: 'hello' }] })
)
expect(report).not.toContain('[object Object]')
expect(report).toContain('"entries"')
expect(report).toContain('"msg": "hello"')
})
})

View File

@@ -29,11 +29,8 @@ import {
triggerCallbackOnAllNodes,
visitGraphNodes,
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
isAncestorPathActive,
isMissingCandidateActive
getExecutionIdForNodeInGraph
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { createMockLGraphNode } from './__tests__/litegraphTestUtils'
@@ -726,141 +723,6 @@ 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,11 +3,9 @@ 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'
@@ -364,58 +362,6 @@ 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.
*