mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 13:17:48 +00:00
Compare commits
3 Commits
shihchi/co
...
codex/crit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4e6645fc | ||
|
|
0228a18554 | ||
|
|
9e5fb67b76 |
8
.github/workflows/ci-tests-unit.yaml
vendored
8
.github/workflows/ci-tests-unit.yaml
vendored
@@ -58,3 +58,11 @@ jobs:
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
- name: Upload critical coverage summary
|
||||
if: always() && !cancelled() && hashFiles('coverage/coverage-summary.json') != ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: critical-coverage-summary
|
||||
path: coverage/coverage-summary.json
|
||||
retention-days: 1
|
||||
|
||||
26
.github/workflows/pr-report.yaml
vendored
26
.github/workflows/pr-report.yaml
vendored
@@ -2,7 +2,11 @@ name: 'PR: Unified Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
|
||||
workflows:
|
||||
- 'CI: Size Data'
|
||||
- 'CI: Performance Report'
|
||||
- 'CI: E2E Coverage'
|
||||
- 'CI: Tests Unit'
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
@@ -90,6 +94,25 @@ jobs:
|
||||
path: temp/coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Find critical coverage workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-critical-coverage
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-tests-unit.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
not-found-status: skip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download critical coverage summary
|
||||
if: steps.pr-meta.outputs.skip != 'true' && (steps.find-critical-coverage.outputs.status == 'ready' || steps.find-critical-coverage.outputs.status == 'failed')
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: critical-coverage-summary
|
||||
run_id: ${{ steps.find-critical-coverage.outputs.run-id }}
|
||||
path: temp/critical-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf metrics (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
@@ -129,6 +152,7 @@ jobs:
|
||||
--size-status=${{ steps.find-size.outputs.status }}
|
||||
--perf-status=${{ steps.find-perf.outputs.status }}
|
||||
--coverage-status=${{ steps.find-coverage.outputs.status }}
|
||||
--critical-coverage-status=${{ steps.find-critical-coverage.outputs.status }}
|
||||
> pr-report.md
|
||||
|
||||
- name: Remove legacy separate comments
|
||||
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
54
scripts/unified-report.test.ts
Normal file
54
scripts/unified-report.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatCoverageMetric,
|
||||
renderCriticalCoverageReport
|
||||
} from './unified-report'
|
||||
|
||||
describe('formatCoverageMetric', () => {
|
||||
it('formats covered counts and percent', () => {
|
||||
expect(
|
||||
formatCoverageMetric({
|
||||
covered: 8,
|
||||
total: 10,
|
||||
pct: 80
|
||||
})
|
||||
).toBe('8/10 | 80.00%')
|
||||
})
|
||||
|
||||
it('falls back when a metric is missing or invalid', () => {
|
||||
expect(formatCoverageMetric()).toBe('N/A | N/A')
|
||||
expect(
|
||||
formatCoverageMetric({
|
||||
covered: Number.NaN,
|
||||
total: 10,
|
||||
pct: 80
|
||||
})
|
||||
).toBe('N/A | N/A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderCriticalCoverageReport', () => {
|
||||
it('renders critical coverage rows from a summary', () => {
|
||||
expect(
|
||||
renderCriticalCoverageReport({
|
||||
total: {
|
||||
statements: { covered: 8, total: 10, pct: 80 },
|
||||
functions: { covered: 3, total: 4, pct: 75 },
|
||||
lines: { covered: 9, total: 10, pct: 90 }
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
[
|
||||
'## Critical Unit Coverage',
|
||||
'',
|
||||
'| Metric | Covered | Coverage |',
|
||||
'|---|---:|---:|',
|
||||
'| Statements | 8/10 | 80.00% |',
|
||||
'| Branches | N/A | N/A |',
|
||||
'| Functions | 3/4 | 75.00% |',
|
||||
'| Lines | 9/10 | 90.00% |'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const args: string[] = process.argv.slice(2)
|
||||
|
||||
@@ -12,6 +13,58 @@ function getArg(name: string): string | undefined {
|
||||
const sizeStatus = getArg('size-status') ?? 'pending'
|
||||
const perfStatus = getArg('perf-status') ?? 'pending'
|
||||
const coverageStatus = getArg('coverage-status') ?? 'skip'
|
||||
const criticalCoverageStatus = getArg('critical-coverage-status') ?? 'skip'
|
||||
|
||||
export type CoverageMetricName =
|
||||
| 'statements'
|
||||
| 'branches'
|
||||
| 'functions'
|
||||
| 'lines'
|
||||
export type CoverageMetric = {
|
||||
total: number
|
||||
covered: number
|
||||
pct: number
|
||||
}
|
||||
export type CoverageSummary = {
|
||||
total?: Partial<Record<CoverageMetricName, CoverageMetric>>
|
||||
}
|
||||
|
||||
export function formatCoverageMetric(metric?: CoverageMetric): string {
|
||||
if (
|
||||
!metric ||
|
||||
!Number.isFinite(metric.covered) ||
|
||||
!Number.isFinite(metric.total) ||
|
||||
!Number.isFinite(metric.pct)
|
||||
) {
|
||||
return 'N/A | N/A'
|
||||
}
|
||||
|
||||
return `${metric.covered}/${metric.total} | ${metric.pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function renderCriticalCoverageReport(
|
||||
summary: CoverageSummary = JSON.parse(
|
||||
readFileSync('temp/critical-coverage/coverage-summary.json', 'utf-8')
|
||||
) as CoverageSummary
|
||||
): string {
|
||||
const rows: Array<[string, CoverageMetricName]> = [
|
||||
['Statements', 'statements'],
|
||||
['Branches', 'branches'],
|
||||
['Functions', 'functions'],
|
||||
['Lines', 'lines']
|
||||
]
|
||||
|
||||
return [
|
||||
'## Critical Unit Coverage',
|
||||
'',
|
||||
'| Metric | Covered | Coverage |',
|
||||
'|---|---:|---:|',
|
||||
...rows.map(([label, key]) => {
|
||||
const metric = summary.total?.[key]
|
||||
return `| ${label} | ${formatCoverageMetric(metric)} |`
|
||||
})
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -97,4 +150,41 @@ if (coverageStatus === 'ready' && existsSync('temp/coverage/coverage.lcov')) {
|
||||
lines.push('> ⚠️ Coverage collection failed. Check the CI workflow logs.')
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
if (
|
||||
(criticalCoverageStatus === 'ready' || criticalCoverageStatus === 'failed') &&
|
||||
existsSync('temp/critical-coverage/coverage-summary.json')
|
||||
) {
|
||||
try {
|
||||
lines.push('')
|
||||
lines.push(renderCriticalCoverageReport())
|
||||
} catch {
|
||||
lines.push('')
|
||||
lines.push('## Critical Unit Coverage')
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'> Failed to render critical coverage summary. Check the CI workflow logs.'
|
||||
)
|
||||
}
|
||||
} else if (criticalCoverageStatus === 'ready') {
|
||||
lines.push('')
|
||||
lines.push('## Critical Unit Coverage')
|
||||
lines.push('')
|
||||
lines.push('> Critical coverage summary unavailable.')
|
||||
} else if (criticalCoverageStatus === 'failed') {
|
||||
lines.push('')
|
||||
lines.push('## Critical Unit Coverage')
|
||||
lines.push('')
|
||||
lines.push('> Critical coverage gate failed. Check the CI workflow logs.')
|
||||
} else if (criticalCoverageStatus === 'pending') {
|
||||
lines.push('')
|
||||
lines.push('## Critical Unit Coverage')
|
||||
lines.push('')
|
||||
lines.push('> Critical coverage gate is still running.')
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,22 +34,17 @@ describe('useSelectionToolboxPosition', () => {
|
||||
canvasStore = useCanvasStore()
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
selectedItems: new Set([item]),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
selectionChanged: true
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
@@ -74,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
@@ -86,64 +81,11 @@ describe('useSelectionToolboxPosition', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates when selection is empty', () => {
|
||||
const { toolbox, unmount } = renderToolboxForSelection([])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates while selected items are being dragged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group], {
|
||||
draggingItems: true
|
||||
})
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions multiple selected items from their union bounds', () => {
|
||||
const first = new LGraphGroup('First', 1)
|
||||
first.pos = [100, 200]
|
||||
first.size = [100, 40]
|
||||
const second = new LGraphGroup('Second', 2)
|
||||
second.pos = [300, 260]
|
||||
second.size = [50, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([first, second])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies canvas scale and offset to screen coordinates', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [100, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(
|
||||
[group],
|
||||
{},
|
||||
{ offset: [10, 20], scale: 2 }
|
||||
)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -60,13 +45,8 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
@@ -202,147 +182,4 @@ describe('useImageMenuOptions', () => {
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image actions', () => {
|
||||
it('opens the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const openOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Open Image'
|
||||
)
|
||||
openOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const saveOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Save Image'
|
||||
)
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open or save when the active image is missing', () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const openOption = options.find((o) => o.label === 'Open Image')
|
||||
const saveOption = options.find((o) => o.label === 'Save Image')
|
||||
|
||||
expect(openOption?.action).toEqual(expect.any(Function))
|
||||
expect(saveOption?.action).toEqual(expect.any(Function))
|
||||
|
||||
openOption?.action?.()
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs save failures for invalid image URLs', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
Object.defineProperty(node.imgs![0], 'src', {
|
||||
value: 'http://[',
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Save Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save image:',
|
||||
expect.any(TypeError)
|
||||
)
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the selected image to clipboard', async () => {
|
||||
const node = createImageNode()
|
||||
const drawImage = vi.fn()
|
||||
const write = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboardItem()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
|
||||
expect(write).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
items: { 'image/png': expect.any(Blob) }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not copy when canvas context is unavailable', async () => {
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() => null) as HTMLCanvasElement['getContext']
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when canvas blob creation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue'
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const {
|
||||
settings,
|
||||
appState,
|
||||
extensionState,
|
||||
nodeDefState,
|
||||
pricingState,
|
||||
setDirtyMock,
|
||||
addEventListenerMock,
|
||||
registerExtensionMock,
|
||||
getCreditsBadgeMock,
|
||||
updateSubgraphCreditsMock,
|
||||
getNodePricingConfigMock,
|
||||
getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculationMock,
|
||||
useComputedWithWidgetWatchMock
|
||||
} = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | null
|
||||
},
|
||||
pricingState: {
|
||||
revision: { value: 0 },
|
||||
config: undefined as
|
||||
| {
|
||||
depends_on?: {
|
||||
widgets?: string[]
|
||||
inputs?: string[]
|
||||
input_groups?: string[]
|
||||
}
|
||||
}
|
||||
| undefined,
|
||||
label: '1 credit'
|
||||
},
|
||||
setDirtyMock: vi.fn(),
|
||||
addEventListenerMock: vi.fn(),
|
||||
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
|
||||
extensionState.registered = extension
|
||||
}),
|
||||
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
|
||||
updateSubgraphCreditsMock: vi.fn(),
|
||||
getNodePricingConfigMock: vi.fn(() => pricingState.config),
|
||||
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
|
||||
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
|
||||
triggerPriceRecalculationMock: vi.fn(),
|
||||
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
setDirty: setDirtyMock,
|
||||
canvas: {
|
||||
addEventListener: addEventListenerMock
|
||||
},
|
||||
graph: appState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
isExtensionInstalled: () => extensionState.installed,
|
||||
registerExtension: registerExtensionMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
fromLGraphNode: () => nodeDefState.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#fff',
|
||||
BADGE_BG_COLOR: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({
|
||||
pricingRevision: pricingState.revision,
|
||||
getNodePricingConfig: getNodePricingConfigMock,
|
||||
getNodeDisplayPrice: getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNames: getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculation: triggerPriceRecalculationMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
getCreditsBadge: getCreditsBadgeMock,
|
||||
updateSubgraphCredits: updateSubgraphCreditsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useWatchWidget', () => ({
|
||||
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
|
||||
}))
|
||||
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
|
||||
function mountBadge(): VueApp {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useNodeBadge()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
return app
|
||||
}
|
||||
|
||||
function registeredExtension(): ComfyExtension {
|
||||
if (!extensionState.registered)
|
||||
throw new Error('Missing registered extension')
|
||||
return extensionState.registered
|
||||
}
|
||||
|
||||
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
|
||||
}
|
||||
|
||||
function callNodeCreated(node: LGraphNode) {
|
||||
registeredExtension().nodeCreated?.(node, comfyApp())
|
||||
}
|
||||
|
||||
function inputSlot(name: string) {
|
||||
return new LGraphNode('slot').addInput(name, '*')
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
}
|
||||
|
||||
describe('useNodeBadge', () => {
|
||||
let mountedApp: VueApp | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
defaultSettings()
|
||||
extensionState.installed = false
|
||||
extensionState.registered = undefined
|
||||
appState.graph.nodes = []
|
||||
nodeDefState.value = null
|
||||
pricingState.revision.value = 0
|
||||
pricingState.config = undefined
|
||||
pricingState.label = '1 credit'
|
||||
setDirtyMock.mockClear()
|
||||
addEventListenerMock.mockClear()
|
||||
registerExtensionMock.mockClear()
|
||||
getCreditsBadgeMock.mockClear()
|
||||
updateSubgraphCreditsMock.mockClear()
|
||||
getNodePricingConfigMock.mockClear()
|
||||
getNodeDisplayPriceMock.mockClear()
|
||||
getRelevantWidgetNamesMock.mockClear()
|
||||
triggerPriceRecalculationMock.mockClear()
|
||||
useComputedWithWidgetWatchMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount()
|
||||
mountedApp = undefined
|
||||
})
|
||||
|
||||
it('does not register the badge extension twice', async () => {
|
||||
extensionState.installed = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
expect(registerExtensionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds the configured node identity badge', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: 'Beta',
|
||||
nodeSource: { badgeText: 'Pack' }
|
||||
}
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = toNodeId('7')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(node.badgePosition).toBe(BadgePosition.TopRight)
|
||||
expect(badge().text).toBe('#7 Beta Pack')
|
||||
})
|
||||
|
||||
it('hides built-in badge text when the mode excludes core nodes', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: true,
|
||||
nodeLifeCycleBadgeText: 'Core',
|
||||
nodeSource: { badgeText: 'Built-in' }
|
||||
}
|
||||
const node = new LGraphNode('Core')
|
||||
node.id = toNodeId('11')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#11')
|
||||
})
|
||||
|
||||
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed'],
|
||||
inputs: ['image'],
|
||||
input_groups: ['lora']
|
||||
}
|
||||
}
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const node = new ApiNode('API')
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
|
||||
widgetNames: ['seed'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
|
||||
const priceBadge = node.badges[1] as () => { text: string }
|
||||
expect(priceBadge().text).toBe('1 credit')
|
||||
pricingState.label = '2 credits'
|
||||
expect(priceBadge().text).toBe('2 credits')
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
|
||||
|
||||
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('updates subgraph credit badges from registered extension hooks', async () => {
|
||||
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
|
||||
appState.graph.nodes = nodes
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
const convertedHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'subgraph-converted'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
|
||||
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
function node(over: Partial<TreeNode>): TreeNode {
|
||||
return over as TreeNode
|
||||
}
|
||||
|
||||
// root ─┬─ a ── a1 (leaf)
|
||||
// └─ b (leaf)
|
||||
function sampleTree() {
|
||||
const a1 = node({ key: 'a1', leaf: true })
|
||||
const a = node({ key: 'a', leaf: false, children: [a1] })
|
||||
const b = node({ key: 'b', leaf: true })
|
||||
const root = node({ key: 'root', leaf: false, children: [a, b] })
|
||||
return { root, a, a1, b }
|
||||
}
|
||||
|
||||
describe('useTreeExpansion', () => {
|
||||
it('toggleNode adds then removes a node key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
const n = node({ key: 'x' })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({ x: true })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNode ignores nodes without a string key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
toggleNode(node({ key: undefined }))
|
||||
toggleNode(node({ key: 42 as unknown as string }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('expandNode expands the node and all non-leaf descendants only', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
expandNode(root)
|
||||
|
||||
// root and a are folders; a1 and b are leaves and must be skipped
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
})
|
||||
|
||||
it('expandNode does nothing for a leaf node', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
expandNode(node({ key: 'leaf', leaf: true }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('collapseNode removes the node and its non-leaf descendants', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({
|
||||
root: true,
|
||||
a: true,
|
||||
stray: true
|
||||
})
|
||||
const { collapseNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
collapseNode(root)
|
||||
|
||||
expect(expandedKeys.value).toEqual({ stray: true })
|
||||
})
|
||||
|
||||
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
// Plain toggle removes only the node's own key, leaving descendants
|
||||
toggleNodeOnEvent(new MouseEvent('click'), root)
|
||||
expect(expandedKeys.value).toEqual({ a: true })
|
||||
})
|
||||
})
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
22
src/composables/useViewErrorsInGraph.ts
Normal file
22
src/composables/useViewErrorsInGraph.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -1,47 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
emitAfterChange: vi.fn(),
|
||||
emitBeforeChange: vi.fn(),
|
||||
selected_nodes: {}
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
registerNodeDef: vi.fn(),
|
||||
rootGraph: {
|
||||
convertToSubgraph: vi.fn(),
|
||||
extra: {},
|
||||
getNodeById: vi.fn(),
|
||||
links: {},
|
||||
nodes: [],
|
||||
remove: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const widgetStoreMock = vi.hoisted(() => ({
|
||||
inputIsWidget: vi.fn((spec: unknown[]) =>
|
||||
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => widgetStoreMock
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
@@ -59,42 +26,6 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
function extension(): ComfyExtension {
|
||||
const groupExtension = appMock.registerExtension.mock.calls.find(
|
||||
([registered]) => registered.name === 'Comfy.GroupNode'
|
||||
)?.[0]
|
||||
if (!groupExtension) throw new Error('GroupNode extension was not registered')
|
||||
return groupExtension as ComfyExtension
|
||||
}
|
||||
|
||||
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
extension().addCustomNodeDefs?.(defs, appMock as unknown as ComfyApp)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appMock.registerNodeDef.mockReset()
|
||||
widgetStoreMock.inputIsWidget.mockClear()
|
||||
LiteGraph.registered_node_types = {}
|
||||
addCustomNodeDefs({})
|
||||
})
|
||||
|
||||
describe('replaceLegacySeparators', () => {
|
||||
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
|
||||
const nodes = [makeNode('workflow/My Group')]
|
||||
@@ -173,398 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
|
||||
const config = configFrom([], [[0, 1, 'IMAGE']])
|
||||
expect(config.externalFrom[0][1]).toBe('IMAGE')
|
||||
})
|
||||
|
||||
it('ignores external links without a type and accumulates multiple slots', () => {
|
||||
const config = configFrom(
|
||||
[],
|
||||
[
|
||||
[0, 1, null as unknown as string],
|
||||
[0, 2, 'LATENT'],
|
||||
[0, 3, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.getNodeDef', () => {
|
||||
const imageNodeDef = makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}],
|
||||
mode: [['fast', 'slow'], {}]
|
||||
},
|
||||
optional: {
|
||||
strength: ['FLOAT', { default: 1 }]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
addCustomNodeDefs({ ImageNode: imageNodeDef })
|
||||
})
|
||||
|
||||
it('returns registered definitions for normal node types', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
|
||||
imageNodeDef
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for nodes without an index or a known type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ type: 'UnknownNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips unlinked primitive nodes', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'PrimitiveNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives primitive node type from the outgoing link type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'PrimitiveNode' },
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toMatchObject({
|
||||
input: { required: { value: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null when primitive combo target spec is not primitive', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'PrimitiveNode',
|
||||
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
|
||||
input: { required: { value: [null, {}] } },
|
||||
output: [null]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for reroutes used only inside the group', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode' },
|
||||
{ index: 1, type: 'Reroute' },
|
||||
{ index: 2, type: 'ImageNode' }
|
||||
],
|
||||
links: [
|
||||
[0, 0, 1, 0, 1, 'IMAGE'],
|
||||
[1, 0, 2, 0, 2, 'IMAGE']
|
||||
] as SerialisedLLinkArray[],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
|
||||
})
|
||||
|
||||
it('derives reroute type from outgoing target inputs', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Reroute' },
|
||||
{
|
||||
index: 1,
|
||||
type: 'ImageNode',
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
}
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives reroute type from incoming output metadata', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
|
||||
{ index: 1, type: 'Reroute' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
|
||||
external: [[1, 0, 'LATENT']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
|
||||
output: ['LATENT']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives pipe reroute type from external metadata when links omit it', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'Reroute' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'MASK']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { MASK: ['MASK', { forceInput: true }] } },
|
||||
output: ['MASK']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig input and output mapping', () => {
|
||||
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [node],
|
||||
links: [],
|
||||
external: [],
|
||||
config: {
|
||||
0: {
|
||||
input: {
|
||||
hidden: { visible: false },
|
||||
renamed: { name: 'Custom Name' }
|
||||
},
|
||||
output: {
|
||||
1: { name: 'Custom Output' },
|
||||
2: { visible: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
config.nodeDef = makeNodeDef({
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: []
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
it('renames duplicate inputs and adds seed control metadata', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'Sampler',
|
||||
title: 'Sampler A',
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }]
|
||||
})
|
||||
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
|
||||
const result = config.getInputConfig(
|
||||
{ index: 0, type: 'Sampler', title: 'Sampler A' },
|
||||
'seed',
|
||||
seenInputs,
|
||||
['INT', {}]
|
||||
)
|
||||
|
||||
expect(result.name).toBe('Sampler A 1 seed')
|
||||
expect(result.config).toEqual([
|
||||
'INT',
|
||||
{ control_after_generate: 'Sampler A control_after_generate' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps image upload widget aliases through converted widget names', () => {
|
||||
const config = configWithNode({ index: 0, type: 'LoadImage' })
|
||||
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
|
||||
|
||||
expect(
|
||||
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
|
||||
'IMAGEUPLOAD',
|
||||
{ widget: 'customImage' }
|
||||
])
|
||||
).toMatchObject({
|
||||
name: 'Custom Name',
|
||||
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splits widget inputs, socket inputs, and converted widget slots', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
})
|
||||
|
||||
const result = config.processWidgetInputs(
|
||||
{
|
||||
mode: ['COMBO', {}],
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
['mode', 'image'],
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.slots).toEqual(['image'])
|
||||
expect(result.converted.get(0)).toBe('mode')
|
||||
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
|
||||
})
|
||||
|
||||
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'InputNode'
|
||||
})
|
||||
const inputMap: Record<number, number> = {}
|
||||
config.processInputSlots(
|
||||
{
|
||||
image: ['IMAGE', {}],
|
||||
hidden: ['LATENT', {}]
|
||||
},
|
||||
{ index: 0, type: 'InputNode' },
|
||||
['image', 'hidden'],
|
||||
{},
|
||||
inputMap,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
|
||||
expect(inputMap).toEqual({ 0: 0 })
|
||||
})
|
||||
|
||||
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'OutputNode',
|
||||
title: 'Output A',
|
||||
outputs: [{ name: 'image', label: 'Rendered' }]
|
||||
})
|
||||
config.linksFrom[0] = {
|
||||
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
|
||||
}
|
||||
config.processNodeOutputs(
|
||||
{ index: 0, type: 'OutputNode', title: 'Output A' },
|
||||
{ Rendered: 1 },
|
||||
{
|
||||
input: { required: {} },
|
||||
output: ['IMAGE', 'LATENT', 'MASK'],
|
||||
output_name: ['image', 'latent', 'mask'],
|
||||
output_is_list: [false, true, false]
|
||||
}
|
||||
)
|
||||
|
||||
expect(config.outputVisibility).toEqual([false, true, false])
|
||||
expect(config.nodeDef?.output).toEqual(['LATENT'])
|
||||
expect(config.nodeDef?.output_is_list).toEqual([true])
|
||||
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.registerFromWorkflow', () => {
|
||||
it('adds missing type actions and skips registration for incomplete groups', async () => {
|
||||
const groupNodes: Record<string, GroupNodeWorkflowData> = {
|
||||
Broken: {
|
||||
nodes: [{ index: 0, type: 'MissingNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
}
|
||||
}
|
||||
const missingNodeTypes: Parameters<
|
||||
typeof GroupNodeConfig.registerFromWorkflow
|
||||
>[1] = []
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
|
||||
|
||||
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
|
||||
expect(missingNodeTypes).toHaveLength(2)
|
||||
expect(missingNodeTypes[0]).toMatchObject({
|
||||
type: 'MissingNode',
|
||||
hint: " (In group node 'workflow>Broken')"
|
||||
})
|
||||
|
||||
const action = missingNodeTypes[1]
|
||||
if (typeof action === 'string') {
|
||||
throw new Error('Expected a missing-node action entry, not a string')
|
||||
}
|
||||
|
||||
const target = document.createElement('button')
|
||||
const { callback } = action.action as {
|
||||
callback: (event: MouseEvent) => void
|
||||
}
|
||||
const event = new MouseEvent('click')
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
callback(event)
|
||||
expect(groupNodes.Broken).toBeUndefined()
|
||||
expect(target.textContent).toBe('Removed')
|
||||
expect(target.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('registers complete group node types and stores their generated node defs', async () => {
|
||||
addCustomNodeDefs({
|
||||
ImageNode: makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
})
|
||||
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
Complete: {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
|
||||
'workflow>Complete',
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
expect(useNodeDefStore().nodeDefsByName['workflow>Complete']).toEqual(
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
@@ -10,47 +11,31 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
|
||||
auth: { isLoggedIn: { value: false } },
|
||||
billing: { isActiveSubscription: { value: false } },
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
|
||||
vueFlags: { shouldRenderVueNodes: { value: false } }
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billing.isActiveSubscription
|
||||
})
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: featureFlags
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -64,7 +49,6 @@ interface MockSettingParams {
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
@@ -88,23 +72,13 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, MockSettingParams>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
auth.isLoggedIn.value = false
|
||||
billing.isActiveSubscription.value = false
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
featureFlags.teamWorkspacesEnabled = false
|
||||
featureFlags.userSecretsEnabled = false
|
||||
vueFlags.shouldRenderVueNodes.value = false
|
||||
Object.assign(window, { __CONFIG__: {} })
|
||||
|
||||
settingsById = mockSettings
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
settingsById: mockSettings
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -133,9 +107,9 @@ describe('useSettingUI', () => {
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
@@ -147,6 +121,7 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
@@ -162,82 +137,4 @@ describe('useSettingUI', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('falls back when defaultPanel is not in the menu', () => {
|
||||
const missingPanel = 'missing' as unknown as Parameters<
|
||||
typeof useSettingUI
|
||||
>[0]
|
||||
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('moves floating settings into Other and hides Vue-node-only settings', () => {
|
||||
settingsById = {
|
||||
Floating: {
|
||||
id: 'Floating',
|
||||
name: 'Floating',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
'Hidden.Setting': {
|
||||
id: 'Hidden.Setting',
|
||||
name: 'Hidden',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
'Vue.Hidden': {
|
||||
id: 'Vue.Hidden',
|
||||
name: 'Vue Hidden',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
hideInVueNodes: true
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
vueFlags.shouldRenderVueNodes.value = true
|
||||
|
||||
const { settingCategories } = useSettingUI()
|
||||
|
||||
expect(settingCategories.value.map((category) => category.label)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
expect(
|
||||
settingCategories.value[0].children?.map((node) => node.key)
|
||||
).toEqual(['root/Floating'])
|
||||
})
|
||||
|
||||
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
dist.isDesktop = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
|
||||
useSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'workspace',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'server-config',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
|
||||
expect(findCategoryByKey('missing')).toBeNull()
|
||||
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
|
||||
expect(findPanelByKey('missing')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: overlayMock.overlayMessage,
|
||||
overlayTitle: overlayMock.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
run: 'Run'
|
||||
},
|
||||
menuLabels: {
|
||||
publish: 'Publish'
|
||||
},
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Queueing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing input',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
setActivePinia(pinia)
|
||||
|
||||
useAppModeStore().selectedOutputs = [toNodeId(1)]
|
||||
if (hasError) {
|
||||
useExecutionErrorStore().lastNodeErrors = nodeErrors
|
||||
}
|
||||
|
||||
const toastTarget = document.createElement('div')
|
||||
|
||||
return render(LinearControls, {
|
||||
props: { mobile, toastTo: toastTarget },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
AppModeWidgetList: true,
|
||||
Loader: true,
|
||||
PartnerNodesList: true,
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
ScrubableNumberInput: true,
|
||||
SubscribeToRunButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])('shows a workflow error warning in $label controls', ({ mobile }) => {
|
||||
renderControls({ hasError: true, mobile })
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(
|
||||
within(warning).getByText('Required input missing')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByRole('button', { name: 'Show errors in graph' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: 'Run' })
|
||||
expect(runButton).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without graph errors',
|
||||
({ mobile }) => {
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show errors in graph' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without an active subscription',
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
renderControls({ hasError: true })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -134,9 +148,10 @@ function handleDragDrop() {
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
@@ -166,18 +181,24 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
@@ -198,9 +219,14 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing',
|
||||
viewErrorsInGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: mocks.overlayMessage,
|
||||
overlayTitle: mocks.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useViewErrorsInGraph', () => ({
|
||||
useViewErrorsInGraph: () => ({
|
||||
viewErrorsInGraph: mocks.viewErrorsInGraph
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderWarning() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(LinearRunErrorWarning, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearRunErrorWarning', () => {
|
||||
beforeEach(() => {
|
||||
mocks.viewErrorsInGraph.mockReset()
|
||||
})
|
||||
|
||||
it('shows the current error overlay title and message without a close action', () => {
|
||||
renderWarning()
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(warning).toHaveTextContent('Required input missing')
|
||||
expect(warning).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(screen.getByText('Required input missing')).toHaveAttribute(
|
||||
'title',
|
||||
'Required input missing'
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens graph errors when the action is clicked', async () => {
|
||||
const { user } = renderWarning()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show errors in graph' })
|
||||
)
|
||||
|
||||
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const { overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="linear-validation-warning"
|
||||
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
|
||||
data-testid="linear-validation-warning-description"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
|
||||
:title="overlayTitle"
|
||||
>
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2"
|
||||
data-testid="linear-validation-warning-message"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="linear-view-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{ t('linearMode.error.goto') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
@@ -1,225 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
trackNodePrice,
|
||||
usePartitionedBadges
|
||||
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
|
||||
vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
nodeDefs: {} as Record<string, unknown>,
|
||||
pricing: {
|
||||
dynamic: false,
|
||||
widgets: [] as string[],
|
||||
inputs: [] as string[],
|
||||
groups: [] as string[]
|
||||
},
|
||||
getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })),
|
||||
getWidgetMock: vi.fn(() => ({ value: 'widget-value' }))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({
|
||||
getRelevantWidgetNames: () => pricing.widgets,
|
||||
hasDynamicPricing: () => pricing.dynamic,
|
||||
getInputGroupPrefixes: () => pricing.groups,
|
||||
getInputNames: () => pricing.inputs,
|
||||
getNodeRevisionRef: getNodeRevisionRefMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settings[key] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ nodeDefsByName: nodeDefs })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: getWidgetMock })
|
||||
}))
|
||||
|
||||
function nodeData(overrides: Partial<VueNodeData> = {}): VueNodeData {
|
||||
return {
|
||||
executing: false,
|
||||
id: toNodeId(1),
|
||||
mode: 0,
|
||||
selected: false,
|
||||
title: 'Test node',
|
||||
type: 'TestNode',
|
||||
apiNode: false,
|
||||
badges: [],
|
||||
inputs: [],
|
||||
...overrides
|
||||
} satisfies VueNodeData
|
||||
}
|
||||
|
||||
function inputSlot(
|
||||
name: string,
|
||||
readLink: () => number | null
|
||||
): INodeInputSlot {
|
||||
return {
|
||||
name,
|
||||
type: '*',
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
get link() {
|
||||
return readLink()
|
||||
},
|
||||
set link(_value: number | null) {}
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function badge(text: string): LGraphBadge {
|
||||
return new LGraphBadge({ text })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
for (const k of Object.keys(nodeDefs)) delete nodeDefs[k]
|
||||
nodeDefs['TestNode'] = { isCoreNode: false }
|
||||
pricing.dynamic = false
|
||||
pricing.widgets = []
|
||||
pricing.inputs = []
|
||||
pricing.groups = []
|
||||
getNodeRevisionRefMock.mockClear()
|
||||
getWidgetMock.mockClear()
|
||||
})
|
||||
|
||||
describe('usePartitionedBadges', () => {
|
||||
it('emits no core badges when every badge mode is None', () => {
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toEqual([])
|
||||
})
|
||||
|
||||
it('tracks dynamic-pricing dependencies for an api node without throwing', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
apiNode: true,
|
||||
inputs: [
|
||||
inputSlot('model', () => 1),
|
||||
inputSlot('lora.0', () => 2),
|
||||
inputSlot('unrelated', () => null)
|
||||
]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result).toHaveProperty('core')
|
||||
expect(result).toHaveProperty('extension')
|
||||
})
|
||||
|
||||
it('adds an id badge when the id mode is enabled', () => {
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value
|
||||
expect(result.core).toContainEqual({ text: '#7' })
|
||||
})
|
||||
|
||||
it('adds a lifecycle badge, trimmed of brackets', () => {
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: '[BETA]'
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'BETA' })
|
||||
})
|
||||
|
||||
it('adds a source badge for non-core nodes when source mode is on', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeSource: { badgeText: 'my-pack' }
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'my-pack' })
|
||||
})
|
||||
|
||||
it('partitions extension badges (skipping the first) from credits badges', () => {
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge'])
|
||||
expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }])
|
||||
})
|
||||
|
||||
it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = { isCoreNode: true }
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({ badges: [badge('x')] })
|
||||
).value
|
||||
expect(result.hasComfyBadge).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackNodePrice', () => {
|
||||
it('no-ops for a node without dynamic pricing', () => {
|
||||
pricing.dynamic = false
|
||||
trackNodePrice({ id: '1', type: 'Static', inputs: [] })
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1'))
|
||||
expect(getWidgetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touches widget, input, and input-group pricing dependencies', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
let modelReads = 0
|
||||
let groupReads = 0
|
||||
let unrelatedReads = 0
|
||||
|
||||
trackNodePrice({
|
||||
id: '2',
|
||||
type: 'Dynamic',
|
||||
inputs: [
|
||||
inputSlot('model', () => {
|
||||
modelReads += 1
|
||||
return 1
|
||||
}),
|
||||
inputSlot('lora.0', () => {
|
||||
groupReads += 1
|
||||
return 2
|
||||
}),
|
||||
inputSlot('unrelated', () => {
|
||||
unrelatedReads += 1
|
||||
return null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2'))
|
||||
expect(getWidgetMock).toHaveBeenCalled()
|
||||
expect(modelReads).toBe(1)
|
||||
expect(groupReads).toBe(1)
|
||||
expect(unrelatedReads).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import {
|
||||
addToComboValues,
|
||||
compressWidgetInputSlots,
|
||||
createNode,
|
||||
executeWidgetsCallback,
|
||||
getItemsColorOption,
|
||||
getLinkTypeColor,
|
||||
getWidgetIdForNode,
|
||||
isAnimatedOutput,
|
||||
isAudioNode,
|
||||
isImageNode,
|
||||
isLoad3dNode,
|
||||
isVideoNode,
|
||||
isVideoOutput,
|
||||
migrateWidgetsValues,
|
||||
resolveComboValues,
|
||||
resolveNode,
|
||||
resolveNodeWidget
|
||||
} from './litegraphUtil'
|
||||
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
|
||||
|
||||
const mockBringNodeToFront = vi.fn()
|
||||
|
||||
@@ -216,233 +191,3 @@ describe('getWidgetIdForNode', () => {
|
||||
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('media helpers', () => {
|
||||
it('classifies preview media nodes', () => {
|
||||
expect(isImageNode(undefined)).toBe(false)
|
||||
expect(isVideoNode(undefined)).toBe(false)
|
||||
expect(isAudioNode(undefined)).toBe(false)
|
||||
|
||||
const imageNode = new LGraphNode('Image')
|
||||
imageNode.previewMediaType = 'image'
|
||||
const imageWithImgs = Object.assign(new LGraphNode('Image'), {
|
||||
previewMediaType: 'model' as const,
|
||||
imgs: [document.createElement('img')]
|
||||
})
|
||||
const videoWithImgs = Object.assign(new LGraphNode('Video'), {
|
||||
previewMediaType: 'video' as const,
|
||||
imgs: [document.createElement('img')]
|
||||
})
|
||||
const videoNode = new LGraphNode('Video')
|
||||
videoNode.previewMediaType = 'video'
|
||||
const videoContainerNode = Object.assign(new LGraphNode('Video'), {
|
||||
videoContainer: document.body
|
||||
})
|
||||
const audioNode = new LGraphNode('Audio')
|
||||
audioNode.previewMediaType = 'audio'
|
||||
|
||||
expect(isImageNode(imageNode)).toBe(true)
|
||||
expect(isImageNode(imageWithImgs)).toBe(true)
|
||||
expect(isImageNode(videoWithImgs)).toBe(false)
|
||||
expect(isVideoNode(videoNode)).toBe(true)
|
||||
expect(isVideoNode(videoContainerNode)).toBe(true)
|
||||
expect(isAudioNode(audioNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('distinguishes animated images from video outputs', () => {
|
||||
expect(isAnimatedOutput(undefined)).toBe(false)
|
||||
expect(isAnimatedOutput({ animated: [false, true] })).toBe(true)
|
||||
expect(
|
||||
isVideoOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'clip.mp4' }]
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isVideoOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'preview.webp' }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isVideoOutput({
|
||||
animated: [true],
|
||||
images: [{ filename: 'preview.png' }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('detects 3d loader nodes', () => {
|
||||
const modelNode = new LGraphNode('Load3D')
|
||||
modelNode.type = 'Load3D'
|
||||
const animationNode = new LGraphNode('Load3DAnimation')
|
||||
animationNode.type = 'Load3DAnimation'
|
||||
const imageNode = new LGraphNode('LoadImage')
|
||||
imageNode.type = 'LoadImage'
|
||||
|
||||
expect(isLoad3dNode(modelNode)).toBe(true)
|
||||
expect(isLoad3dNode(animationNode)).toBe(true)
|
||||
expect(isLoad3dNode(imageNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('combo widget helpers', () => {
|
||||
function combo(values: IComboWidget['options']['values']): IComboWidget {
|
||||
return fromPartial<IComboWidget>({
|
||||
name: 'mode',
|
||||
type: 'combo',
|
||||
value: 'a',
|
||||
options: { values }
|
||||
})
|
||||
}
|
||||
|
||||
it('resolves combo values from arrays, records, functions, and missing options', () => {
|
||||
expect(resolveComboValues(combo(['a', 'b']))).toEqual(['a', 'b'])
|
||||
expect(resolveComboValues(combo({ a: 'A', b: 'B' }))).toEqual(['a', 'b'])
|
||||
expect(resolveComboValues(combo(() => ['x']))).toEqual(['x'])
|
||||
expect(
|
||||
resolveComboValues(fromPartial<IComboWidget>({ options: {} }))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('adds only missing array combo values', () => {
|
||||
const widget = combo(['a'])
|
||||
|
||||
addToComboValues(widget, 'b')
|
||||
addToComboValues(widget, 'b')
|
||||
|
||||
expect(widget.options.values).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('node utility helpers', () => {
|
||||
it('returns a shared color option only when all colorable items match', () => {
|
||||
const red = { getColorOption: () => 'red', setColorOption: vi.fn() }
|
||||
const redAgain = { getColorOption: () => 'red', setColorOption: vi.fn() }
|
||||
const blue = { getColorOption: () => 'blue', setColorOption: vi.fn() }
|
||||
|
||||
expect(getItemsColorOption([red, redAgain, {}])).toBe('red')
|
||||
expect(getItemsColorOption([red, blue])).toBeNull()
|
||||
expect(getItemsColorOption([{}])).toBeNull()
|
||||
})
|
||||
|
||||
it('executes matching callbacks on node widgets', () => {
|
||||
const onRemove = vi.fn()
|
||||
const afterQueued = vi.fn()
|
||||
const node = new LGraphNode('Callbacks')
|
||||
node.widgets = [
|
||||
fromPartial<IBaseWidget>({ onRemove }),
|
||||
fromPartial<IBaseWidget>({ afterQueued })
|
||||
]
|
||||
|
||||
executeWidgetsCallback([node], 'onRemove')
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
expect(afterQueued).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns configured link colors with the default fallback', () => {
|
||||
expect(getLinkTypeColor('missing-type')).toBe(LiteGraph.LINK_COLOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy workflow migration helpers', () => {
|
||||
it('drops legacy force-input widget values only when lengths match', () => {
|
||||
const inputDefs = {
|
||||
seed: { name: 'seed', type: 'INT', forceInput: true },
|
||||
mode: { name: 'mode', type: 'STRING' },
|
||||
batch: {
|
||||
name: 'batch',
|
||||
type: 'INT',
|
||||
control_after_generate: true
|
||||
}
|
||||
}
|
||||
const widgets = [
|
||||
fromPartial<IBaseWidget>({ name: 'mode' }),
|
||||
fromPartial<IBaseWidget>({ name: 'batch' })
|
||||
]
|
||||
|
||||
expect(migrateWidgetsValues(inputDefs, widgets, [1, 2, 3, 4])).toEqual([
|
||||
2, 3, 4
|
||||
])
|
||||
expect(migrateWidgetsValues(inputDefs, widgets, [1, 2])).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('compresses root and subgraph widget input slots', () => {
|
||||
const graph = fromPartial<ISerialisedGraph>({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Node',
|
||||
inputs: [
|
||||
{
|
||||
name: 'widget',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
widget: { name: 'w' }
|
||||
},
|
||||
{ name: 'kept', type: 'STRING', link: 7 }
|
||||
]
|
||||
}
|
||||
],
|
||||
links: [[7, 2, 0, 1, 99, 'STRING']],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
name: 'Subgraph',
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'Inner',
|
||||
inputs: [
|
||||
{
|
||||
name: 'legacy',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
widget: { name: 'legacy' }
|
||||
},
|
||||
{ name: 'inner', type: 'STRING', link: 8 }
|
||||
]
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 8,
|
||||
origin_id: 4,
|
||||
origin_slot: 0,
|
||||
target_id: 3,
|
||||
target_slot: 42,
|
||||
type: 'STRING'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
compressWidgetInputSlots(graph)
|
||||
|
||||
expect(graph.nodes[0].inputs?.map((input) => input.name)).toEqual(['kept'])
|
||||
expect(graph.links[0][4]).toBe(0)
|
||||
const subgraph = graph.definitions?.subgraphs?.[0]
|
||||
expect(subgraph?.nodes?.[0].inputs?.map((input) => input.name)).toEqual([
|
||||
'inner'
|
||||
])
|
||||
expect(subgraph?.links?.[0].target_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNodeWidget', () => {
|
||||
it('resolves root graph nodes and widgets', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('TestNode')
|
||||
const widget = node.addWidget('text', 'prompt', 'hello', () => {})
|
||||
graph.add(node)
|
||||
|
||||
expect(resolveNodeWidget(node.id, undefined, graph)).toEqual([node])
|
||||
expect(resolveNodeWidget(node.id, 'prompt', graph)).toEqual([node, widget])
|
||||
expect(resolveNodeWidget(node.id, 'missing', graph)).toEqual([])
|
||||
expect(resolveNodeWidget('not-a-node-id', 'prompt', graph)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
|
||||
|
||||
type RegistryNode = components['schemas']['ComfyNode']
|
||||
type RegistryPack = components['schemas']['Node']
|
||||
|
||||
function nodeDef(over: Partial<RegistryNode> = {}): RegistryNode {
|
||||
return over as RegistryNode
|
||||
}
|
||||
|
||||
function pack(over: Partial<RegistryPack> = {}): RegistryPack {
|
||||
return over as RegistryPack
|
||||
}
|
||||
|
||||
describe('registryToFrontendV2NodeDef', () => {
|
||||
it('maps outputs, defaulting names to types and is_list to false', () => {
|
||||
const def = registryToFrontendV2NodeDef(
|
||||
nodeDef({
|
||||
return_types: '["INT","IMAGE"]',
|
||||
return_names: '["count",""]',
|
||||
output_is_list: [true]
|
||||
}),
|
||||
pack()
|
||||
)
|
||||
|
||||
expect(def.outputs).toEqual([
|
||||
{ type: 'INT', name: 'count', is_list: true, index: 0 },
|
||||
{ type: 'IMAGE', name: 'IMAGE', is_list: false, index: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no outputs when return_types is empty or absent', () => {
|
||||
expect(
|
||||
registryToFrontendV2NodeDef(nodeDef({ return_types: '[]' }), pack())
|
||||
.outputs
|
||||
).toEqual([])
|
||||
expect(registryToFrontendV2NodeDef(nodeDef(), pack()).outputs).toEqual([])
|
||||
})
|
||||
|
||||
it('maps required and optional inputs into keyed specs', () => {
|
||||
const def = registryToFrontendV2NodeDef(
|
||||
nodeDef({
|
||||
input_types: JSON.stringify({
|
||||
required: { seed: ['INT', { default: 0 }] },
|
||||
optional: { label: ['STRING', {}] }
|
||||
})
|
||||
}),
|
||||
pack()
|
||||
)
|
||||
|
||||
expect(def.inputs).toEqual({
|
||||
seed: { type: 'INT', name: 'seed', isOptional: false, default: 0 },
|
||||
label: { type: 'STRING', name: 'label', isOptional: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no inputs when input_types is empty or absent', () => {
|
||||
expect(registryToFrontendV2NodeDef(nodeDef(), pack()).inputs).toEqual({})
|
||||
expect(
|
||||
registryToFrontendV2NodeDef(nodeDef({ input_types: '{}' }), pack()).inputs
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('applies field fallbacks for name, category, and python_module', () => {
|
||||
const def = registryToFrontendV2NodeDef(nodeDef(), pack({ id: 'pack-id' }))
|
||||
|
||||
expect(def.name).toBe('Node Name')
|
||||
expect(def.display_name).toBe('Node Name')
|
||||
expect(def.category).toBe('unknown')
|
||||
expect(def.python_module).toBe('pack-id') // name absent -> falls back to id
|
||||
})
|
||||
|
||||
it('prefers explicit values over fallbacks', () => {
|
||||
const def = registryToFrontendV2NodeDef(
|
||||
nodeDef({ comfy_node_name: 'KSampler', category: 'sampling' }),
|
||||
pack({ name: 'comfy-core' })
|
||||
)
|
||||
|
||||
expect(def.name).toBe('KSampler')
|
||||
expect(def.category).toBe('sampling')
|
||||
expect(def.python_module).toBe('comfy-core')
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
@@ -715,7 +715,7 @@ export default defineConfig({
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],
|
||||
include: COVERAGE_CRITICAL
|
||||
? CRITICAL_COVERAGE_INCLUDE
|
||||
: ['src/**/*.{ts,vue}'],
|
||||
|
||||
Reference in New Issue
Block a user