[test] Add subgraph units tests for events and i/o (#1126)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-07-15 10:11:08 -07:00
committed by GitHub
parent 3ac96979fe
commit 28f955ed6a
10 changed files with 1524 additions and 144 deletions

View File

@@ -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

View 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)
},
}
}

View File

@@ -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.
*/

View File

@@ -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]
* })
* ```
*/