chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

248
src/utils/colorUtil.test.ts Normal file
View File

@@ -0,0 +1,248 @@
import { describe, expect, it, vi } from 'vitest'
import {
adjustColor,
hexToRgb,
hsbToRgb,
parseToRgb,
rgbToHex
} from '@/utils/colorUtil'
interface ColorTestCase {
hex: string
rgb: string
rgba: string
hsl: string
hsla: string
lightExpected: string
transparentExpected: string
lightTransparentExpected: string
}
type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
vi.mock('es-toolkit/compat', () => ({
memoize: (fn: any) => fn
}))
const targetOpacity = 0.5
const targetLightness = 0.5
const assertColorVariationsMatch = (variations: string[], adjustment: any) => {
for (let i = 0; i < variations.length - 1; i++) {
expect(adjustColor(variations[i], adjustment)).toBe(
adjustColor(variations[i + 1], adjustment)
)
}
}
const colors: Record<string, ColorTestCase> = {
green: {
hex: '#073642',
rgb: 'rgb(7, 54, 66)',
rgba: 'rgba(7, 54, 66, 1)',
hsl: 'hsl(192, 80.8%, 14.3%)',
hsla: 'hsla(192, 80.8%, 14.3%, 1)',
lightExpected: 'hsla(192, 80.8%, 64.3%, 1)',
transparentExpected: 'hsla(192, 80.8%, 14.3%, 0.5)',
lightTransparentExpected: 'hsla(192, 80.8%, 64.3%, 0.5)'
},
blue: {
hex: '#00008B',
rgb: 'rgb(0,0,139)',
rgba: 'rgba(0,0,139,1)',
hsl: 'hsl(240,100%,27.3%)',
hsla: 'hsl(240,100%,27.3%,1)',
lightExpected: 'hsla(240, 100%, 77.3%, 1)',
transparentExpected: 'hsla(240, 100%, 27.3%, 0.5)',
lightTransparentExpected: 'hsla(240, 100%, 77.3%, 0.5)'
}
}
const formats: ColorFormat[] = ['hex', 'rgb', 'rgba', 'hsl', 'hsla']
describe('colorUtil conversions', () => {
describe('hexToRgb / rgbToHex', () => {
it('converts 6-digit hex to RGB', () => {
expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts 3-digit hex to RGB', () => {
expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts RGB to lowercase #hex and clamps values', () => {
expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000')
expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00')
expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff')
// out-of-range should clamp
expect(rgbToHex({ r: -10, g: 300, b: 16 })).toBe('#00ff10')
})
it('round-trips #hex -> rgb -> #hex', () => {
const hex = '#123abc'
expect(rgbToHex(hexToRgb(hex))).toBe('#123abc')
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses rgb()/rgba()', () => {
expect(parseToRgb('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 })
expect(parseToRgb('rgba(255,0,0,0.5)')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses hsl()/hsla()', () => {
expect(parseToRgb('hsl(0, 100%, 50%)')).toEqual({ r: 255, g: 0, b: 0 })
const green = parseToRgb('hsla(120, 100%, 50%, 0.7)')
expect(green.r).toBe(0)
expect(green.g).toBe(255)
expect(green.b).toBe(0)
})
})
describe('hsbToRgb', () => {
it('converts HSB to primary RGB colors', () => {
expect(hsbToRgb({ h: 0, s: 100, b: 100 })).toEqual({ r: 255, g: 0, b: 0 })
expect(hsbToRgb({ h: 120, s: 100, b: 100 })).toEqual({
r: 0,
g: 255,
b: 0
})
expect(hsbToRgb({ h: 240, s: 100, b: 100 })).toEqual({
r: 0,
g: 0,
b: 255
})
})
it('handles non-100 brightness and clamps/normalizes input', () => {
const rgb = hsbToRgb({ h: 360, s: 150, b: 50 })
expect(rgbToHex(rgb)).toBe('#7f0000')
})
})
})
describe('colorUtil - adjustColor', () => {
const runAdjustColorTests = (
color: ColorTestCase,
format: ColorFormat
): void => {
it('converts lightness', () => {
const result = adjustColor(color[format], { lightness: targetLightness })
expect(result).toBe(color.lightExpected)
})
it('applies opacity', () => {
const result = adjustColor(color[format], { opacity: targetOpacity })
expect(result).toBe(color.transparentExpected)
})
it('applies lightness and opacity jointly', () => {
const result = adjustColor(color[format], {
lightness: targetLightness,
opacity: targetOpacity
})
expect(result).toBe(color.lightTransparentExpected)
})
}
describe.each(Object.entries(colors))('%s color', (_colorName, color) => {
describe.each(formats)('%s format', (format) => {
runAdjustColorTests(color, format as ColorFormat)
})
})
it('returns the original value for invalid color formats', () => {
const invalidColors = [
'cmky(100, 50, 50, 0.5)',
'rgb(300, -10, 256)',
'xyz(255, 255, 255)',
'hsl(100, 50, 50%)',
'hsl(100, 50%, 50)',
'#GGGGGG',
'#3333'
]
invalidColors.forEach((color) => {
const result = adjustColor(color, {
lightness: targetLightness,
opacity: targetOpacity
})
expect(result).toBe(color)
})
})
it('returns the original value for null or undefined inputs', () => {
// @ts-expect-error fixme ts strict error
expect(adjustColor(null, { opacity: targetOpacity })).toBe(null)
// @ts-expect-error fixme ts strict error
expect(adjustColor(undefined, { opacity: targetOpacity })).toBe(undefined)
})
describe('handles input variations', () => {
it('handles spaces in rgb input', () => {
const variations = [
'rgb(0, 0, 0)',
'rgb(0,0,0)',
'rgb(0, 0,0)',
'rgb(0,0, 0)'
]
assertColorVariationsMatch(variations, { lightness: 0.5 })
})
it('handles spaces in hsl input', () => {
const variations = [
'hsl(0, 0%, 0%)',
'hsl(0,0%,0%)',
'hsl(0, 0%,0%)',
'hsl(0,0%, 0%)'
]
assertColorVariationsMatch(variations, { lightness: 0.5 })
})
it('handles different decimal places in rgba input', () => {
const variations = [
'rgba(0, 0, 0, 0.5)',
'rgba(0, 0, 0, 0.50)',
'rgba(0, 0, 0, 0.500)'
]
assertColorVariationsMatch(variations, { opacity: 0.5 })
})
it('handles different decimal places in hsla input', () => {
const variations = [
'hsla(0, 0%, 0%, 0.5)',
'hsla(0, 0%, 0%, 0.50)',
'hsla(0, 0%, 0%, 0.500)'
]
assertColorVariationsMatch(variations, { opacity: 0.5 })
})
})
describe('clamps values correctly', () => {
it('clamps lightness to 0 and 100', () => {
expect(adjustColor('hsl(0, 100%, 50%)', { lightness: -1 })).toBe(
'hsla(0, 100%, 0%, 1)'
)
expect(adjustColor('hsl(0, 100%, 50%)', { lightness: 1.5 })).toBe(
'hsla(0, 100%, 100%, 1)'
)
})
it('clamps opacity to 0 and 1', () => {
expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: -0.5 })).toBe(
'hsla(0, 0%, 0%, 0)'
)
expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: 1.5 })).toBe(
'hsla(0, 0%, 0%, 1)'
)
})
})
})

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GroupNodeHandler } from '@/extensions/core/groupNode'
import type {
ExecutableLGraphNode,
ExecutionId,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
describe('ExecutableGroupNodeChildDTO', () => {
let mockNode: LGraphNode
let mockInputNode: LGraphNode
let mockNodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>
let mockGroupNodeHandler: GroupNodeHandler
beforeEach(() => {
// Create mock nodes
mockNode = {
id: '3', // Simple node ID for most tests
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
mockInputNode = {
id: '1',
graph: {}
} as any
// Create the nodesByExecutionId map
mockNodesByExecutionId = new Map()
mockGroupNodeHandler = {} as GroupNodeHandler
})
describe('resolveInput', () => {
it('should resolve input from external node (node outside the group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// External node with ID '1'
const externalNodeDto = {
id: '1',
type: 'TestNode'
} as ExecutableLGraphNode
mockNodesByExecutionId.set('1', externalNodeDto)
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[], // No subgraph path - group is in root graph
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: externalNodeDto,
origin_id: '1',
origin_slot: 0
})
})
it('should resolve input from internal node (node inside the same group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
// Internal node with ID '10:2'
const internalInputNode = {
id: '10:2',
graph: {}
} as LGraphNode
const internalNodeDto = {
id: '2',
type: 'InternalNode'
} as ExecutableLGraphNode
// Internal nodes are stored with just their index
mockNodesByExecutionId.set('2', internalNodeDto)
groupNodeChild.getInputNode.mockReturnValue(internalInputNode)
groupNodeChild.getInputLink.mockReturnValue({
origin_slot: 1
})
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: internalNodeDto,
origin_id: '10:2',
origin_slot: 1
})
})
it('should return undefined if no input node exists', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toBeUndefined()
})
it('should throw error if input link is missing', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow('Failed to get input link')
})
it('should throw error if input node cannot be found in nodesByExecutionId', () => {
// Node exists but is not in the map
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue({
origin_slot: 0
})
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId, // Empty map
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Failed to get input node 1 for group node child 3 with slot 0'
)
})
it('should throw error for group nodes inside subgraphs (unsupported)', () => {
// Setup: Group node child inside a subgraph (execution ID has more than 2 segments)
const nestedGroupNode = {
id: '1:2:3', // subgraph:groupnode:innernode
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// Create DTO with deeply nested path to simulate group node inside subgraph
const dto = new ExecutableGroupNodeChildDTO(
nestedGroupNode,
['1', '2'], // Path indicating it's inside a subgraph then group
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
describe('hostWhitelist utils', () => {
describe('normalizeHost', () => {
it.each([
['LOCALHOST', 'localhost'],
['localhost.', 'localhost'], // trims trailing dot
['localhost:5173', 'localhost'], // strips :port
['127.0.0.1:5173', '127.0.0.1'], // strips :port
['[::1]:5173', '::1'], // strips brackets + :port
['[::1]', '::1'], // strips brackets
['::1', '::1'], // leaves plain IPv6
[' [::1] ', '::1'], // trims whitespace
['APP.LOCALHOST', 'app.localhost'], // lowercases
['example.com.', 'example.com'], // trims trailing dot
['[2001:db8::1]:8443', '2001:db8::1'], // IPv6 with brackets+port
['2001:db8::1', '2001:db8::1'] // plain IPv6 stays
])('normalizeHost(%o) -> %o', (input, expected) => {
expect(normalizeHost(input)).toBe(expected)
})
it('does not strip non-numeric suffixes (not a port pattern)', () => {
expect(normalizeHost('example.com:abc')).toBe('example.com:abc')
expect(normalizeHost('127.0.0.1:abc')).toBe('127.0.0.1:abc')
})
})
describe('isHostWhitelisted', () => {
describe('localhost label', () => {
it.each([
'localhost',
'LOCALHOST',
'localhost.',
'localhost:5173',
'foo.localhost',
'Foo.Localhost',
'sub.foo.localhost',
'foo.localhost:5173'
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'localhost.com',
'evil-localhost',
'notlocalhost',
'foo.localhost.evil'
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
})
describe('IPv4 127/8 loopback', () => {
it.each([
'127.0.0.1',
'127.1.2.3',
'127.255.255.255',
'127.0.0.1:3000',
'127.000.000.001', // leading zeros are still digits 0-255
'127.0.0.1.' // trailing dot should be tolerated
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'126.0.0.1',
'127.256.0.1',
'127.-1.0.1',
'127.0.0.1:abc',
'128.0.0.1',
'192.168.1.10',
'10.0.0.2',
'0.0.0.0',
'255.255.255.255',
'127.0.0', // malformed
'127.0.0.1.5' // malformed
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
})
describe('IPv6 loopback ::1 (all textual forms)', () => {
it.each([
'::1',
'[::1]',
'[::1]:5173',
'::0001',
'0:0:0:0:0:0:0:1',
'0000:0000:0000:0000:0000:0000:0000:0001',
// Compressed equivalents of ::1 (with zeros compressed)
'0:0::1',
'0:0:0:0:0:0::1',
'::0:1' // compressing the initial zeros (still ::1 when expanded)
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'::2',
'::',
'::0',
'0:0:0:0:0:0:0:2',
'fe80::1', // link-local, not loopback
'2001:db8::1',
'::1:5173', // bracketless "port-like" suffix must not pass
':::1', // invalid (triple colon)
'0:0:0:0:0:0:::1', // invalid compression
'[::1%25lo0]',
'[::1%25lo0]:5173',
'::1%25lo0'
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
it('should reject empty/whitespace-only input', () => {
expect(isHostWhitelisted('')).toBe(false)
expect(isHostWhitelisted(' ')).toBe(false)
})
})
describe('comfy.org hosts', () => {
it.each([
'staging.comfy.org',
'stagingcloud.comfy.org',
'pr-123.testingcloud.comfy.org',
'api.v2.staging.comfy.org'
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'comfy.org.evil.com',
'evil-comfy.org',
'comfy.organization',
'notcomfy.org',
'comfy.org.hacker.net',
'mycomfy.org.example.com'
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
})
})
})

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
compressWidgetInputSlots,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {
normalInput: {
type: 'INT',
name: 'normalInput'
},
forceInputField: {
type: 'STRING',
name: 'forceInputField',
forceInput: true
},
anotherNormal: {
type: 'FLOAT',
name: 'anotherNormal'
}
}
const widgets: IWidget[] = [
{ name: 'normalInput', type: 'number' },
{ name: 'anotherNormal', type: 'number' }
] as unknown as IWidget[]
const widgetValues = [42, 'dummy value', 3.14]
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([42, 3.14])
})
it('should return original values if lengths do not match', () => {
const inputDefs: Record<string, InputSpec> = {
input1: {
type: 'INT',
name: 'input1',
forceInput: true
}
}
const widgets: IWidget[] = []
const widgetValues = [42, 'extra value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(widgetValues)
})
it('should handle empty widgets and values', () => {
const inputDefs: Record<string, InputSpec> = {}
const widgets: IWidget[] = []
const widgetValues: any[] = []
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual([])
})
it('should preserve order of non-forceInput widget values', () => {
const inputDefs: Record<string, InputSpec> = {
first: {
type: 'INT',
name: 'first'
},
forced: {
type: 'STRING',
name: 'forced',
forceInput: true
},
last: {
type: 'FLOAT',
name: 'last'
}
}
const widgets: IWidget[] = [
{ name: 'first', type: 'number' },
{ name: 'last', type: 'number' }
] as unknown as IWidget[]
const widgetValues = ['first value', 'dummy', 'last value']
const result = migrateWidgetsValues(inputDefs, widgets, widgetValues)
expect(result).toEqual(['first value', 'last value'])
})
})
describe('compressWidgetInputSlots', () => {
it('should remove unconnected widget input slots', () => {
const graph: ISerialisedGraph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: null, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [[2, 1, 0, 1, 0, 'INT']]
} as unknown as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' }
])
})
it('should update link target slots correctly', () => {
const graph: ISerialisedGraph = {
nodes: [
{
id: 1,
type: 'foo',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [
{ widget: { name: 'foo' }, link: null, type: 'INT', name: 'foo' },
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
],
outputs: []
}
],
links: [
[2, 1, 0, 1, 1, 'INT'],
[3, 1, 0, 1, 2, 'INT']
]
} as unknown as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs).toEqual([
{ widget: { name: 'bar' }, link: 2, type: 'INT', name: 'bar' },
{ widget: { name: 'baz' }, link: 3, type: 'INT', name: 'baz' }
])
expect(graph.links).toEqual([
[2, 1, 0, 1, 0, 'INT'],
[3, 1, 0, 1, 1, 'INT']
])
})
it('should handle graphs with no nodes gracefully', () => {
const graph: ISerialisedGraph = {
nodes: [],
links: []
} as unknown as ISerialisedGraph
compressWidgetInputSlots(graph)
expect(graph.nodes).toEqual([])
expect(graph.links).toEqual([])
})
})

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
describe('markdownRendererUtil', () => {
describe('renderMarkdownToHtml', () => {
it('should render basic markdown to HTML', () => {
const markdown = '# Hello\n\nThis is a test.'
const html = renderMarkdownToHtml(markdown)
expect(html).toContain('<h1')
expect(html).toContain('Hello')
expect(html).toContain('<p>')
expect(html).toContain('This is a test.')
})
it('should render links with target="_blank" and rel="noopener noreferrer"', () => {
const markdown = '[Click here](https://example.com)'
const html = renderMarkdownToHtml(markdown)
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
expect(html).toContain('href="https://example.com"')
expect(html).toContain('Click here')
})
it('should render multiple links with target="_blank"', () => {
const markdown =
'[Link 1](https://example.com) and [Link 2](https://test.com)'
const html = renderMarkdownToHtml(markdown)
const targetBlankMatches = html.match(/target="_blank"/g)
expect(targetBlankMatches).toHaveLength(2)
const relMatches = html.match(/rel="noopener noreferrer"/g)
expect(relMatches).toHaveLength(2)
})
it('should handle relative image paths with baseUrl', () => {
const markdown = '![Alt text](image.png)'
const baseUrl = 'https://cdn.example.com'
const html = renderMarkdownToHtml(markdown, baseUrl)
expect(html).toContain(`src="${baseUrl}/image.png"`)
expect(html).toContain('alt="Alt text"')
})
it('should not modify absolute image URLs', () => {
const markdown = '![Alt text](https://example.com/image.png)'
const baseUrl = 'https://cdn.example.com'
const html = renderMarkdownToHtml(markdown, baseUrl)
expect(html).toContain('src="https://example.com/image.png"')
expect(html).not.toContain(baseUrl)
})
it('should handle empty markdown', () => {
const html = renderMarkdownToHtml('')
expect(html).toBe('')
})
it('should sanitize potentially dangerous HTML', () => {
const markdown = '<script>alert("xss")</script>'
const html = renderMarkdownToHtml(markdown)
expect(html).not.toContain('<script>')
expect(html).not.toContain('alert')
})
it('should allow video tags with proper attributes', () => {
const markdown =
'<video src="video.mp4" controls autoplay loop muted></video>'
const html = renderMarkdownToHtml(markdown)
expect(html).toContain('<video')
expect(html).toContain('src="video.mp4"')
expect(html).toContain('controls')
})
it('should render links with title attribute', () => {
const markdown = '[Link](https://example.com "This is a title")'
const html = renderMarkdownToHtml(markdown)
expect(html).toContain('title="This is a title"')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('should handle bare URLs (autolinks)', () => {
const markdown = 'Visit https://example.com for more info.'
const html = renderMarkdownToHtml(markdown)
expect(html).toContain('href="https://example.com"')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('should render complex markdown with links, images, and text', () => {
const markdown = `
# Release Notes
Check out our [documentation](https://docs.example.com) for more info.
![Screenshot](screenshot.png)
Visit our [homepage](https://example.com) to learn more.
`
const baseUrl = 'https://cdn.example.com'
const html = renderMarkdownToHtml(markdown, baseUrl)
// Check links have target="_blank"
const targetBlankMatches = html.match(/target="_blank"/g)
expect(targetBlankMatches).toHaveLength(2)
// Check image has baseUrl prepended
expect(html).toContain(`${baseUrl}/screenshot.png`)
// Check heading
expect(html).toContain('Release Notes')
})
})
})

110
src/utils/mathUtil.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
describe('mathUtil', () => {
describe('gcd', () => {
it('should compute greatest common divisor correctly', () => {
expect(gcd(48, 18)).toBe(6)
expect(gcd(100, 25)).toBe(25)
expect(gcd(17, 13)).toBe(1)
expect(gcd(0, 5)).toBe(5)
expect(gcd(5, 0)).toBe(5)
})
it('should handle negative numbers', () => {
expect(gcd(-48, 18)).toBe(6)
expect(gcd(48, -18)).toBe(6)
expect(gcd(-48, -18)).toBe(6)
})
it('should not cause stack overflow with small floating-point step values', () => {
// This would cause Maximum call stack size exceeded with recursive impl
// when used in lcm calculations with small step values
expect(() => gcd(0.0001, 0.0003)).not.toThrow()
expect(() => gcd(1e-10, 1e-9)).not.toThrow()
})
})
describe('lcm', () => {
it('should compute least common multiple correctly', () => {
expect(lcm(4, 6)).toBe(12)
expect(lcm(15, 20)).toBe(60)
expect(lcm(7, 11)).toBe(77)
})
})
describe('computeUnionBounds', () => {
it('should return null for empty input', () => {
expect(computeUnionBounds([])).toBe(null)
})
// Tests for tuple format (ReadOnlyRect)
it('should work with ReadOnlyRect tuple format', () => {
const tuples: ReadOnlyRect[] = [
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 10, // min(10, 50)
y: 10, // min(20, 10)
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
})
})
it('should handle single ReadOnlyRect tuple', () => {
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
const result = computeUnionBounds([tuple])
expect(result).toEqual({
x: 10,
y: 20,
width: 30,
height: 40
})
})
it('should handle tuple format with negative dimensions', () => {
const tuples: ReadOnlyRect[] = [
[100, 50, -20, -10] as const, // x+width=80, y+height=40
[90, 45, 15, 20] as const // x+width=105, y+height=65
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 90, // min(100, 90)
y: 45, // min(50, 45)
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
})
})
it('should maintain optimal performance with SoA tuples', () => {
// Test that array access is as expected for typical selection sizes
const tuples: ReadOnlyRect[] = Array.from(
{ length: 10 },
(_, i) =>
[
i * 20, // x
i * 15, // y
100 + i * 5, // width
80 + i * 3 // height
] as const
)
const result = computeUnionBounds(tuples)
expect(result).toBeTruthy()
expect(result!.x).toBe(0)
expect(result!.y).toBe(0)
expect(result!.width).toBe(325)
expect(result!.height).toBe(242)
})
})
})

View File

@@ -0,0 +1,288 @@
{
"last_node_id": 27,
"last_link_id": 34,
"nodes": [
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
47.948699951171875,
239.2628173828125
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
13,
31
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 13,
"type": "Reroute",
"pos": [
510,
280
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 32
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"slot_index": 0,
"links": [
21
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 25,
"type": "Reroute",
"pos": [
404.7915344238281,
280.9454650878906
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 31
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
32,
33
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 27,
"type": "Reroute",
"pos": [
514,
386
],
"size": [
75,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 33
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
34
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 26,
"type": "VAEDecode",
"pos": [
625,
373
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 34
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
21,
13,
0,
12,
1,
"VAE"
],
[
31,
4,
2,
25,
0,
"*"
],
[
32,
25,
0,
13,
0,
"*"
],
[
33,
25,
0,
27,
0,
"*"
],
[
34,
27,
0,
26,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 2.3195551508147507,
"offset": [
96.55985005696607,
-41.449812921703376
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,343 @@
{
"last_node_id": 31,
"last_link_id": 38,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
47.948699951171875,
239.2628173828125
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
13,
31
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 13,
"type": "Reroute",
"pos": [
510,
280
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "",
"type": "*",
"slot_index": 0,
"links": [
21
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 25,
"type": "Reroute",
"pos": [
401.49267578125,
278.96600341796875
],
"size": [
75,
26
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 31
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": []
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 28,
"type": "Reroute",
"pos": [
401.874267578125,
354.56207275390625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "",
"type": "*",
"links": null
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 30,
"type": "Reroute",
"pos": [
533.9331665039062,
433.1788330078125
],
"size": [
75,
26
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 37
}
],
"outputs": [
{
"name": "",
"type": "*",
"links": null
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 29,
"type": "Reroute",
"pos": [
535.5305786132812,
350.32513427734375
],
"size": [
75,
26
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 38
}
],
"outputs": [
{
"name": "",
"type": "*",
"links": []
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 31,
"type": "Reroute",
"pos": [
405.2763671875,
431.85943603515625
],
"size": [
75,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": null
}
],
"outputs": [
{
"name": "",
"type": "*",
"links": [
37,
38
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
}
],
"links": [
[
21,
13,
0,
12,
1,
"VAE"
],
[
31,
4,
2,
25,
0,
"*"
],
[
37,
31,
0,
30,
0,
"*"
],
[
38,
31,
0,
29,
0,
"*"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.2526143885641918,
"offset": [
449.14488520559655,
200.06933385457722
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,253 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 33,
"type": "Reroute",
"pos": [
492.768310546875,
274.761962890625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 41
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
40
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 32,
"type": "Reroute",
"pos": [
362.8304138183594,
275.12872314453125
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 39
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
41,
42
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
39
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 34,
"type": "Reroute",
"pos": [
490.8152770996094,
364.4836730957031
],
"size": [
75,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 42
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": null
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
}
],
"links": [
[
39,
4,
2,
32,
0,
"*"
],
[
40,
33,
0,
12,
1,
"VAE"
],
[
41,
32,
0,
33,
0,
"*"
],
[
42,
32,
0,
34,
0,
"*"
]
],
"floatingLinks": [
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*"
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": []
},
"version": 0.4
}

View File

@@ -0,0 +1,154 @@
{
"last_node_id": 24,
"last_link_id": 30,
"nodes": [
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 13,
"type": "Reroute",
"pos": [
510,
280
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 13
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"slot_index": 0,
"links": [
21
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
160,
240
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
13
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
}
],
"links": [
[
13,
4,
2,
13,
0,
"*"
],
[
21,
13,
0,
12,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 2.7130434782608694,
"offset": [
-35,
-40.86698717948718
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,202 @@
{
"last_node_id": 27,
"last_link_id": 34,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
47.948699951171875,
239.2628173828125
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
21,
34
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 26,
"type": "VAEDecode",
"pos": [
625,
373
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 34
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
21,
4,
2,
12,
1,
"VAE"
],
[
34,
4,
2,
26,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 2.3195551508147507,
"offset": [
96.55985005696607,
-41.449812921703376
]
},
"reroutes": [
{
"id": 1,
"pos": [
547.5,
293
],
"linkIds": [
21
],
"parentId": 2
},
{
"id": 2,
"pos": [
442.2915344238281,
293.9454650878906
],
"linkIds": [
21,
34
]
},
{
"id": 3,
"pos": [
551.5,
399
],
"linkIds": [
34
],
"parentId": 2
}
],
"linkExtensions": [
{
"id": 21,
"parentId": 1
},
{
"id": 34,
"parentId": 3
}
]
},
"version": 0.4
}

View File

@@ -0,0 +1,148 @@
{
"last_node_id": 31,
"last_link_id": 38,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
47.948699951171875,
239.2628173828125
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.2526143885641918,
"offset": [
449.14488520559655,
200.06933385457722
]
},
"reroutes": [
{
"id": 1,
"pos": [
547.5,
293
],
"linkIds": [],
"floating": {
"slotType": "input"
}
},
{
"id": 2,
"pos": [
438.99267578125,
291.96600341796875
],
"linkIds": [],
"floating": {
"slotType": "output"
}
}
],
"linkExtensions": []
},
"version": 0.4,
"floatingLinks": [
{
"id": 7,
"origin_id": -1,
"origin_slot": -1,
"target_id": 12,
"target_slot": 1,
"type": "VAE",
"parentId": 1
},
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 2
}
]
}

View File

@@ -0,0 +1,166 @@
{
"last_node_id": 36,
"last_link_id": 44,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
-0.6348835229873657,
238.0631866455078
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
40
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
611.6028442382812,
254.6018524169922
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 40
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
40,
4,
2,
12,
1,
"VAE"
]
],
"floatingLinks": [
{
"id": 4,
"origin_id": 4,
"origin_slot": 2,
"target_id": -1,
"target_slot": -1,
"type": "*",
"parentId": 3
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.6672297511789418,
"offset": [
262.0504372113823,
124.35120995663942
]
},
"linkExtensions": [
{
"id": 40,
"parentId": 1
}
],
"reroutes": [
{
"id": 1,
"pos": [
530.268310546875,
287.761962890625
],
"linkIds": [
40
],
"parentId": 2
},
{
"id": 2,
"pos": [
400.3304138183594,
288.12872314453125
],
"linkIds": [
40
]
},
{
"id": 3,
"pos": [
528.3152770996094,
377.4836730957031
],
"linkIds": [],
"parentId": 2,
"floating": {
"slotType": "output"
}
}
]
},
"version": 0.4
}

View File

@@ -0,0 +1,128 @@
{
"last_node_id": 24,
"last_link_id": 30,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
160,
240
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": []
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": []
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
21
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 12,
"type": "VAEDecode",
"pos": [
620,
260
],
"size": [
210,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [
[
21,
4,
2,
12,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 2.7130434782608694,
"offset": [
-35,
-40.86698717948718
]
},
"reroutes": [
{
"id": 1,
"pos": [
547.5,
293
],
"linkIds": [
21
]
}
],
"linkExtensions": [
{
"id": 21,
"parentId": 1
}
]
},
"version": 0.4
}

View File

@@ -0,0 +1,37 @@
import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { WorkflowJSON04 } from '@/platform/workflow/validation/schemas/workflowSchema'
import { migrateLegacyRerouteNodes } from '@/utils/migration/migrateReroute'
describe('migrateReroute', () => {
describe('migrateReroute snapshots', () => {
// Helper function to load workflow JSON files
const loadWorkflow = (filePath: string): WorkflowJSON04 => {
const fullPath = path.resolve(__dirname, filePath)
const fileContent = fs.readFileSync(fullPath, 'utf-8')
return JSON.parse(fileContent) as WorkflowJSON04
}
it.each([
'branching.json',
'single_connected.json',
'floating.json',
'floating_branch.json'
])('should correctly migrate %s', async (fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`__fixtures__/reroute/legacy/${fileName}`
)
// Migrate the workflow
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Compare with snapshot
await expect(
JSON.stringify(migratedWorkflow, null, 2)
).toMatchFileSnapshot(`__fixtures__/reroute/native/${fileName}`)
})
})
})

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest'
import type {
ComboInputSpec,
ComboInputSpecV2,
FloatInputSpec,
InputSpec,
IntInputSpec
} from '@/schemas/nodeDefSchema'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
describe('nodeDefUtil', () => {
describe('mergeInputSpec', () => {
// Test numeric input specs (INT and FLOAT)
describe('numeric input specs', () => {
it('should merge INT specs with overlapping ranges', () => {
const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }]
const spec2: IntInputSpec = ['INT', { min: 5, max: 15 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('INT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].min).toBe(5)
// @ts-expect-error fixme ts strict error
expect(result?.[1].max).toBe(10)
})
it('should return null for INT specs with non-overlapping ranges', () => {
const spec1: IntInputSpec = ['INT', { min: 0, max: 5 }]
const spec2: IntInputSpec = ['INT', { min: 10, max: 15 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).toBeNull()
})
it('should merge FLOAT specs with overlapping ranges', () => {
const spec1: FloatInputSpec = ['FLOAT', { min: 0.5, max: 10.5 }]
const spec2: FloatInputSpec = ['FLOAT', { min: 5.5, max: 15.5 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('FLOAT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].min).toBe(5.5)
// @ts-expect-error fixme ts strict error
expect(result?.[1].max).toBe(10.5)
})
it('should handle specs with undefined min/max values', () => {
const spec1: FloatInputSpec = ['FLOAT', { min: 0.5 }]
const spec2: FloatInputSpec = ['FLOAT', { max: 15.5 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('FLOAT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].min).toBe(0.5)
// @ts-expect-error fixme ts strict error
expect(result?.[1].max).toBe(15.5)
})
it('should merge step values using least common multiple', () => {
const spec1: IntInputSpec = ['INT', { min: 0, max: 10, step: 2 }]
const spec2: IntInputSpec = ['INT', { min: 0, max: 10, step: 3 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('INT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].step).toBe(6) // LCM of 2 and 3 is 6
})
it('should use default step of 1 when step is not specified', () => {
const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }]
const spec2: IntInputSpec = ['INT', { min: 0, max: 10, step: 4 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('INT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].step).toBe(4) // LCM of 1 and 4 is 4
})
it('should handle step values for FLOAT specs', () => {
const spec1: FloatInputSpec = ['FLOAT', { min: 0, max: 10, step: 0.5 }]
const spec2: FloatInputSpec = ['FLOAT', { min: 0, max: 10, step: 0.25 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('FLOAT')
// @ts-expect-error fixme ts strict error
expect(result?.[1].step).toBe(0.5)
})
})
// Test combo input specs
describe('combo input specs', () => {
it('should merge COMBO specs with overlapping options', () => {
const spec1: ComboInputSpecV2 = ['COMBO', { options: ['A', 'B', 'C'] }]
const spec2: ComboInputSpecV2 = ['COMBO', { options: ['B', 'C', 'D'] }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('COMBO')
// @ts-expect-error fixme ts strict error
expect(result?.[1].options).toEqual(['B', 'C'])
})
it('should return null for COMBO specs with no overlapping options', () => {
const spec1: ComboInputSpecV2 = ['COMBO', { options: ['A', 'B'] }]
const spec2: ComboInputSpecV2 = ['COMBO', { options: ['C', 'D'] }]
const result = mergeInputSpec(spec1, spec2)
expect(result).toBeNull()
})
it('should handle COMBO specs with additional properties', () => {
const spec1: ComboInputSpecV2 = [
'COMBO',
{
options: ['A', 'B', 'C'],
default: 'A',
tooltip: 'Select an option'
}
]
const spec2: ComboInputSpecV2 = [
'COMBO',
{
options: ['B', 'C', 'D'],
default: 'B',
multiline: true
}
]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('COMBO')
// @ts-expect-error fixme ts strict error
expect(result?.[1].options).toEqual(['B', 'C'])
// @ts-expect-error fixme ts strict error
expect(result?.[1].default).toBe('B')
// @ts-expect-error fixme ts strict error
expect(result?.[1].tooltip).toBe('Select an option')
// @ts-expect-error fixme ts strict error
expect(result?.[1].multiline).toBe(true)
})
it('should handle v1 and v2 combo specs', () => {
const spec1: ComboInputSpec = [['A', 'B', 'C', 'D'], {}]
const spec2: ComboInputSpecV2 = ['COMBO', { options: ['C', 'D'] }]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('COMBO')
// @ts-expect-error fixme ts strict error
expect(result?.[1].options).toEqual(['C', 'D'])
})
})
// Test common input spec behavior
describe('common input spec behavior', () => {
it('should return null for specs with different types', () => {
const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }]
const spec2: ComboInputSpecV2 = ['COMBO', { options: ['A', 'B'] }]
const result = mergeInputSpec(spec1, spec2 as unknown as IntInputSpec)
expect(result).toBeNull()
})
it('should ignore specified keys when comparing specs', () => {
const spec1: InputSpec = [
'STRING',
{
default: 'value1',
tooltip: 'Tooltip 1',
step: 1
}
]
const spec2: InputSpec = [
'STRING',
{
default: 'value2',
tooltip: 'Tooltip 2',
step: 1
}
]
const result = mergeInputSpec(spec1, spec2)
expect(result).not.toBeNull()
expect(result?.[0]).toBe('STRING')
// @ts-expect-error fixme ts strict error
expect(result?.[1].default).toBe('value2')
// @ts-expect-error fixme ts strict error
expect(result?.[1].tooltip).toBe('Tooltip 2')
// @ts-expect-error fixme ts strict error
expect(result?.[1].step).toBe(1)
})
it('should return null if non-ignored properties differ', () => {
const spec1: InputSpec = ['STRING', { step: 1 }]
const spec2: InputSpec = ['STRING', { step: 2 }]
const result = mergeInputSpec(spec1, spec2)
expect(result).toBeNull()
})
})
})
})

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { filterOutputNodes, isOutputNode } from '@/utils/nodeFilterUtil'
describe('nodeFilterUtil', () => {
// Helper to create a mock node
const createMockNode = (
id: number,
isOutputNode: boolean = false
): LGraphNode => {
// Create a custom class with the nodeData static property
class MockNode extends LGraphNode {
static nodeData = isOutputNode ? { output_node: true } : {}
}
const node = new MockNode('')
node.id = id
return node
}
describe('filterOutputNodes', () => {
it('should return empty array when given empty array', () => {
const result = filterOutputNodes([])
expect(result).toEqual([])
})
it('should filter out non-output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should return all nodes if all are output nodes', () => {
const nodes = [
createMockNode(1, true),
createMockNode(2, true),
createMockNode(3, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(3)
expect(result).toEqual(nodes)
})
it('should return empty array if no output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, false),
createMockNode(3, false)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(0)
})
it('should handle nodes without nodeData', () => {
// Create a plain LGraphNode without custom constructor
const node = new LGraphNode('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
it('should handle nodes with undefined output_node', () => {
class MockNodeWithOtherData extends LGraphNode {
static nodeData = { someOtherProperty: true }
}
const node = new MockNodeWithOtherData('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
})
describe('isOutputNode', () => {
it('should filter selected nodes to only output nodes', () => {
const selectedNodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true),
createMockNode(5, false)
]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should handle empty selection', () => {
const emptyNodes: LGraphNode[] = []
const result = emptyNodes.filter(isOutputNode)
expect(result).toEqual([])
})
it('should handle selection with no output nodes', () => {
const selectedNodes = [createMockNode(1, false), createMockNode(2, false)]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(0)
})
})
})

254
src/utils/packUtils.test.ts Normal file
View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest'
import { normalizePackId, normalizePackKeys } from '@/utils/packUtils'
describe('packUtils', () => {
describe('normalizePackId', () => {
it('should return pack ID unchanged when no version suffix exists', () => {
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-Manager')).toBe('ComfyUI-Manager')
expect(normalizePackId('simple-pack')).toBe('simple-pack')
})
it('should remove version suffix with underscores', () => {
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-Manager@2_0_0')).toBe('ComfyUI-Manager')
expect(normalizePackId('pack@1_0_0_beta')).toBe('pack')
})
it('should remove version suffix with dots', () => {
expect(normalizePackId('ComfyUI-GGUF@1.1.4')).toBe('ComfyUI-GGUF')
expect(normalizePackId('pack@2.0.0')).toBe('pack')
})
it('should handle multiple @ symbols by only removing after first @', () => {
expect(normalizePackId('pack@1_0_0@extra')).toBe('pack')
expect(normalizePackId('my@pack@1_0_0')).toBe('my')
})
it('should handle empty string', () => {
expect(normalizePackId('')).toBe('')
})
it('should handle pack ID with @ but no version', () => {
expect(normalizePackId('pack@')).toBe('pack')
})
it('should handle special characters in pack name', () => {
expect(normalizePackId('my-pack_v2@1_0_0')).toBe('my-pack_v2')
expect(normalizePackId('pack.with.dots@2_0_0')).toBe('pack.with.dots')
expect(normalizePackId('UPPERCASE-Pack@1_0_0')).toBe('UPPERCASE-Pack')
})
it('should handle edge cases', () => {
// Only @ symbol
expect(normalizePackId('@')).toBe('')
expect(normalizePackId('@1_0_0')).toBe('')
// Whitespace
expect(normalizePackId(' pack @1_0_0')).toBe(' pack ')
expect(normalizePackId('pack @1_0_0')).toBe('pack ')
})
})
describe('normalizePackKeys', () => {
it('should normalize all keys with version suffixes', () => {
const input = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false },
'another-pack@1_0_0': { ver: '1.0.0', enabled: true }
}
const expected = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager': { ver: '2.0.0', enabled: false },
'another-pack': { ver: '1.0.0', enabled: true }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should handle empty object', () => {
expect(normalizePackKeys({})).toEqual({})
})
it('should handle keys without version suffixes', () => {
const input = {
pack1: { data: 'value1' },
pack2: { data: 'value2' }
}
expect(normalizePackKeys(input)).toEqual(input)
})
it('should handle mixed keys (with and without versions)', () => {
const input = {
'normal-pack': { ver: '1.0.0' },
'versioned-pack@2_0_0': { ver: '2.0.0' },
'another-normal': { ver: '3.0.0' },
'another-versioned@4_0_0': { ver: '4.0.0' }
}
const expected = {
'normal-pack': { ver: '1.0.0' },
'versioned-pack': { ver: '2.0.0' },
'another-normal': { ver: '3.0.0' },
'another-versioned': { ver: '4.0.0' }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should handle duplicate keys after normalization (last one wins)', () => {
const input = {
'pack@1_0_0': { ver: '1.0.0', data: 'first' },
'pack@2_0_0': { ver: '2.0.0', data: 'second' },
pack: { ver: '3.0.0', data: 'third' }
}
const result = normalizePackKeys(input)
// The exact behavior depends on object iteration order,
// but there should only be one 'pack' key in the result
expect(Object.keys(result)).toEqual(['pack'])
expect(result.pack).toBeDefined()
expect(result.pack.ver).toBeDefined()
})
it('should preserve value references', () => {
const value1 = { ver: '1.0.0', complex: { nested: 'data' } }
const value2 = { ver: '2.0.0', complex: { nested: 'data2' } }
const input = {
'pack1@1_0_0': value1,
'pack2@2_0_0': value2
}
const result = normalizePackKeys(input)
// Values should be the same references, not cloned
expect(result.pack1).toBe(value1)
expect(result.pack2).toBe(value2)
})
it('should handle special characters in keys', () => {
const input = {
'@1_0_0': { ver: '1.0.0' },
'my-pack.v2@2_0_0': { ver: '2.0.0' },
'UPPERCASE@3_0_0': { ver: '3.0.0' }
}
const expected = {
'': { ver: '1.0.0' },
'my-pack.v2': { ver: '2.0.0' },
UPPERCASE: { ver: '3.0.0' }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should work with different value types', () => {
const input = {
'pack1@1_0_0': 'string value',
'pack2@2_0_0': 123,
'pack3@3_0_0': null,
'pack4@4_0_0': undefined,
'pack5@5_0_0': true,
pack6: []
}
const expected = {
pack1: 'string value',
pack2: 123,
pack3: null,
pack4: undefined,
pack5: true,
pack6: []
}
expect(normalizePackKeys(input)).toEqual(expected)
})
})
describe('Integration scenarios from JSDoc examples', () => {
it('should handle the examples from normalizePackId JSDoc', () => {
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
})
it('should handle the examples from normalizePackKeys JSDoc', () => {
const input = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false }
}
const expected = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager': { ver: '2.0.0', enabled: false }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
})
describe('Real-world scenarios', () => {
it('should handle typical ComfyUI-Manager response with mixed enabled/disabled packs', () => {
// Simulating actual server response pattern
const serverResponse = {
// Enabled packs come without version suffix
'ComfyUI-Essential': { ver: '1.2.3', enabled: true, aux_id: undefined },
'ComfyUI-Impact': { ver: '2.0.0', enabled: true, aux_id: undefined },
// Disabled packs come with version suffix
'ComfyUI-GGUF@1_1_4': {
ver: '1.1.4',
enabled: false,
aux_id: undefined
},
'ComfyUI-Manager@2_5_0': {
ver: '2.5.0',
enabled: false,
aux_id: undefined
}
}
const normalized = normalizePackKeys(serverResponse)
// All keys should be normalized (no version suffixes)
expect(Object.keys(normalized)).toEqual([
'ComfyUI-Essential',
'ComfyUI-Impact',
'ComfyUI-GGUF',
'ComfyUI-Manager'
])
// Values should be preserved
expect(normalized['ComfyUI-GGUF']).toEqual({
ver: '1.1.4',
enabled: false,
aux_id: undefined
})
})
it('should allow consistent access by pack ID regardless of enabled state', () => {
const packsBeforeToggle = {
'my-pack': { ver: '1.0.0', enabled: true }
}
const packsAfterToggle = {
'my-pack@1_0_0': { ver: '1.0.0', enabled: false }
}
const normalizedBefore = normalizePackKeys(packsBeforeToggle)
const normalizedAfter = normalizePackKeys(packsAfterToggle)
// Both should have the same key after normalization
expect(normalizedBefore['my-pack']).toBeDefined()
expect(normalizedAfter['my-pack']).toBeDefined()
// Can access by the same key regardless of the original format
expect(Object.keys(normalizedBefore)).toEqual(
Object.keys(normalizedAfter)
)
})
})
})

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { applyTextReplacements } from '@/utils/searchAndReplace'
describe('applyTextReplacements', () => {
// Test specifically the filename sanitization part
describe('filename sanitization', () => {
it('should replace invalid filename characters with underscores', () => {
// Mock the minimal app structure needed
const mockNodes = [
{
title: 'TestNode',
widgets: [
{
name: 'testWidget',
value:
'file/name?with<invalid>chars\\:*|"control\x00chars\x1F\x7F'
}
]
} as LGraphNode
]
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
// The expected result should have all invalid characters replaced with underscores
expect(result).toBe('file_name_with_invalid_chars_____control_chars__')
})
it('should handle various invalid filename characters individually', () => {
const testCases = [
{ input: '/', expected: '_' },
{ input: '?', expected: '_' },
{ input: '<', expected: '_' },
{ input: '>', expected: '_' },
{ input: '\\', expected: '_' },
{ input: ':', expected: '_' },
{ input: '*', expected: '_' },
{ input: '|', expected: '_' },
{ input: '"', expected: '_' },
{ input: '\x00', expected: '_' }, // NULL character
{ input: '\x1F', expected: '_' }, // Unit separator
{ input: '\x7F', expected: '_' } // Delete character
]
for (const { input, expected } of testCases) {
const mockNodes = [
{
title: 'TestNode',
widgets: [{ name: 'testWidget', value: input }]
} as LGraphNode
]
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
expect(result).toBe(expected)
}
})
it('should not modify valid filename characters', () => {
const validChars = 'abcABC123.-_ '
const mockNodes = [
{
title: 'TestNode',
widgets: [{ name: 'testWidget', value: validChars }]
} as LGraphNode
]
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
expect(result).toBe(validChars)
})
})
})

147
src/utils/treeUtil.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { buildTree, sortedTree } from '@/utils/treeUtil'
describe('buildTree', () => {
it('should handle empty folder items correctly', () => {
const items = [
{ path: 'a/b/c/' },
{ path: 'a/b/d.txt' },
{ path: 'a/e/' },
{ path: 'f.txt' }
]
const tree = buildTree(items, (item) => item.path.split('/'))
expect(tree).toEqual({
key: 'root',
label: 'root',
children: [
{
key: 'root/a',
label: 'a',
leaf: false,
children: [
{
key: 'root/a/b',
label: 'b',
leaf: false,
children: [
{
key: 'root/a/b/c',
label: 'c',
leaf: false,
children: [],
data: { path: 'a/b/c/' }
},
{
key: 'root/a/b/d.txt',
label: 'd.txt',
leaf: true,
children: [],
data: { path: 'a/b/d.txt' }
}
]
},
{
key: 'root/a/e',
label: 'e',
leaf: false,
children: [],
data: { path: 'a/e/' }
}
]
},
{
key: 'root/f.txt',
label: 'f.txt',
leaf: true,
children: [],
data: { path: 'f.txt' }
}
]
})
})
})
describe('sortedTree', () => {
const createNode = (label: string, leaf = false): TreeNode => ({
key: label,
label,
leaf,
children: []
})
it('should return a new node instance', () => {
const node = createNode('root')
const result = sortedTree(node)
expect(result).not.toBe(node)
expect(result).toEqual(node)
})
it('should sort children by label', () => {
const node: TreeNode = {
key: 'root',
label: 'root',
leaf: false,
children: [createNode('c'), createNode('a'), createNode('b')]
}
const result = sortedTree(node)
expect(result.children?.map((c) => c.label)).toEqual(['a', 'b', 'c'])
})
describe('with groupLeaf=true', () => {
it('should group folders before files', () => {
const node: TreeNode = {
key: 'root',
label: 'root',
children: [
createNode('file.txt', true),
createNode('folder1'),
createNode('another.txt', true),
createNode('folder2')
]
}
const result = sortedTree(node, { groupLeaf: true })
const labels = result.children?.map((c) => c.label)
expect(labels).toEqual(['folder1', 'folder2', 'another.txt', 'file.txt'])
})
it('should sort recursively', () => {
const node: TreeNode = {
key: 'root',
label: 'root',
children: [
{
...createNode('folder1'),
children: [
createNode('z.txt', true),
createNode('subfolder2'),
createNode('a.txt', true),
createNode('subfolder1')
]
}
]
}
const result = sortedTree(node, { groupLeaf: true })
const folder = result.children?.[0]
const subLabels = folder?.children?.map((c) => c.label)
expect(subLabels).toEqual(['subfolder1', 'subfolder2', 'a.txt', 'z.txt'])
})
})
it('should handle nodes without children', () => {
const node: TreeNode = {
key: 'leaf',
label: 'leaf',
leaf: true
}
const result = sortedTree(node)
expect(result).toEqual(node)
})
})

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
describe('typeGuardUtil', () => {
describe('isSubgraphIoNode', () => {
it('should identify SubgraphInputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphInputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should identify SubgraphOutputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphOutputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should not identify regular nodes as IO nodes', () => {
const node = {
constructor: { comfyClass: 'CLIPTextEncode' }
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without constructor', () => {
const node = {} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without comfyClass', () => {
const node = {
constructor: {}
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
})
})