mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-02 03:14:53 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad242181b | ||
|
|
16dfc33df3 | ||
|
|
1a8bf498ef | ||
|
|
7b8ad1c11b | ||
|
|
364bcb3831 | ||
|
|
a6699f6922 | ||
|
|
962e70d7a5 | ||
|
|
6193b76157 | ||
|
|
c5c916f80e | ||
|
|
badc97b982 | ||
|
|
67affd2075 |
@@ -213,7 +213,8 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment)
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment),
|
||||
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
type RunOptions = {
|
||||
nodeErrors?: Record<string, NodeError>
|
||||
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
@@ -70,8 +75,9 @@ export class ExecutionHelper {
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(): Promise<string> {
|
||||
async run(options: RunOptions = {}): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
const { nodeErrors = {}, onPromptRequest } = options
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
@@ -81,12 +87,13 @@ export class ExecutionHelper {
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await onPromptRequest?.(route.request().postDataJSON())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: {}
|
||||
node_errors: nodeErrors
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
|
||||
@@ -135,7 +135,8 @@ export const TestIds = {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
@@ -152,6 +153,7 @@ export const TestIds = {
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
valueControl: 'value-control',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Tracks convert to subgraph as undo step',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
|
||||
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
|
||||
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
|
||||
|
||||
await input.fill('40')
|
||||
await node.title.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
|
||||
.click()
|
||||
await expect(input).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('40')
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const VALIDATION_ERROR_NODE_ID = '1'
|
||||
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
|
||||
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
|
||||
|
||||
type PromptRequestNode = {
|
||||
class_type?: string
|
||||
}
|
||||
|
||||
type PromptRequestBody = {
|
||||
prompt?: Record<string, PromptRequestNode>
|
||||
}
|
||||
|
||||
function buildPreviewAnyValidationError(): NodeError {
|
||||
return {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: VALIDATION_ERROR_MESSAGE,
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function expectPartialExecutionRootNodes(requestBody: unknown): void {
|
||||
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
|
||||
|
||||
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
|
||||
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidationErrorMessage(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
|
||||
?.message ?? null,
|
||||
VALIDATION_ERROR_NODE_ID
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -74,3 +127,48 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
|
||||
test('preserves validation errors when another active root starts execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const nodeErrors = {
|
||||
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
|
||||
}
|
||||
let promptRequestBody: unknown
|
||||
|
||||
const jobId = await exec.run({
|
||||
nodeErrors,
|
||||
onPromptRequest: (requestBody) => {
|
||||
promptRequestBody = requestBody
|
||||
}
|
||||
})
|
||||
expectPartialExecutionRootNodes(promptRequestBody)
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
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', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
@@ -1,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -651,6 +650,12 @@ test(
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
@@ -659,9 +664,6 @@ test(
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
@@ -699,5 +701,18 @@ test(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Can disconnect link by right click', async () => {
|
||||
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
|
||||
const { x, y } = await stepsIOSlot.getPosition()
|
||||
await comfyPage.page.mouse.click(x, y, { button: 'right' })
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
|
||||
await expect(slotParent).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const postScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
|
||||
await controls.decrementButton.click()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
})
|
||||
|
||||
test('displays control widgets with default state', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
|
||||
await expect(widget).toBeVisible()
|
||||
|
||||
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
|
||||
await expect(valueControl).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
@@ -52,4 +55,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Can load dynamic combos', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
|
||||
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
|
||||
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
|
||||
|
||||
await test.step('Update value of the dynamic combo widget', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
|
||||
await expect(widget).toHaveText('scale width')
|
||||
})
|
||||
|
||||
await test.step('Swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await expect(widget).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(widget).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
:src
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
|
||||
222
src/components/graph/NodeTooltip.test.ts
Normal file
222
src/components/graph/NodeTooltip.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { i18n, te } from '@/i18n'
|
||||
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeTooltip from './NodeTooltip.vue'
|
||||
|
||||
type HitTest = (
|
||||
node: MockNode,
|
||||
x: number,
|
||||
y: number,
|
||||
offset: [number, number]
|
||||
) => number
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
type: string
|
||||
flags: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
pos: [number, number]
|
||||
inputs: Array<{ name: string }>
|
||||
constructor: {
|
||||
title_mode?: 0 | 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
|
||||
interface MockCanvas {
|
||||
mouse: [number, number]
|
||||
graph_mouse: [number, number]
|
||||
node_over: MockNode | null
|
||||
getWidgetAtCursor: () => MockWidget | null
|
||||
}
|
||||
|
||||
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsDOMWidget = vi.hoisted(() =>
|
||||
vi.fn<(widget: MockWidget) => boolean>()
|
||||
)
|
||||
const mockCanvas = vi.hoisted(
|
||||
(): MockCanvas => ({
|
||||
mouse: [100, 80],
|
||||
graph_mouse: [10, 10],
|
||||
node_over: null,
|
||||
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LiteGraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
isOverNodeInput: mockIsOverNodeInput,
|
||||
isOverNodeOutput: mockIsOverNodeOutput
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
isDOMWidget: mockIsDOMWidget
|
||||
}))
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
function createSam3Node(): MockNode {
|
||||
return {
|
||||
type: 'SAM3_Detect',
|
||||
flags: {},
|
||||
pos: [0, 0],
|
||||
inputs: [{ name: 'positive_coords' }],
|
||||
constructor: {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderAndHoverCanvas() {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(NodeTooltip)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
document.body.appendChild(canvas)
|
||||
await user.hover(canvas)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('NodeTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 0 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockCanvas.mouse = [100, 80]
|
||||
mockCanvas.graph_mouse = [10, 10]
|
||||
mockCanvas.node_over = createSam3Node()
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
|
||||
name: 'positive_coords'
|
||||
})
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { stRaw } from '@/i18n'
|
||||
import {
|
||||
LiteGraph,
|
||||
isOverNodeInput,
|
||||
@@ -84,7 +84,7 @@ function onIdle() {
|
||||
)
|
||||
if (inputSlot !== -1) {
|
||||
const inputName = node.inputs[inputSlot].name
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
|
||||
nodeDef?.inputs[inputName]?.tooltip ?? ''
|
||||
)
|
||||
@@ -98,7 +98,7 @@ function onIdle() {
|
||||
[0, 0]
|
||||
)
|
||||
if (outputSlot !== -1) {
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
|
||||
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
|
||||
)
|
||||
@@ -108,7 +108,7 @@ function onIdle() {
|
||||
const widget = comfyApp.canvas.getWidgetAtCursor()
|
||||
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
|
||||
if (widget && !isDOMWidget(widget)) {
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
|
||||
nodeDef?.inputs[widget.name]?.tooltip ?? ''
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
@@ -154,9 +155,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
|
||||
@@ -108,23 +108,6 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -19,7 +19,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
@@ -35,10 +34,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
@@ -90,22 +85,12 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
@@ -173,19 +158,9 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -185,11 +187,18 @@ function dynamicComboWidget(
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return widgetValue
|
||||
return getState()?.value ?? widgetValue
|
||||
},
|
||||
set(value) {
|
||||
const state = getState()
|
||||
if (state) state.value = value
|
||||
widgetValue = value
|
||||
updateWidgets(value)
|
||||
}
|
||||
|
||||
15
src/i18n.ts
15
src/i18n.ts
@@ -155,6 +155,7 @@ export const i18n = createI18n({
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
@@ -166,3 +167,17 @@ export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe raw translation function for strings that may contain i18n syntax.
|
||||
*
|
||||
* @param key - The key for the raw locale message.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found
|
||||
* or the locale message is not a string.
|
||||
*/
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
@@ -1670,6 +1670,7 @@ export class LGraph
|
||||
|
||||
// Record state before conversion for proper undo support
|
||||
this.beforeChange()
|
||||
this.canvasAction((c) => c.emitBeforeChange())
|
||||
|
||||
try {
|
||||
function extractNodes(item: Positionable): Positionable[] {
|
||||
@@ -1684,6 +1685,7 @@ export class LGraph
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
this.canvasAction((c) => c.emitAfterChange())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ export abstract class SubgraphIONodeBase<
|
||||
break
|
||||
}
|
||||
|
||||
this.subgraph.setDirtyCanvas(true)
|
||||
this.subgraph.setDirtyCanvas(true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadModel,
|
||||
fetchModelMetadata,
|
||||
isModelDownloadable,
|
||||
toBrowsableUrl
|
||||
} from './missingModelDownload'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
|
||||
vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
mockIsDesktop: { value: false },
|
||||
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
|
||||
mockStartDownload: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({}))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockIsDesktop.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({
|
||||
useElectronDownloadStore: () => ({
|
||||
start: mockStartDownload
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => mockSidebarTabStore
|
||||
}))
|
||||
|
||||
let testId = 0
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -213,3 +237,31 @@ describe('isModelDownloadable', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
mockIsDesktop.value = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-library')
|
||||
expect(mockStartDownload).toHaveBeenCalledWith({
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
savePath: '/models/checkpoints',
|
||||
filename: 'model.safetensors'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
@@ -26,6 +27,8 @@ const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
const MODEL_LIBRARY_TAB_ID = 'model-library'
|
||||
|
||||
export interface ModelWithUrl {
|
||||
name: string
|
||||
url: string
|
||||
@@ -72,6 +75,7 @@ export function downloadModel(
|
||||
|
||||
const modelPaths = paths[model.directory]
|
||||
if (modelPaths?.[0]) {
|
||||
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
|
||||
void useElectronDownloadStore().start({
|
||||
url: model.url,
|
||||
savePath: modelPaths[0],
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { i18n, te } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useNodeTooltips } from './useNodeTooltips'
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const positiveCoordsWidget: SafeWidgetData = {
|
||||
name: 'positive_coords',
|
||||
type: 'STRING'
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
describe('useNodeTooltips', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'Comfy.EnableTooltips':
|
||||
return true as Settings[K]
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 500 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads JSON examples in node metadata without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getInputSlotTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
// Ensure this exercises the bundled i18n path, not only metadata fallback.
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(getInputSlotTooltip('positive_coords')).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads input-based widget tooltips without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getWidgetTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(getWidgetTooltip(positiveCoordsWidget)).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads output slot tooltips without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getOutputSlotTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(getOutputSlotTooltip(0)).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { computed, ref, unref } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { st } from '@/i18n'
|
||||
import { st, stRaw } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -119,7 +119,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
return stRaw(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
|
||||
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
|
||||
return st(key, outputTooltip)
|
||||
return stRaw(key, outputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +146,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
// Then try input-based tooltip lookup
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
return stRaw(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey when no nodeId is available at all', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses duplicate normal widgets on the same node to one render', () => {
|
||||
const colorA = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const colorB = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'ColorToRGBInt',
|
||||
widgets: [colorA, colorB],
|
||||
title: 'Color to RGB Int',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
@@ -129,11 +129,15 @@ export function getWidgetIdentity(
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
|
||||
@@ -28,6 +28,7 @@ const textMap: Record<ControlOptions, string | null> = {
|
||||
|
||||
<template>
|
||||
<button
|
||||
data-testid="value-control"
|
||||
type="button"
|
||||
:aria-label="t('widgets.valueControl.' + mode)"
|
||||
:class="
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IColorWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
const widgets: IColorWidget[] = []
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
options: IWidgetOptions
|
||||
) => {
|
||||
const widget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
callback: _callback
|
||||
} as unknown as IColorWidget
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return { widgets, addWidget } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const colorSpec: InputSpec = {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
default: '#ffffff',
|
||||
socketless: true
|
||||
}
|
||||
|
||||
describe('useColorWidget', () => {
|
||||
it('reads the top-level default from the V2 spec', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, colorSpec)
|
||||
expect(widget.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('falls back to nested options.default when top-level default is absent', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
options: { default: '#abcdef' }
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#abcdef')
|
||||
})
|
||||
|
||||
it('falls back to #000000 when no default is declared', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color'
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns the existing widget instead of creating a duplicate', () => {
|
||||
const node = createMockNode()
|
||||
const first = useColorWidget()(node, colorSpec)
|
||||
const second = useColorWidget()(node, colorSpec)
|
||||
expect(second).toBe(first)
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
const colorSpec = inputSpec as ColorInputSpec
|
||||
const { name, options } = colorSpec
|
||||
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
|
||||
|
||||
const existing = node.widgets?.find(
|
||||
(w): w is IColorWidget => w.name === name && w.type === 'color'
|
||||
)
|
||||
if (existing) return existing
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
|
||||
@@ -2,12 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { ComfyApp } from './app'
|
||||
import { createNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
@@ -20,14 +21,30 @@ import {
|
||||
} from '@/composables/usePaste'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const {
|
||||
mockApiKeyAuthStore,
|
||||
mockAuthStore,
|
||||
mockSettingStore,
|
||||
mockToastStore,
|
||||
mockExtensionService,
|
||||
mockNodeOutputStore,
|
||||
mockWorkspaceWorkflow,
|
||||
mockRefreshMissingModelPipeline
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiKeyAuthStore: {
|
||||
getApiKey: vi.fn()
|
||||
},
|
||||
mockAuthStore: {
|
||||
getAuthToken: vi.fn()
|
||||
},
|
||||
mockSettingStore: {
|
||||
get: vi.fn()
|
||||
},
|
||||
mockToastStore: {
|
||||
addAlert: vi.fn(),
|
||||
add: vi.fn(),
|
||||
@@ -41,7 +58,7 @@ const {
|
||||
refreshNodeOutputs: vi.fn()
|
||||
},
|
||||
mockWorkspaceWorkflow: {
|
||||
activeWorkflow: null
|
||||
activeWorkflow: null as ComfyWorkflow | null
|
||||
},
|
||||
mockRefreshMissingModelPipeline: vi.fn()
|
||||
}))
|
||||
@@ -55,6 +72,18 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
fixLinkInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: vi.fn(() => mockApiKeyAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => mockAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => mockSettingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePaste', () => ({
|
||||
pasteAudioNode: vi.fn(),
|
||||
pasteAudioNodes: vi.fn(),
|
||||
@@ -110,6 +139,7 @@ function createMockCanvas(): Partial<LGraphCanvas> {
|
||||
|
||||
return {
|
||||
graph: mockGraph as LGraph,
|
||||
draw: vi.fn(),
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
}
|
||||
@@ -141,8 +171,70 @@ describe('ComfyApp', () => {
|
||||
app = new ComfyApp()
|
||||
mockCanvas = createMockCanvas() as LGraphCanvas
|
||||
app.canvas = mockCanvas as LGraphCanvas
|
||||
mockWorkspaceWorkflow.activeWorkflow = null
|
||||
mockApiKeyAuthStore.getApiKey.mockReturnValue(undefined)
|
||||
mockAuthStore.getAuthToken.mockResolvedValue(undefined)
|
||||
mockExtensionService.invokeExtensions.mockReturnValue([])
|
||||
mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined)
|
||||
mockSettingStore.get.mockImplementation((key: string) =>
|
||||
key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined
|
||||
)
|
||||
})
|
||||
|
||||
describe('queuePrompt', () => {
|
||||
it('shows the error overlay for successful prompt responses with node errors', async () => {
|
||||
const graph = new LGraph()
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: 'workflows/review.json',
|
||||
modified: 0,
|
||||
size: 0
|
||||
})
|
||||
const promptOutput: ComfyApiWorkflow = {
|
||||
'1': {
|
||||
class_type: 'PreviewAny',
|
||||
inputs: {},
|
||||
_meta: { title: 'PreviewAny' }
|
||||
}
|
||||
}
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: ['1'],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: source',
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
mockWorkspaceWorkflow.activeWorkflow = workflow
|
||||
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
|
||||
output: promptOutput,
|
||||
workflow: createWorkflowGraphData()
|
||||
})
|
||||
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
|
||||
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
|
||||
prompt_id: 'job-1',
|
||||
node_errors: nodeErrors,
|
||||
error: ''
|
||||
})
|
||||
|
||||
await expect(app.queuePrompt(0)).resolves.toBe(false)
|
||||
|
||||
const errorStore = useExecutionErrorStore()
|
||||
const executionStore = useExecutionStore()
|
||||
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
|
||||
expect(errorStore.isErrorOverlayOpen).toBe(true)
|
||||
expect(executionStore.queuedJobs['job-1']?.nodes).toEqual({ '1': false })
|
||||
expect(executionStore.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
'workflows/review.json'
|
||||
)
|
||||
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshComboInNodes', () => {
|
||||
|
||||
@@ -1623,20 +1623,32 @@ export class ComfyApp {
|
||||
})
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
executionErrorStore.lastNodeErrors = res.node_errors ?? null
|
||||
if (executionErrorStore.lastNodeErrors?.length) {
|
||||
const nodeErrors = res.node_errors
|
||||
const hasNodeErrors =
|
||||
nodeErrors && Object.keys(nodeErrors).length > 0
|
||||
executionErrorStore.lastNodeErrors = hasNodeErrors
|
||||
? nodeErrors
|
||||
: null
|
||||
try {
|
||||
if (res.prompt_id) {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to store queued job metadata', {
|
||||
promptId: res.prompt_id,
|
||||
error
|
||||
})
|
||||
}
|
||||
if (hasNodeErrors) {
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
} else {
|
||||
try {
|
||||
if (res.prompt_id) {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
/** Clear all error state. Called at execution start and workflow changes.
|
||||
/** Clear all error state.
|
||||
* Missing model state is intentionally preserved here to avoid wiping
|
||||
* in-progress model repairs (importTaskIds, URL inputs, etc.).
|
||||
* Missing models are cleared separately during workflow load/clean paths. */
|
||||
@@ -64,6 +64,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
function clearExecutionStartErrors() {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
if (
|
||||
!lastNodeErrors.value ||
|
||||
Object.keys(lastNodeErrors.value).length === 0
|
||||
) {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear only prompt-level errors. Called during resetExecutionState. */
|
||||
function clearPromptError() {
|
||||
lastPromptError.value = null
|
||||
@@ -361,6 +372,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
// Clearing
|
||||
clearAllErrors,
|
||||
clearExecutionStartErrors,
|
||||
clearPromptError,
|
||||
|
||||
// Overlay UI
|
||||
|
||||
@@ -948,6 +948,48 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
|
||||
})
|
||||
|
||||
it('clears transient errors while preserving validation errors', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
const nodeErrors = {
|
||||
'1': {
|
||||
class_type: 'Test',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
errorStore.lastExecutionError = {
|
||||
prompt_id: 'old-job',
|
||||
timestamp: 0,
|
||||
node_id: '1',
|
||||
node_type: 'Test',
|
||||
executed: [],
|
||||
exception_message: 'boom',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
errorStore.lastPromptError = {
|
||||
type: 'old-error',
|
||||
message: 'old prompt error',
|
||||
details: ''
|
||||
}
|
||||
errorStore.lastNodeErrors = nodeErrors
|
||||
errorStore.showErrorOverlay()
|
||||
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(errorStore.lastExecutionError).toBeNull()
|
||||
expect(errorStore.lastPromptError).toBeNull()
|
||||
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
|
||||
expect(errorStore.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('clears initializing state for the starting job', () => {
|
||||
store.initializingJobIds = new Set([
|
||||
'job-1',
|
||||
|
||||
@@ -261,7 +261,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionIdToLocatorCache.clear()
|
||||
executionErrorStore.clearAllErrors()
|
||||
executionErrorStore.clearExecutionStartErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
|
||||
@@ -473,6 +473,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
node.imgs = [element]
|
||||
node.imageIndex = activeIndex
|
||||
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (outputs?.images) node.images = outputs.images
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user