mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 23:50:00 +00:00
[test] Add subgraph units tests for events and i/o (#1126)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
# Subgraph Testing Fixtures and Utilities
|
||||
|
||||
Testing infrastructure for LiteGraph's subgraph functionality. A subgraph is a graph-within-a-graph that can be reused as a single node, with input/output slots mapping to internal IO nodes.
|
||||
This directory contains the testing infrastructure for LiteGraph's subgraph functionality. These utilities provide a consistent, easy-to-use API for writing subgraph tests.
|
||||
|
||||
## What is a Subgraph?
|
||||
|
||||
A subgraph in LiteGraph is a graph-within-a-graph that can be reused as a single node. It has:
|
||||
- Input slots that map to an internal input node
|
||||
- Output slots that map to an internal output node
|
||||
- Internal nodes and connections
|
||||
- The ability to be instantiated multiple times as SubgraphNode instances
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -22,9 +30,10 @@ it("should do something", () => {
|
||||
})
|
||||
|
||||
// Option 2: Use pre-configured fixtures
|
||||
subgraphTest("should handle events", ({ simpleSubgraph }) => {
|
||||
subgraphTest("should handle events", ({ simpleSubgraph, eventCapture }) => {
|
||||
// simpleSubgraph comes pre-configured with 1 input, 1 output, and 2 nodes
|
||||
expect(simpleSubgraph.inputs).toHaveLength(1)
|
||||
// Your test logic here
|
||||
})
|
||||
```
|
||||
|
||||
@@ -230,10 +239,73 @@ interface NestedSubgraphOptions {
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
1. **Array items don't have index property** - Use `indexOf()` instead
|
||||
2. **IO nodes have `subgraph` property** - Not `graph` like regular nodes
|
||||
3. **Links are stored in a Map** - Use `.size` not `.length`
|
||||
4. **Event detail structures** - Check exact property names:
|
||||
- `"adding-input"`: `{ name, type }`
|
||||
- `"input-added"`: `{ input, index }`
|
||||
1. **Array vs Index**: The `inputs` and `outputs` arrays don't have an `index` property on items. Use `indexOf()`:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
expect(input.index).toBe(0)
|
||||
|
||||
// ✅ Correct
|
||||
expect(subgraph.inputs.indexOf(input)).toBe(0)
|
||||
```
|
||||
|
||||
2. **Graph vs Subgraph Property**: SubgraphInputNode/OutputNode have `subgraph`, not `graph`:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
expect(inputNode.graph).toBe(subgraph)
|
||||
|
||||
// ✅ Correct
|
||||
expect(inputNode.subgraph).toBe(subgraph)
|
||||
```
|
||||
|
||||
3. **Event Detail Structure**: Events have specific detail structures:
|
||||
```typescript
|
||||
// Input events
|
||||
"adding-input": { name: string, type: string }
|
||||
"input-added": { input: SubgraphInput, index: number }
|
||||
|
||||
// Output events
|
||||
"adding-output": { name: string, type: string }
|
||||
"output-added": { output: SubgraphOutput, index: number }
|
||||
```
|
||||
|
||||
4. **Links are stored in a Map**: Use `.size` not `.length`:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
expect(subgraph.links.length).toBe(1)
|
||||
|
||||
// ✅ Correct
|
||||
expect(subgraph.links.size).toBe(1)
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
- Always use helper functions instead of manual setup
|
||||
- Use fixtures for common scenarios to avoid repetitive code
|
||||
- Clean up event listeners with `capture.cleanup()` after event tests
|
||||
- Use `verifyEventSequence()` to test event ordering
|
||||
- Remember fixtures are created fresh for each test (no shared state)
|
||||
- Use `assertSubgraphStructure()` for comprehensive validation
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
- Use `logSubgraphStructure(subgraph)` to print subgraph details
|
||||
- Check `subgraph.rootGraph` to verify graph hierarchy
|
||||
- Event capture includes timestamps for debugging timing issues
|
||||
- All factory functions accept optional parameters for customization
|
||||
|
||||
## Adding New Test Utilities
|
||||
|
||||
When extending the test infrastructure:
|
||||
|
||||
1. Add new helper functions to `subgraphHelpers.ts`
|
||||
2. Add new fixtures to `subgraphFixtures.ts`
|
||||
3. Update this README with usage examples
|
||||
4. Follow existing patterns for consistency
|
||||
5. Add TypeScript types for all parameters
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Helper functions are optimized for test clarity, not performance
|
||||
- Use `structuredClone()` for deep copying test data
|
||||
- Event capture systems automatically clean up listeners
|
||||
- Fixtures are created fresh for each test to avoid state contamination
|
||||
|
||||
244
test/subgraph/fixtures/advancedEventHelpers.ts
Normal file
244
test/subgraph/fixtures/advancedEventHelpers.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { CapturedEvent } from "./subgraphHelpers"
|
||||
|
||||
import { expect } from "vitest"
|
||||
|
||||
/**
|
||||
* Extended captured event with additional metadata not in the base infrastructure
|
||||
*/
|
||||
export interface ExtendedCapturedEvent<T = unknown> extends CapturedEvent<T> {
|
||||
defaultPrevented: boolean
|
||||
bubbles: boolean
|
||||
cancelable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an enhanced event capture that includes additional event properties
|
||||
* This extends the basic createEventCapture with more metadata
|
||||
*/
|
||||
export function createExtendedEventCapture<T = unknown>(
|
||||
eventTarget: EventTarget,
|
||||
eventTypes: string[],
|
||||
) {
|
||||
const capturedEvents: ExtendedCapturedEvent<T>[] = []
|
||||
const listeners: Array<() => void> = []
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
const listener = (event: Event) => {
|
||||
capturedEvents.push({
|
||||
type: eventType,
|
||||
detail: (event as CustomEvent<T>).detail,
|
||||
timestamp: Date.now(),
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
bubbles: event.bubbles,
|
||||
cancelable: event.cancelable,
|
||||
})
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(eventType, listener)
|
||||
listeners.push(() => eventTarget.removeEventListener(eventType, listener))
|
||||
}
|
||||
|
||||
return {
|
||||
events: capturedEvents,
|
||||
clear: () => { capturedEvents.length = 0 },
|
||||
cleanup: () => { for (const cleanup of listeners) cleanup() },
|
||||
getEventsByType: (type: string) => capturedEvents.filter(e => e.type === type),
|
||||
getLatestEvent: () => capturedEvents.at(-1),
|
||||
getFirstEvent: () => capturedEvents[0],
|
||||
|
||||
/**
|
||||
* Wait for a specific event type to be captured
|
||||
*/
|
||||
async waitForEvent(type: string, timeoutMs: number = 1000): Promise<ExtendedCapturedEvent<T>> {
|
||||
const existingEvent = capturedEvents.find(e => e.type === type)
|
||||
if (existingEvent) return existingEvent
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
eventTarget.removeEventListener(type, eventListener)
|
||||
reject(new Error(`Event ${type} not received within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
const eventListener = (_event: Event) => {
|
||||
const capturedEvent = capturedEvents.find(e => e.type === type)
|
||||
if (capturedEvent) {
|
||||
clearTimeout(timeout)
|
||||
eventTarget.removeEventListener(type, eventListener)
|
||||
resolve(capturedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(type, eventListener)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for a sequence of events to occur in order
|
||||
*/
|
||||
async waitForSequence(expectedSequence: string[], timeoutMs: number = 1000): Promise<ExtendedCapturedEvent<T>[]> {
|
||||
// Check if sequence is already complete
|
||||
if (capturedEvents.length >= expectedSequence.length) {
|
||||
const actualSequence = capturedEvents.slice(0, expectedSequence.length).map(e => e.type)
|
||||
if (JSON.stringify(actualSequence) === JSON.stringify(expectedSequence)) {
|
||||
return capturedEvents.slice(0, expectedSequence.length)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
const actual = capturedEvents.map(e => e.type).join(", ")
|
||||
const expected = expectedSequence.join(", ")
|
||||
reject(new Error(`Event sequence not completed within ${timeoutMs}ms. Expected: ${expected}, Got: ${actual}`))
|
||||
}, timeoutMs)
|
||||
|
||||
const checkSequence = () => {
|
||||
if (capturedEvents.length >= expectedSequence.length) {
|
||||
const actualSequence = capturedEvents.slice(0, expectedSequence.length).map(e => e.type)
|
||||
if (JSON.stringify(actualSequence) === JSON.stringify(expectedSequence)) {
|
||||
cleanup()
|
||||
resolve(capturedEvents.slice(0, expectedSequence.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventListener = () => checkSequence()
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
for (const type of expectedSequence) {
|
||||
eventTarget.removeEventListener(type, eventListener)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for all expected event types
|
||||
for (const type of expectedSequence) {
|
||||
eventTarget.addEventListener(type, eventListener)
|
||||
}
|
||||
|
||||
// Initial check in case events already exist
|
||||
checkSequence()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for memory leak testing
|
||||
*/
|
||||
export interface MemoryLeakTestOptions {
|
||||
cycles?: number
|
||||
instancesPerCycle?: number
|
||||
gcAfterEach?: boolean
|
||||
maxMemoryGrowth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory leak test factory
|
||||
* Useful for testing that event listeners and references are properly cleaned up
|
||||
*/
|
||||
export function createMemoryLeakTest<T>(
|
||||
setupFn: () => { ref: WeakRef<T>, cleanup: () => void },
|
||||
options: MemoryLeakTestOptions = {},
|
||||
) {
|
||||
const {
|
||||
cycles = 1,
|
||||
instancesPerCycle = 1,
|
||||
gcAfterEach = true,
|
||||
maxMemoryGrowth = 0,
|
||||
} = options
|
||||
|
||||
return async () => {
|
||||
const refs: WeakRef<T>[] = []
|
||||
const initialMemory = process.memoryUsage?.()?.heapUsed || 0
|
||||
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
const cycleRefs: WeakRef<T>[] = []
|
||||
|
||||
for (let instance = 0; instance < instancesPerCycle; instance++) {
|
||||
const { ref, cleanup } = setupFn()
|
||||
cycleRefs.push(ref)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
refs.push(...cycleRefs)
|
||||
|
||||
if (gcAfterEach && global.gc) {
|
||||
global.gc()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
// Final garbage collection
|
||||
if (global.gc) {
|
||||
global.gc()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Check if objects were collected
|
||||
const uncollectedRefs = refs.filter(ref => ref.deref() !== undefined)
|
||||
if (uncollectedRefs.length > 0) {
|
||||
console.warn(`${uncollectedRefs.length} objects were not garbage collected`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory growth check
|
||||
if (maxMemoryGrowth > 0 && process.memoryUsage) {
|
||||
const finalMemory = process.memoryUsage().heapUsed
|
||||
const memoryGrowth = finalMemory - initialMemory
|
||||
|
||||
if (memoryGrowth > maxMemoryGrowth) {
|
||||
throw new Error(`Memory growth ${memoryGrowth} bytes exceeds limit ${maxMemoryGrowth} bytes`)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a performance monitor for event operations
|
||||
*/
|
||||
export function createEventPerformanceMonitor() {
|
||||
const measurements: Array<{
|
||||
operation: string
|
||||
duration: number
|
||||
timestamp: number
|
||||
}> = []
|
||||
|
||||
return {
|
||||
measure: <T>(operation: string, fn: () => T): T => {
|
||||
const start = performance.now()
|
||||
const result = fn()
|
||||
const end = performance.now()
|
||||
|
||||
measurements.push({
|
||||
operation,
|
||||
duration: end - start,
|
||||
timestamp: start,
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
getMeasurements: () => [...measurements],
|
||||
|
||||
getAverageDuration: (operation: string) => {
|
||||
const operationMeasurements = measurements.filter(m => m.operation === operation)
|
||||
if (operationMeasurements.length === 0) return 0
|
||||
|
||||
const totalDuration = operationMeasurements.reduce((sum, m) => sum + m.duration, 0)
|
||||
return totalDuration / operationMeasurements.length
|
||||
},
|
||||
|
||||
clear: () => { measurements.length = 0 },
|
||||
|
||||
assertPerformance: (operation: string, maxDuration: number) => {
|
||||
const measurements = this.getMeasurements()
|
||||
const relevantMeasurements = measurements.filter(m => m.operation === operation)
|
||||
if (relevantMeasurements.length === 0) return
|
||||
|
||||
const avgDuration = relevantMeasurements.reduce((sum, m) => sum + m.duration, 0) / relevantMeasurements.length
|
||||
expect(avgDuration).toBeLessThan(maxDuration)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Vitest Fixtures for Subgraph Testing
|
||||
*
|
||||
* Reusable Vitest fixtures for subgraph testing.
|
||||
* Each fixture provides a clean, pre-configured subgraph
|
||||
* This file provides reusable Vitest fixtures that other developers can use
|
||||
* in their test files. Each fixture provides a clean, pre-configured subgraph
|
||||
* setup for different testing scenarios.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Test Helper Functions for Subgraph Testing
|
||||
*
|
||||
* Core utilities for creating and testing subgraphs.
|
||||
* Provides consistent APIs for test subgraph creation, node management,
|
||||
* and behavior verification.
|
||||
* This file contains the core utilities that all subgraph developers will use.
|
||||
* These functions provide consistent ways to create test subgraphs, nodes, and
|
||||
* verify their behavior.
|
||||
*/
|
||||
|
||||
import type { ISlotType, NodeId } from "@/litegraph"
|
||||
@@ -55,16 +55,20 @@ export interface CapturedEvent<T = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test subgraph with the specified configuration.
|
||||
* This is the primary function for creating test subgraphs.
|
||||
* Creates a test subgraph with specified inputs, outputs, and nodes.
|
||||
* This is the primary function for creating subgraphs in tests.
|
||||
* @param options Configuration options for the subgraph
|
||||
* @returns A configured Subgraph instance
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create empty subgraph
|
||||
* const subgraph = createTestSubgraph()
|
||||
*
|
||||
* // Create subgraph with specific I/O
|
||||
* const subgraph = createTestSubgraph({
|
||||
* name: "My Test Subgraph",
|
||||
* inputCount: 2,
|
||||
* outputCount: 1
|
||||
* inputs: [{ name: "value", type: "number" }],
|
||||
* outputs: [{ name: "result", type: "string" }],
|
||||
* nodeCount: 3
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
@@ -78,7 +82,6 @@ export function createTestSubgraph(options: TestSubgraphOptions = {}): Subgraph
|
||||
if (options.outputs && options.outputCount) {
|
||||
throw new Error(`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`)
|
||||
}
|
||||
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
// Create the base subgraph data
|
||||
@@ -153,14 +156,17 @@ export function createTestSubgraph(options: TestSubgraphOptions = {}): Subgraph
|
||||
|
||||
/**
|
||||
* Creates a SubgraphNode instance from a subgraph definition.
|
||||
* @param subgraph The subgraph definition to instantiate
|
||||
* @param options Configuration options for the node instance
|
||||
* @returns A SubgraphNode instance
|
||||
* The node is automatically added to a test parent graph.
|
||||
* @param subgraph The subgraph definition to create a node from
|
||||
* @param options Configuration options for the subgraph node
|
||||
* @returns A configured SubgraphNode instance
|
||||
* @example
|
||||
* ```typescript
|
||||
* const subgraph = createTestSubgraph()
|
||||
* const subgraph = createTestSubgraph({ inputs: [{ name: "value", type: "number" }] })
|
||||
* const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
* pos: [100, 200]
|
||||
* id: 42,
|
||||
* pos: [100, 200],
|
||||
* size: [180, 100]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user