feat: squash v2 branch

This commit is contained in:
pbanddev
2026-03-23 16:40:53 -05:00
parent 39c1719e0d
commit a1e6315b37
96 changed files with 11832 additions and 6279 deletions
+2
View File
@@ -0,0 +1,2 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-architecture linux/amd64
+53 -9
View File
@@ -2,17 +2,14 @@ name: CI
on:
push:
branches: [main]
branches: [main, v2]
pull_request:
branches: [main]
jobs:
test:
name: Test & Build
unit-tests:
name: Unit Tests & Build
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps:
- name: Check out code
uses: actions/checkout@v4
@@ -20,10 +17,10 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 20
cache: "pnpm"
- name: Install dependencies
@@ -32,8 +29,55 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- name: Run tests
- name: Run unit tests
run: pnpm test
- name: Build
run: pnpm build
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: unit-tests
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: pnpx playwright install chromium --with-deps
- name: Build extension
run: pnpm build
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Setup test ComfyUI
run: pnpm setup:e2e
timeout-minutes: 15
- name: Run E2E tests
run: pnpm test:e2e
timeout-minutes: 30
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-test-results
path: |
test-results/
playwright-report/
retention-days: 7
+80 -28
View File
@@ -17,40 +17,64 @@ jobs:
test:
name: Test before publish
runs-on: ubuntu-latest
if: github.actor == github.repository_owner
strategy:
matrix:
node-version: [20]
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Use Python from .python-version
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Sync Python dependencies
run: uv sync --locked --group dev
- name: Typecheck
run: pnpm typecheck
- name: Run tests
- name: Run unit tests
run: pnpm test
- name: Build
run: pnpm build
- name: Install Playwright browsers
run: pnpx playwright install chromium --with-deps
- name: Setup test ComfyUI
run: pnpm setup:e2e
timeout-minutes: 15
- name: Run E2E tests
run: pnpm test:e2e
timeout-minutes: 30
bump-version:
name: Bump version and create tag
runs-on: ubuntu-latest
needs: test
if: github.actor == github.repository_owner
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
outputs:
previous-version: ${{ steps.versions.outputs.previous-version }}
current-version: ${{ steps.versions.outputs.current-version }}
permissions:
contents: write
steps:
@@ -59,57 +83,85 @@ jobs:
with:
fetch-depth: 0
- name: Use Python from .python-version
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Configure Git for GitHub Actions bot
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Bump version
id: bump
uses: callowayproject/bump-my-version@master
env:
BUMPVERSION_TAG: "true"
with:
args: ${{ github.event.inputs.bump_type }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Sync Python dependencies
run: uv sync --locked --group dev
- name: Compute release version
id: versions
shell: bash
run: |
previous_version="$(uv run bump-my-version show current_version)"
current_version="$(uv run bump-my-version show --increment "${{ github.event.inputs.bump_type }}" new_version)"
{
echo "previous-version=${previous_version}"
echo "current-version=${current_version}"
} >> "$GITHUB_OUTPUT"
- name: Bump version and refresh uv.lock
run: |
uv run bump-my-version bump --no-commit --no-tag ${{ github.event.inputs.bump_type }}
uv lock
- name: Commit version bump
run: |
git add .python-version pyproject.toml package.json src/settings.ts uv.lock
git commit -m "chore: bump version to ${{ steps.versions.outputs.current-version }} [skip ci]"
- name: Create release tag
run: git tag "v${{ steps.versions.outputs.current-version }}" -m "Release version ${{ steps.versions.outputs.current-version }}"
- name: Push version commit and tag
run: git push origin HEAD:main --follow-tags
- name: Create GitHub Release
if: steps.bump.outputs.bumped == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.bump.outputs.current-version }}
name: Release v${{ steps.bump.outputs.current-version }}
tag_name: v${{ steps.versions.outputs.current-version }}
name: Release v${{ steps.versions.outputs.current-version }}
body: |
## Version ${{ steps.bump.outputs.current-version }}
## Version ${{ steps.versions.outputs.current-version }}
Bumped from version ${{ steps.bump.outputs.previous-version }} to ${{ steps.bump.outputs.current-version }}
Bumped from version ${{ steps.versions.outputs.previous-version }} to ${{ steps.versions.outputs.current-version }}
This release was automatically created by the version bump workflow.
draft: false
prerelease: false
generate_release_notes: true
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
needs: bump-version
if: github.actor == github.repository_owner
strategy:
matrix:
node-version: [20]
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
steps:
- name: Check out code
uses: actions/checkout@v4
with:
ref: main
ref: v${{ needs.bump-version.outputs.current-version }}
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 20
cache: "pnpm"
- name: Install dependencies
+4 -1
View File
@@ -15,4 +15,7 @@ pnpm-debug.log*
node.zip
.vscode/
.claude/
.changeset/
.changeset/
.test-comfy/
test-results/
playwright-report/
+1
View File
@@ -0,0 +1 @@
3.13
+61
View File
@@ -0,0 +1,61 @@
## Project
ComfyUI Node Organizer. Frontend-only custom node extension loaded from `dist/` through `__init__.py`.
## Start Here
Inspect `README.md`, `package.json`, `src/index.ts`, `src/adapter.ts`, and the relevant file under `src/layout/` or `tests/e2e/`.
Use `.changeset/plans/` only when prior design context is needed.
## Common Commands
```bash
pnpm install
pnpm build
pnpm typecheck
pnpm test
pnpm setup:e2e
pnpm test:e2e
pnpm ci:local
uv sync --locked --group dev
```
Version bumping:
- `uv run bump-my-version bump patch|minor|major`
## Repo Rules
- Build before E2E. ComfyUI loads `dist/`, not `src/`.
- Use `uv` for Python-side commands.
- Keep `.python-version` and `uv.lock` in sync with the publish workflow.
- Local `.changeset/*` files are intentionally gitignored. Do not commit them.
- Verify runtime behavior in ComfyUI before changing adapter logic or making claims about ComfyUI APIs.
## Runtime Facts
- Ctrl+Z works after organize. ComfyUI snapshots graph state before extension mutations.
- Keybindings are managed in ComfyUI's keybinding settings, not inside this extension's settings panel.
- Graph access: `canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph`.
- Group membership is geometry-based. Use center-point containment for nodes and full-rect containment for child groups. Do not read `_children`.
- Do not call `recomputeInsideNodes()`. It reads stale coordinates after our in-place `pos[0] = x` updates and fills `_children` with wrong node IDs.
- Group layout IDs are namespaced as `group:<id>`.
- Selected-group organize re-anchors selected root groups to their original absolute positions.
- Subgraph `inputNode` and `outputNode` are first-class layout nodes.
- `isGroup()` is verified against live runtime with `constructor.name === "LGraphGroup"` and fallback `typeof type !== "string"`.
## Test Facts
- Unit: layout framework, adapter bridge, runtime helpers, property-based invariants.
- E2E: Playwright against `.test-comfy/`.
- `pnpm setup:e2e` pins ComfyUI to the revision in `e2e.config.ts` for CI parity.
- CI and publish run the full Playwright suite on Linux.
- Repo-owned fixtures live flat in `tests/fixtures/`.
- Installed `comfyui-workflow-templates` are discovered live at E2E runtime from the pinned test ComfyUI environment.
- Broad installed-template coverage asserts correctness invariants only; strict topological order remains repo-fixture-only.
- Visual regression is graph-canvas scoped and uses platform-specific `-win32` / `-linux` baselines.
- `pnpm ci:local` covers unit/build parity only.
## Known Friction
- Some real ComfyUI workflows load with imperfect group rectangles. Verify against live runtime before changing containment behavior.
- A small number of real installed templates have topological-order violations due to complex inter-group connections. Tracked for v2.1.
+1 -145
View File
@@ -1,145 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ComfyUI custom node extension that organizes nodes. Frontend-only extension (no Python backend nodes) that uses TypeScript/Vite for the UI.
## Commands
```bash
pnpm install # install deps
pnpm dev # dev server
pnpm build # typecheck + build to dist/
pnpm watch # build with watch mode
pnpm typecheck # tsc --noEmit
pnpm test # run tests
pnpm test:watch # run tests in watch mode
pnpm test:coverage # run tests with coverage report
```
Version bumping: `uv run bump-my-version bump patch|minor|major`
## Architecture
**Dual-stack extension**: Python entrypoint + TypeScript frontend
- `__init__.py` - ComfyUI entrypoint, points `WEB_DIRECTORY` to `./dist`
- `src/index.ts` - Extension entry, registers via `app.registerExtension()`
- `src/debug.ts` - Debug logging utilities (separated for testability)
- `dist/` - Built JS output, loaded by ComfyUI frontend
**Build system**: Vite with custom plugin that injects `import { app } from "/scripts/app.js"` at build time. ComfyUI scripts are external (not bundled).
**Types**: `@comfyorg/comfyui-frontend-types` provides ComfyApp interface. Global `app` declared for runtime access.
## ComfyUI Extension Pattern
Extensions register hooks via `app.registerExtension({ name, setup?, ... })`. The `app` object provides access to:
- Graph/canvas manipulation
- Node lifecycle hooks
- UI customization APIs
Use docs-mcp server for ComfyUI API reference.
## Layout Algorithm
**Hybrid Sugiyama + Bin Packing** in `src/layout/`:
1. `node-classifier.ts` - Classify nodes (connected/disconnected/reroutes)
2. `reroute-collapse.ts` - Collapse reroute chains into virtual edges
3. `graph-builder.ts` - Build DAG from connected nodes only
4. `layer-assign.ts` - Longest-path layer assignment
5. `ordering.ts` - Size-aware barycenter crossing minimization
6. `bin-pack.ts` - FFDH algorithm for multi-column packing
7. `positioning.ts` - Assign coordinates, place disconnected nodes, restore reroutes
8. `groups.ts` - Resize groups to fit members
9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment
10. `selected-groups.ts` - Organize only selected groups independently
11. `title-tokens.ts` - Parse group title tokens and arrange nodes by mode
**Configuration** in `types.ts`:
- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns
- `collapseReroutes`: true to collapse reroute chains
- `disconnectedGap`: gap between disconnected zone and DAG
**Key design decisions**:
- `compactVertically` excludes groups from max height (prevents Y explosion)
- `positionGroupContents` uses translation (preserves relative positions)
- Group dimensions are layer-aware: width = sum of layer widths, height = max of layer heights (for groups with internal connections)
- `resolveGroupOverlaps` detects and shifts overlapping groups using minimal direction (up/down/right)
- `assignNodeYPositions` tracks reserved Y regions to prevent node/group overlap within layers
- **Idempotency**: Layout is a pure function of topology + sizes; overlap resolution decoupled from node.pos writes
## Selected Groups Layout
`layoutSelectedGroups(graph, selectedGroupIds, config?)` in `src/layout/selected-groups.ts`:
**Context menu items** (in `src/index.ts`):
- "Organize Group" - when single group selected
- "Organize N Groups" - when multiple groups selected
**Behavior**:
- Uses internal connections only (ignores external edges)
- Disconnected nodes within group stacked vertically
- Nested groups auto-included when parent selected
- External overlaps acceptable (no resolution with non-selected nodes)
- Groups resized to fit organized contents
**Selection detection**:
- Uses `app.canvas.selectedItems` (Set of Positionable)
- Duck-typing via `isLGraphGroup()` checks for pos/size/title/id
- Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size)
## Group Title Tokens
`parseLayoutToken(title)` and `arrangeByMode()` in `src/layout/title-tokens.ts`:
**Supported tokens** (case-insensitive):
- `[HORIZONTAL]` - Single horizontal row
- `[VERTICAL]` - Single vertical column
- `[1-9ROW]` - Distribute into N rows (round-robin)
- `[1-9COL]` - Distribute into N columns (round-robin)
**Behavior**:
- Tokens override default layout for direct group members
- Ignores DAG structure when token present (treats all as disconnected)
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
- Nested groups each respect their own tokens
- Groups without tokens use default behavior
- Nodes sorted by original position (left-to-right, top-to-bottom) to preserve user intent
**Integration points**:
- `selected-groups.ts:layoutGroupContents()` - Token layout for "Organize Group"
- `positioning.ts:positionGroupContents()` - Token layout for "Organize Workflow"
- Both handle nested groups: process bottom-up first, then include as items in parent's token layout
## Testing
**Vitest-based regression testing** in `tests/`:
- `tests/fixtures/` - 4 workflow JSONs (nested-groups, complex-parallel, simple-dag, nested-wrapper)
- `tests/helpers.ts` - Fixture loader, workflow→LGraph converter, cloneGraph
- `tests/invariants.ts` - 6 reusable assertions:
- `assertNoOverlaps` - AABB collision detection
- `assertNodesInsideGroups` - nodes stay in groups
- `assertFiniteCoordinates` - no NaN/Infinity
- `assertTopologicalOrder` - DAG order preserved
- `assertIdempotent` - stable after multiple runs
- `assertGroupMembershipPreserved` - nodes originally in groups remain in groups
- `tests/integration/layout.test.ts` - Main regression tests
- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (34 cases)
- `tests/integration/token-layout.test.ts` - Token-based layout regression tests (8 cases)
**Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures)
All fixtures pass all invariants including idempotency. 75 total tests.
## CI/CD
`.github/workflows/publish_action.yaml` - Manual dispatch workflow that:
1. Runs tests
2. Bumps version via bump-my-version
3. Creates GitHub release
4. Builds and publishes to Comfy registry
See and edit @AGENTS.md. Do not touch this file.
+48 -13
View File
@@ -1,6 +1,6 @@
# ComfyUI Node Organizer
Automatically organizes nodes in your workflow using a compact hierarchical layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template)
Automatically organizes ComfyUI workflows with a compact group-aware layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template).
### Preview of organizing a workflow
@@ -19,9 +19,13 @@ Automatically organizes nodes in your workflow using a compact hierarchical layo
## Usage
Right-click on the canvas and select **Organize Workflow**.
Use any of these entry points:
To organize specific groups, select them and choose **Organize Group**.
- Right-click the canvas and choose **Organize Workflow**
- Select groups and choose **Organize Group**
- Click the **Organize** action-bar button
- Use `Extensions > Node Organizer`
- Press `Shift+O`
## Group Layout Tokens
@@ -38,22 +42,53 @@ Add tokens to group titles to control how nodes are arranged:
- `"My Loaders [HORIZONTAL]"` - arranges all nodes in a single row
- `"Processing [3COL]"` - distributes nodes into 3 columns
**Notes:**
- Tokens are case-insensitive (`[horizontal]` works)
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
- Nested groups each respect their own tokens
- Groups without tokens use default DAG-based layout
## Features
## Testing
- **Execution Order Layout**: Nodes arranged left-to-right following data flow
- **Compact Bin Packing**: Reduces vertical height by packing nodes into rows
- **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin
- **Reroute Optimization**: Reroute chains collapsed and restored along edges
- **Group Support**: Nested groups with proper hierarchy
- **Group Layout Tokens**: Control layout with title tokens (see below)
- **Subgraph Support**: Works inside subgraphs with I/O node handling
- `pnpm typecheck`
- `pnpm test`
- `pnpm build`
- `pnpm setup:e2e` provisions a dedicated ComfyUI instance for browser tests and installs the exact workflow-template package required by the pinned ComfyUI checkout
- `pnpm test:e2e` runs the full Playwright suite against that instance
- Broad installed-template coverage is discovered live at E2E runtime from the pinned test ComfyUI environment, with one correctness-invariant test per installed workflow template
Checked-in repo fixtures live in `tests/fixtures/`. Visual regression is graph-canvas scoped and runs as part of the normal E2E suite.
Visual regression baselines are platform-specific: committed snapshots use `-win32` and `-linux` suffixes.
### Local CI
Install [act](https://github.com/nektos/act) and Docker, then run:
- `pnpm ci:local` runs the unit-test and build workflow inside a local container
E2E tests still require the normal local ComfyUI provisioning flow and are not part of the `act` path yet.
## Release
Maintainer flow:
- Merge the release branch into `main`
- Trigger `.github/workflows/publish_action.yaml` manually from `main`
That workflow runs the full test suite, bumps versioned files with `uv run bump-my-version`, creates the tag and GitHub release, then publishes the custom node from the bumped tag ref.
### Manual browser testing
Build the extension first so ComfyUI loads the current `dist/` output:
- `pnpm build`
Launch the test ComfyUI instance on a free port:
- `.test-comfy/venv/Scripts/comfy.exe --skip-prompt --workspace .test-comfy/comfyui/ launch -- --cpu --port 65192`
If the port is already taken, either choose a different port or stop leftover test-instance processes first:
- `Get-Process | Where-Object { $_.Path -like '*comfy-node-organizer\\.test-comfy*' } | Stop-Process -Force`
## Known Limitations
This extension has not been thoroughly tested with very large or complex workflows. If you encounter issues, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a **minimal reproducible workflow** attached.
Very large or unusual workflows may still expose edge cases. If you hit one, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a minimal reproducible workflow attached.
+22
View File
@@ -0,0 +1,22 @@
export const e2eConfig = {
port: 8199,
testComfyDir: ".test-comfy",
comfyRevision: "v0.18.1",
get comfyUrl() {
return `http://localhost:${this.port}`;
},
get comfyInstallDir() {
return `${this.testComfyDir}/comfyui`;
},
get venvDir() {
return `${this.testComfyDir}/venv`;
},
get customNodesDir() {
return `${this.comfyInstallDir}/custom_nodes`;
},
timeouts: {
comfyStartup: 120_000,
pageLoad: 30_000,
organize: 10_000,
},
} as const;
+7 -1
View File
@@ -10,7 +10,10 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"setup:e2e": "tsx scripts/setup-test-comfy.ts",
"test:e2e": "playwright test",
"ci:local": "act push --workflows .github/workflows/ci.yaml -j unit-tests"
},
"keywords": [
"comfyui",
@@ -31,8 +34,11 @@
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.32.1",
"@playwright/test": "^1.58.2",
"@types/node": "^24.10.0",
"@vitest/coverage-v8": "^4.0.16",
"fast-check": "^4.6.0",
"tsx": "^4.21.0",
"vitest": "^4.0.16"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
import { e2eConfig } from "./e2e.config.ts";
export default defineConfig({
testDir: "./tests/e2e",
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{platform}{ext}",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 8,
reporter: "html",
use: {
baseURL: e2eConfig.comfyUrl,
trace: "on-first-retry",
viewport: { width: 1600, height: 1000 },
},
globalSetup: "./tests/e2e/global-setup.ts",
globalTeardown: "./tests/e2e/global-teardown.ts",
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+93 -11
View File
@@ -13,20 +13,29 @@ importers:
version: 5.9.3
vite:
specifier: ^7.1.12
version: 7.3.0(@types/node@24.10.4)
version: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
devDependencies:
'@comfyorg/comfyui-frontend-types':
specifier: ^1.32.1
version: 1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@types/node':
specifier: ^24.10.0
version: 24.10.4
'@vitest/coverage-v8':
specifier: ^4.0.16
version: 4.0.16(vitest@4.0.16(@types/node@24.10.4))
version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0))
fast-check:
specifier: ^4.6.0
version: 4.6.0
tsx:
specifier: ^4.21.0
version: 4.21.0
vitest:
specifier: ^4.0.16
version: 4.0.16(@types/node@24.10.4)
version: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)
packages:
@@ -223,6 +232,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@rollup/rollup-android-arm-eabi@4.53.5':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm]
@@ -460,6 +474,10 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-check@4.6.0:
resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==}
engines: {node: '>=12.17.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -469,11 +487,19 @@ packages:
picomatch:
optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -531,10 +557,26 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
pure-rand@8.3.0:
resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rollup@4.53.5:
resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -577,6 +619,11 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -784,6 +831,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@rollup/rollup-android-arm-eabi@4.53.5':
optional: true
@@ -865,7 +916,7 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4))':
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.16
@@ -878,7 +929,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@types/node@24.10.4)
vitest: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -891,13 +942,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4))':
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.0(@types/node@24.10.4)
vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
'@vitest/pretty-format@4.0.16':
dependencies:
@@ -1032,13 +1083,24 @@ snapshots:
expect-type@1.3.0: {}
fast-check@4.6.0:
dependencies:
pure-rand: 8.3.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
has-flag@4.0.0: {}
html-escaper@2.0.2: {}
@@ -1092,12 +1154,24 @@ snapshots:
picomatch@4.0.3: {}
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
pure-rand@8.3.0: {}
resolve-pkg-maps@1.0.0: {}
rollup@4.53.5:
dependencies:
'@types/estree': 1.0.8
@@ -1151,11 +1225,18 @@ snapshots:
tinyrainbow@3.0.3: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.2
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@7.16.0: {}
vite@7.3.0(@types/node@24.10.4):
vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -1166,11 +1247,12 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
fsevents: 2.3.3
tsx: 4.21.0
vitest@4.0.16(@types/node@24.10.4):
vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4))
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -1187,7 +1269,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.0(@types/node@24.10.4)
vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.10.4
+11 -5
View File
@@ -2,9 +2,15 @@
name = "comfyui-node-organizer"
description = "ComfyUI extension to organize nodes"
version = "1.3.0"
requires-python = ">=3.13"
readme = "README.md"
license = { file = "LICENSE" }
[dependency-groups]
dev = [
"bump-my-version>=1.2.2",
]
[project.urls]
Repository = "https://github.com/PBandDev/comfyui-node-organizer"
@@ -19,8 +25,8 @@ includes = ['dist/']
current_version = "1.3.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
commit = true
tag = true
commit = false
tag = false
tag_name = "v{new_version}"
tag_message = "Release version {new_version}"
message = "chore: bump version to {new_version} [skip ci]"
@@ -36,6 +42,6 @@ search = '"version": "{current_version}"'
replace = '"version": "{new_version}"'
[[tool.bumpversion.files]]
filename = "src/index.ts"
search = 'Version {current_version}'
replace = 'Version {new_version}'
filename = "src/settings.ts"
search = 'export const CURRENT_VERSION = "{current_version}";'
replace = 'export const CURRENT_VERSION = "{new_version}";'
+16
View File
@@ -0,0 +1,16 @@
export function extractPinnedWorkflowTemplatesRequirement(
requirementsText: string,
): string {
const requirement = requirementsText
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith("comfyui-workflow-templates=="));
if (!requirement) {
throw new Error(
"Pinned ComfyUI checkout does not declare comfyui-workflow-templates",
);
}
return requirement;
}
+175
View File
@@ -0,0 +1,175 @@
import { execSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
symlinkSync,
readlinkSync,
rmSync,
} from "node:fs";
import { resolve, join } from "node:path";
import { e2eConfig } from "../e2e.config.ts";
import { extractPinnedWorkflowTemplatesRequirement } from "./setup-test-comfy-helpers";
const isWindows = process.platform === "win32";
const projectRoot = resolve(import.meta.dirname, "..");
const venvDir = resolve(projectRoot, e2eConfig.venvDir);
const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir);
const customNodesDir = resolve(projectRoot, e2eConfig.customNodesDir);
const pythonBin = isWindows
? join(venvDir, "Scripts", "python.exe")
: join(venvDir, "bin", "python");
const comfyBin = isWindows
? join(venvDir, "Scripts", "comfy.exe")
: join(venvDir, "bin", "comfy");
function tryExec(cmd: string): string | null {
try {
return execSync(cmd, {
cwd: projectRoot,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf-8",
}).trim();
} catch {
return null;
}
}
function run(cmd: string, label: string) {
console.log(`[setup] ${label}...`);
console.log(`[setup] $ ${cmd}`);
execSync(cmd, { stdio: "inherit", cwd: projectRoot });
}
function getInstalledComfyRevision(): string | null {
const exactTag = tryExec(
`git -C "${comfyInstallDir}" describe --tags --exact-match HEAD`,
);
if (exactTag) return exactTag;
const gitHead = tryExec(`git -C "${comfyInstallDir}" rev-parse HEAD`);
return gitHead;
}
function installComfy(revision: string): void {
run(
`"${comfyBin}" --skip-prompt --workspace "${comfyInstallDir}" install --version "${revision}" --restore --cpu`,
`Installing ComfyUI ${revision}`,
);
}
function getPinnedWorkflowTemplatesRequirement(): string {
const requirementsPath = resolve(comfyInstallDir, "requirements.txt");
if (!existsSync(requirementsPath)) {
throw new Error(
`Pinned ComfyUI checkout is missing requirements.txt at ${requirementsPath}`,
);
}
return extractPinnedWorkflowTemplatesRequirement(
readFileSync(requirementsPath, "utf-8"),
);
}
// Step 1: Create venv (with --seed so pip is available for comfy-cli)
if (existsSync(pythonBin)) {
console.log("[setup] venv already exists, skipping.");
} else {
if (existsSync(venvDir)) {
console.log("[setup] Removing invalid venv directory before recreate...");
rmSync(venvDir, { recursive: true, force: true });
}
mkdirSync(resolve(projectRoot, e2eConfig.testComfyDir), { recursive: true });
run(`uv venv --seed "${venvDir}"`, "Creating Python venv (with pip)");
}
// Ensure pip is available (handles venvs created without --seed)
try {
execSync(`"${pythonBin}" -m pip --version`, { stdio: "ignore" });
} catch {
console.log("[setup] pip not found in venv, installing...");
run(
`uv pip install pip --python "${pythonBin}"`,
"Installing pip into venv",
);
}
// Step 2: Install comfy-cli
if (existsSync(comfyBin)) {
console.log("[setup] comfy-cli already installed, skipping.");
} else {
run(
`uv pip install comfy-cli --python "${pythonBin}"`,
"Installing comfy-cli",
);
}
// Step 3: Install ComfyUI
// Check for main.py directly in workspace (comfy clones repo as workspace root)
if (existsSync(join(comfyInstallDir, "main.py"))) {
console.log("[setup] ComfyUI repo already cloned.");
const installedRevision = getInstalledComfyRevision();
if (installedRevision === e2eConfig.comfyRevision) {
try {
execSync(`"${pythonBin}" -c "import torch"`, { stdio: "ignore" });
console.log(
`[setup] ComfyUI already pinned at ${e2eConfig.comfyRevision} and dependencies are installed.`,
);
} catch {
installComfy(e2eConfig.comfyRevision);
}
} else {
console.log(
`[setup] Syncing ComfyUI to ${e2eConfig.comfyRevision} (current: ${installedRevision ?? "unknown"})...`,
);
installComfy(e2eConfig.comfyRevision);
}
} else {
// Fresh install: clean up any invalid workspace state
if (existsSync(comfyInstallDir)) {
console.log(
"[setup] Removing invalid workspace directory before install...",
);
rmSync(comfyInstallDir, { recursive: true, force: true });
}
mkdirSync(resolve(comfyInstallDir, ".."), { recursive: true });
installComfy(e2eConfig.comfyRevision);
}
// Step 3b: Install the exact workflow templates package required by the
// pinned ComfyUI checkout. Do not hardcode or float this version here.
const workflowTemplatesRequirement = getPinnedWorkflowTemplatesRequirement();
run(
`uv pip install "${workflowTemplatesRequirement}" --python "${pythonBin}"`,
`Installing ${workflowTemplatesRequirement}`,
);
// Step 4: Symlink extension into custom_nodes
const symlinkTarget = resolve(customNodesDir, "comfy-node-organizer");
if (existsSync(symlinkTarget)) {
try {
const linkDest = readlinkSync(symlinkTarget);
const resolvedLink = resolve(customNodesDir, linkDest);
if (resolvedLink === projectRoot) {
console.log("[setup] Symlink already exists and is correct, skipping.");
} else {
console.log(
`[setup] WARNING: Symlink exists but points to ${resolvedLink}, expected ${projectRoot}`,
);
}
} catch {
console.log(
"[setup] WARNING: Path exists but is not a symlink. Manual cleanup may be needed.",
);
}
} else {
mkdirSync(customNodesDir, { recursive: true });
symlinkSync(projectRoot, symlinkTarget, isWindows ? "junction" : "dir");
console.log(`[setup] Created symlink: ${symlinkTarget} -> ${projectRoot}`);
}
console.log("[setup] Done! Run `pnpm test:e2e` to run e2e tests.");
+451
View File
@@ -0,0 +1,451 @@
/**
* Bridge between ComfyUI's LGraph runtime objects and the pure layout types.
*
* Uses structural typing to avoid tight coupling to ComfyUI class hierarchy.
* Handles Float64Array pos/size fields, Map-based link storage, and group
* hierarchy inference via spatial containment.
*/
import type {
LayoutNode,
LayoutEdge,
LayoutGroup,
Position,
GroupBounds,
LayoutToken,
FrameworkResult,
} from "./layout/types";
import { parseLayoutToken } from "./layout/tokens";
import { debugLog } from "./debug";
import { isGroupInsideGroup, isNodeCenterInsideGroup } from "./group-geometry";
// ---------------------------------------------------------------------------
// Structural types for ComfyUI runtime objects
// ---------------------------------------------------------------------------
interface GraphNode {
readonly id: number | string;
readonly type: string;
readonly title: string;
readonly pos: ArrayLike<number>;
readonly size: ArrayLike<number>;
readonly inputs?: ReadonlyArray<{ link: number | null }>;
readonly outputs?: ReadonlyArray<{ links: number[] | null }>;
}
interface GraphBoundaryNode {
readonly id: number;
readonly pos: ArrayLike<number>;
readonly size: ArrayLike<number>;
}
interface GraphGroup {
readonly id: number;
readonly title: string;
pos: ArrayLike<number>;
size: ArrayLike<number>;
readonly _children?: ReadonlySet<{ readonly id: number | string }>;
}
interface GraphLink {
readonly id: number;
readonly origin_id: number;
readonly target_id: number;
}
/** Minimal structural interface for ComfyUI graph objects (LGraph | Subgraph). */
export interface GraphLike {
readonly _nodes: ReadonlyArray<GraphNode>;
readonly _groups: GraphGroup[];
readonly links: Map<number, GraphLink> | Record<number, GraphLink>;
readonly inputNode?: GraphBoundaryNode;
readonly outputNode?: GraphBoundaryNode;
setDirtyCanvas?(fg: boolean, bg: boolean): void;
}
function toGroupLayoutId(groupId: number): string {
return `group:${groupId}`;
}
function fromGroupLayoutId(groupId: string): number | null {
if (!groupId.startsWith("group:")) return null;
const parsed = Number(groupId.slice("group:".length));
return Number.isFinite(parsed) ? parsed : null;
}
// ---------------------------------------------------------------------------
// Extract layout input from a ComfyUI graph
// ---------------------------------------------------------------------------
export function extractLayoutInput(graph: GraphLike): {
nodes: LayoutNode[];
edges: LayoutEdge[];
groups: LayoutGroup[];
} {
// 1. Convert nodes
const nodes: LayoutNode[] = graph._nodes.map((n) => ({
id: String(n.id),
width: Number(n.size[0]),
height: Number(n.size[1]),
}));
if (graph.inputNode) {
nodes.push({
id: String(graph.inputNode.id),
width: Number(graph.inputNode.size[0]),
height: Number(graph.inputNode.size[1]),
kind: "subgraph-input",
layerConstraint: "first",
});
}
if (graph.outputNode) {
nodes.push({
id: String(graph.outputNode.id),
width: Number(graph.outputNode.size[0]),
height: Number(graph.outputNode.size[1]),
kind: "subgraph-output",
layerConstraint: "last",
});
}
// 2. Extract edges from links
const edges: LayoutEdge[] = [];
const linksIterable =
graph.links instanceof Map
? graph.links.values()
: Object.values(graph.links);
for (const link of linksIterable) {
if (link) {
edges.push({
source: String(link.origin_id),
target: String(link.target_id),
});
}
}
// 3. Build group hierarchy
const groups = buildGroupsFromGraph(graph);
debugLog(
`Extracted: ${nodes.length} nodes, ${edges.length} edges, ${groups.length} groups`,
);
return { nodes, edges, groups };
}
// ---------------------------------------------------------------------------
// Build LayoutGroup array with parent-child hierarchy from spatial containment
// ---------------------------------------------------------------------------
function buildGroupsFromGraph(graph: GraphLike): LayoutGroup[] {
const graphGroups = graph._groups;
if (graphGroups.length === 0) return [];
const graphGroupById = new Map<number, GraphGroup>();
for (const group of graphGroups) {
graphGroupById.set(group.id, group);
}
const childGroupIds = new Map<number, number[]>();
const groupAreas = new Map<number, number>();
for (const group of graphGroups) {
groupAreas.set(group.id, Number(group.size[0]) * Number(group.size[1]));
}
// Determine the nearest containing parent for each group.
for (const inner of graphGroups) {
let nearestParent: GraphGroup | null = null;
let nearestArea = Infinity;
for (const outer of graphGroups) {
if (outer.id === inner.id) continue;
if (!isGroupInside(inner, outer)) continue;
const outerArea = groupAreas.get(outer.id) ?? Infinity;
if (outerArea < nearestArea) {
nearestParent = outer;
nearestArea = outerArea;
}
}
if (nearestParent) {
const children = childGroupIds.get(nearestParent.id) ?? [];
children.push(inner.id);
childGroupIds.set(nearestParent.id, children);
}
}
// Build the LayoutGroup array
const result: LayoutGroup[] = [];
for (const g of graphGroups) {
const memberIds: string[] = [];
for (const node of graph._nodes) {
if (!isNodeInsideGroup(node, g)) continue;
let insideDirectChild = false;
for (const childGroupId of childGroupIds.get(g.id) ?? []) {
const childGroup = graphGroupById.get(childGroupId);
if (childGroup && isNodeInsideGroup(node, childGroup)) {
insideDirectChild = true;
break;
}
}
if (!insideDirectChild) {
memberIds.push(String(node.id));
}
}
const token: LayoutToken | null = parseLayoutToken(g.title);
result.push({
id: toGroupLayoutId(g.id),
title: g.title,
memberIds,
childGroupIds: (childGroupIds.get(g.id) ?? []).map(toGroupLayoutId),
...(token ? { token } : {}),
});
}
return result;
}
/** Check if inner group is spatially inside outer group */
function isGroupInside(inner: GraphGroup, outer: GraphGroup): boolean {
return isGroupInsideGroup(inner, outer);
}
function isNodeInsideGroup(node: GraphNode, group: GraphGroup): boolean {
return isNodeCenterInsideGroup(node, group);
}
// ---------------------------------------------------------------------------
// Apply layout output back to the ComfyUI graph
// ---------------------------------------------------------------------------
export function applyLayoutOutput(
graph: GraphLike,
result: {
readonly positions: ReadonlyMap<string, Position>;
readonly groupBounds: ReadonlyMap<string, GroupBounds>;
},
): void {
// Apply node positions
for (const node of graph._nodes) {
const pos = result.positions.get(String(node.id));
if (pos) {
// Mutate in-place — ComfyUI internals may hold references to the typed array
const mutablePos = node.pos as { [index: number]: number };
mutablePos[0] = pos.x;
mutablePos[1] = pos.y;
}
}
if (graph.inputNode) {
const pos = result.positions.get(String(graph.inputNode.id));
if (pos) {
const mutablePos = graph.inputNode.pos as { [index: number]: number };
mutablePos[0] = pos.x;
mutablePos[1] = pos.y;
}
}
if (graph.outputNode) {
const pos = result.positions.get(String(graph.outputNode.id));
if (pos) {
const mutablePos = graph.outputNode.pos as { [index: number]: number };
mutablePos[0] = pos.x;
mutablePos[1] = pos.y;
}
}
// Apply group bounds
for (const group of graph._groups) {
const namespacedGroupId = toGroupLayoutId(group.id);
const resolvedBounds = result.groupBounds.get(namespacedGroupId);
if (resolvedBounds) {
const mutablePos = group.pos as { [index: number]: number };
mutablePos[0] = resolvedBounds.x;
mutablePos[1] = resolvedBounds.y;
const mutableSize = group.size as { [index: number]: number };
mutableSize[0] = resolvedBounds.width;
mutableSize[1] = resolvedBounds.height;
// NOTE: Do NOT call recomputeInsideNodes() here.
// Our adapter mutates pos/size arrays in-place, which doesn't trigger
// ComfyUI's internal cache invalidation. recomputeInsideNodes() then
// reads stale pre-organize positions and populates _children with
// wrong node IDs. Verified 2026-03-21 via runtime inspection.
// See AGENTS.md for more details.
}
}
// Mark canvas as dirty
if (graph.setDirtyCanvas) {
graph.setDirtyCanvas(true, true);
}
debugLog(
`Applied: ${result.positions.size} positions, ${result.groupBounds.size} group bounds`,
);
}
// ---------------------------------------------------------------------------
// Extract layout input for specific groups only (selected-groups mode)
// ---------------------------------------------------------------------------
export function extractGroupLayoutInput(
graph: GraphLike,
groupIds: ReadonlySet<number>,
): {
nodes: LayoutNode[];
edges: LayoutEdge[];
groups: LayoutGroup[];
} {
// Get the full extraction first
const full = extractLayoutInput(graph);
// Filter to only the selected groups and their nested children
const selectedLayoutGroupIds = new Set(
[...groupIds].map((groupId) => toGroupLayoutId(groupId)),
);
// Expand to include all nested child groups
const allGroupIds = new Set(selectedLayoutGroupIds);
const groupMap = new Map(full.groups.map((g) => [g.id, g]));
function addChildren(gId: string): void {
const g = groupMap.get(gId);
if (!g) return;
for (const childId of g.childGroupIds) {
if (!allGroupIds.has(childId)) {
allGroupIds.add(childId);
addChildren(childId);
}
}
}
for (const gId of selectedLayoutGroupIds) {
addChildren(gId);
}
// Filter groups
const groups = full.groups.filter((g) => allGroupIds.has(g.id));
// Collect all node IDs that belong to these groups
const relevantNodeIds = new Set<string>();
for (const g of groups) {
for (const mId of g.memberIds) {
relevantNodeIds.add(mId);
}
}
// Filter nodes and edges
const nodes = full.nodes.filter((n) => relevantNodeIds.has(n.id));
const edges = full.edges.filter(
(e) => relevantNodeIds.has(e.source) && relevantNodeIds.has(e.target),
);
return { nodes, edges, groups };
}
export function anchorSelectedGroupLayoutResult(
graph: GraphLike,
groups: ReadonlyArray<LayoutGroup>,
selectedGroupIds: ReadonlySet<number>,
result: FrameworkResult,
): FrameworkResult {
const selectedLayoutGroupIds = new Set(
[...selectedGroupIds].map((groupId) => toGroupLayoutId(groupId)),
);
if (selectedLayoutGroupIds.size === 0) {
return result;
}
const parentByChildId = new Map<string, string>();
const groupById = new Map(groups.map((group) => [group.id, group]));
for (const group of groups) {
for (const childGroupId of group.childGroupIds) {
parentByChildId.set(childGroupId, group.id);
}
}
const selectedRootIds = [...selectedLayoutGroupIds].filter((groupId) => {
let parentId = parentByChildId.get(groupId);
while (parentId) {
if (selectedLayoutGroupIds.has(parentId)) {
return false;
}
parentId = parentByChildId.get(parentId);
}
return true;
});
if (selectedRootIds.length === 0) {
return result;
}
const anchoredPositions = new Map(result.positions);
const anchoredGroupBounds = new Map(result.groupBounds);
const graphGroupById = new Map(
graph._groups.map((group) => [group.id, group]),
);
for (const rootGroupId of selectedRootIds) {
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
if (originalGraphGroupId === null) continue;
const originalGroup = graphGroupById.get(originalGraphGroupId);
const layoutRootBounds = anchoredGroupBounds.get(rootGroupId);
if (!originalGroup || !layoutRootBounds) continue;
const dx = Number(originalGroup.pos[0]) - layoutRootBounds.x;
const dy = Number(originalGroup.pos[1]) - layoutRootBounds.y;
if (dx === 0 && dy === 0) continue;
const nodeIdsToTranslate = new Set<string>();
const groupIdsToTranslate = new Set<string>();
const stack = [rootGroupId];
while (stack.length > 0) {
const groupId = stack.pop();
if (!groupId || groupIdsToTranslate.has(groupId)) continue;
groupIdsToTranslate.add(groupId);
const group = groupById.get(groupId);
if (!group) continue;
for (const memberId of group.memberIds) {
nodeIdsToTranslate.add(memberId);
}
for (const childGroupId of group.childGroupIds) {
stack.push(childGroupId);
}
}
for (const nodeId of nodeIdsToTranslate) {
const pos = anchoredPositions.get(nodeId);
if (!pos) continue;
anchoredPositions.set(nodeId, { x: pos.x + dx, y: pos.y + dy });
}
for (const groupId of groupIdsToTranslate) {
const bounds = anchoredGroupBounds.get(groupId);
if (!bounds) continue;
anchoredGroupBounds.set(groupId, {
x: bounds.x + dx,
y: bounds.y + dy,
width: bounds.width,
height: bounds.height,
});
}
}
return {
positions: anchoredPositions,
groupBounds: anchoredGroupBounds,
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { Position, GroupBounds } from "./layout/types";
export interface BoundsItem {
readonly id: number | string;
readonly pos: ArrayLike<number>;
readonly size: ArrayLike<number>;
}
/**
* Compute the bounding rect of all laid-out items for fit-to-view.
* Includes regular nodes, boundary nodes (inputNode/outputNode), and groups.
*/
export function computeGraphBounds(
nodes: ReadonlyArray<BoundsItem>,
inputNode: BoundsItem | undefined,
outputNode: BoundsItem | undefined,
positions: ReadonlyMap<string, Position>,
groupBounds: ReadonlyMap<string, GroupBounds>,
): [number, number, number, number] | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const expandWithItem = (item: BoundsItem): void => {
const pos = positions.get(String(item.id));
if (!pos) return;
const w = item.size[0] ?? 0;
const h = item.size[1] ?? 0;
if (pos.x < minX) minX = pos.x;
if (pos.y < minY) minY = pos.y;
if (pos.x + w > maxX) maxX = pos.x + w;
if (pos.y + h > maxY) maxY = pos.y + h;
};
for (const node of nodes) {
expandWithItem(node);
}
if (inputNode) expandWithItem(inputNode);
if (outputNode) expandWithItem(outputNode);
for (const [, gb] of groupBounds) {
if (gb.x < minX) minX = gb.x;
if (gb.y < minY) minY = gb.y;
if (gb.x + gb.width > maxX) maxX = gb.x + gb.width;
if (gb.y + gb.height > maxY) maxY = gb.y + gb.height;
}
if (!Number.isFinite(minX)) return null;
return [minX, minY, maxX - minX, maxY - minY];
}
-5
View File
@@ -1,5 +0,0 @@
export const SETTINGS_PREFIX = "Node Organizer";
export const SETTINGS_IDS = {
VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`,
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
};
+5 -3
View File
@@ -1,4 +1,4 @@
import { SETTINGS_IDS } from "./consants";
import { SETTING_IDS } from "./settings";
/** Check if debug logging is enabled */
export function isDebugEnabled(): boolean {
@@ -8,13 +8,15 @@ export function isDebugEnabled(): boolean {
const app = (
globalThis as {
app?: {
extensionManager?: { setting?: { get: <T>(id: string) => T } };
extensionManager?: {
setting?: { get: <T>(id: string) => T };
};
};
}
).app;
return (
app?.extensionManager?.setting?.get<boolean>(
SETTINGS_IDS.DEBUG_LOGGING
SETTING_IDS.DEBUG_LOGGING,
) ?? false
);
}
+43
View File
@@ -0,0 +1,43 @@
export interface PositionedItem {
readonly pos: ArrayLike<number>;
readonly size: ArrayLike<number>;
}
export function isGroupInsideGroup(
inner: PositionedItem,
outer: PositionedItem,
): boolean {
const ix = Number(inner.pos[0]);
const iy = Number(inner.pos[1]);
const iw = Number(inner.size[0]);
const ih = Number(inner.size[1]);
const ox = Number(outer.pos[0]);
const oy = Number(outer.pos[1]);
const ow = Number(outer.size[0]);
const oh = Number(outer.size[1]);
return (
ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh
);
}
export function isNodeCenterInsideGroup(
node: PositionedItem,
group: PositionedItem,
): boolean {
const centerX = Number(node.pos[0]) + Number(node.size[0]) / 2;
const centerY = Number(node.pos[1]) + Number(node.size[1]) / 2;
const gx = Number(group.pos[0]);
const gy = Number(group.pos[1]);
const gw = Number(group.size[0]);
const gh = Number(group.size[1]);
return (
centerX >= gx &&
centerX <= gx + gw &&
centerY >= gy &&
centerY <= gy + gh
);
}
+370 -138
View File
@@ -1,181 +1,413 @@
/**
* ComfyUI Node Organizer — extension entry point.
*
* Registers commands, keybindings, settings, and context menu items
* that wire the pure layout framework into ComfyUI's runtime.
*/
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import type { LGraph, LGraphGroup } from "./layout/types";
import { layoutGraph, layoutSelectedGroups } from "./layout";
import { SETTINGS_IDS } from "./consants";
/** Context menu option */
interface ContextMenuOption {
content: string;
callback?: () => void;
}
/** LGraphCanvas type (available at runtime) */
interface LGraphCanvasType {
prototype: {
getCanvasMenuOptions: () => (ContextMenuOption | null)[];
};
}
import {
SETTING_IDS,
EXTENSION_NAME,
SETTINGS_PREFIX,
REPOSITORY_URL,
CURRENT_VERSION,
DEFAULT_ALGORITHM_OPTIONS,
} from "./settings";
import type { DefaultAlgorithmName } from "./settings";
import { debugLog } from "./debug";
import {
extractLayoutInput,
extractGroupLayoutInput,
applyLayoutOutput,
anchorSelectedGroupLayoutResult,
} from "./adapter";
import type { GraphLike } from "./adapter";
import { layoutWithGroups } from "./layout/framework";
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
import type { LayoutAlgorithm, FrameworkConfig } from "./layout/types";
import { computeGraphBounds } from "./bounds";
import { DEFAULT_FRAMEWORK_CONFIG } from "./layout/types";
import { isGroup } from "./utils";
import { getCurrentGraph, getSelectedGroups, type AppLike } from "./runtime";
// Declare the global app variable injected by ComfyUI's runtime loader
declare global {
const app: ComfyApp & {
canvas?: {
selectedItems?: Set<unknown>;
};
const app: ComfyApp;
}
const DEFAULT_ALGORITHM_NAME: DefaultAlgorithmName = "sugiyama";
const algorithmFactories: Record<
DefaultAlgorithmName,
(config: Partial<FrameworkConfig>) => LayoutAlgorithm
> = {
sugiyama: (config) =>
createSugiyamaAlgorithm({
horizontalGap: config.horizontalGap,
verticalGap: config.verticalGap,
}),
horizontal: (config) => createHorizontalAlgorithm(config.horizontalGap),
vertical: (config) => createVerticalAlgorithm(config.verticalGap),
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getDefaultAlgorithmName(): DefaultAlgorithmName {
const raw = (app.extensionManager?.setting?.get?.<string>(
SETTING_IDS.DEFAULT_ALGORITHM,
) ?? DEFAULT_ALGORITHM_NAME) as string;
return DEFAULT_ALGORITHM_OPTIONS.includes(raw as DefaultAlgorithmName)
? (raw as DefaultAlgorithmName)
: DEFAULT_ALGORITHM_NAME;
}
export function createConfiguredDefaultAlgorithm(
algorithmName: DefaultAlgorithmName,
config: Partial<FrameworkConfig>,
): LayoutAlgorithm {
return algorithmFactories[algorithmName](config);
}
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
return createConfiguredDefaultAlgorithm(getDefaultAlgorithmName(), config);
}
function getFrameworkConfig(): Partial<FrameworkConfig> {
const get = app.extensionManager?.setting?.get;
if (!get) return {};
return {
horizontalGap:
get<number | undefined>(SETTING_IDS.HORIZONTAL_GAP as never) ??
DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
verticalGap:
get<number | undefined>(SETTING_IDS.VERTICAL_GAP as never) ??
DEFAULT_FRAMEWORK_CONFIG.verticalGap,
groupPadding:
get<number | undefined>(SETTING_IDS.GROUP_PADDING as never) ??
DEFAULT_FRAMEWORK_CONFIG.groupPadding,
disconnectedGap:
get<number | undefined>(SETTING_IDS.DISCONNECTED_GAP as never) ??
DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
};
const LGraphCanvas: LGraphCanvasType;
interface Window {
app: ComfyApp;
}
}
/**
* Check if value is array-like (regular array or typed array like Float64Array)
*/
function isArrayLike(value: unknown): boolean {
if (Array.isArray(value)) return true;
if (!value || typeof value !== "object") return false;
// Check for typed arrays (Float64Array, etc.) which have numeric length
const obj = value as { length?: unknown };
return typeof obj.length === "number" && obj.length >= 0;
}
/**
* Check if an item is a group (duck typing)
* Groups have pos/size (can be Float64Array), title, id, but no .type like nodes
*/
function isLGraphGroup(item: unknown): item is LGraphGroup {
if (!item || typeof item !== "object") return false;
const obj = item as Record<string, unknown>;
function getFitToView(): boolean {
return (
isArrayLike(obj.pos) &&
isArrayLike(obj.size) &&
typeof obj.title === "string" &&
typeof obj.id === "number" &&
typeof obj.type !== "string" // Nodes have .type, groups don't
app.extensionManager?.setting?.get?.<boolean>(
SETTING_IDS.FIT_TO_VIEW as never,
) ?? false
);
}
/**
* Get selected groups from global app.canvas.selectedItems
* Per ComfyUI docs: use app.canvas.selectedItems for selection access
*/
function getSelectedGroups(): LGraphGroup[] {
const groups: LGraphGroup[] = [];
const selectedItems = app.canvas?.selectedItems;
if (!selectedItems) return groups;
// ---------------------------------------------------------------------------
// Core actions
// ---------------------------------------------------------------------------
for (const item of selectedItems) {
if (isLGraphGroup(item)) {
groups.push(item);
/** Organize every node in the current graph. */
function executeOrganization(mode: "workflow" | "groups"): void {
const graph = getCurrentGraph(app as AppLike<GraphLike>);
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
const config = getFrameworkConfig();
const algorithm = getDefaultAlgorithm(config);
const start = performance.now();
debugLog(`Starting ${mode} organization with ${algorithm.name}...`);
try {
const selectedGroupIds =
mode === "groups"
? new Set(getSelectedGroups(app.canvas).map((group) => group.id))
: null;
const input =
mode === "workflow"
? extractLayoutInput(graph)
: extractGroupLayoutInput(graph, selectedGroupIds ?? new Set<number>());
const { nodes, edges, groups } = input;
const rawResult = layoutWithGroups(nodes, edges, groups, algorithm, config);
const result =
mode === "groups" && selectedGroupIds
? anchorSelectedGroupLayoutResult(
graph,
groups,
selectedGroupIds,
rawResult,
)
: rawResult;
applyLayoutOutput(graph, result);
const elapsed = (performance.now() - start).toFixed(1);
debugLog(
`Organized ${nodes.length} nodes in ${elapsed}ms with ${algorithm.name}`,
);
if (mode === "workflow" && getFitToView()) {
const bounds = computeGraphBounds(
graph._nodes,
graph.inputNode,
graph.outputNode,
result.positions,
result.groupBounds,
);
if (bounds) {
// Defer by one frame: applyLayoutOutput calls setDirtyCanvas, but the
// canvas needs one animation frame to process the dirty flag.
requestAnimationFrame(() => {
const canvas = app.canvas as {
animateToBounds?: (
bounds: readonly [number, number, number, number],
opts?: { zoom?: number },
) => void;
} | null;
canvas?.animateToBounds?.(bounds, { zoom: 0.9 });
});
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[node-organizer] ${mode} layout failed: ${message}`);
}
return groups;
}
app.registerExtension({
name: "ComfyUI Node Organizer",
settings: [
{
id: SETTINGS_IDS.VERSION,
name: "Version 1.3.0",
type: () => {
const spanEl = document.createElement("span");
spanEl.insertAdjacentHTML(
"beforeend",
`<a href="https://github.com/PBandDev/comfyui-node-organizer" target="_blank" style="padding-right: 12px;">Homepage</a>`
);
/** Organize the full workflow. */
function organizeWorkflow(): void {
executeOrganization("workflow");
}
return spanEl;
},
defaultValue: undefined,
/** Organize nodes within currently selected groups. */
function organizeSelectedGroups(): void {
const selectedGroups = getSelectedGroups(app.canvas);
if (selectedGroups.length === 0) {
console.warn("[node-organizer] No groups selected");
return;
}
executeOrganization("groups");
}
/** Context-aware organize: selected groups if any, otherwise full workflow. */
function organizeContextAware(): void {
const selectedGroups = getSelectedGroups(app.canvas);
if (selectedGroups.length > 0) {
executeOrganization("groups");
} else {
executeOrganization("workflow");
}
}
// ---------------------------------------------------------------------------
// Extension registration
// ---------------------------------------------------------------------------
app.registerExtension({
name: EXTENSION_NAME,
settings: [
// --- About (sortOrder 50 — highest = first in ComfyUI's descending sort) ---
{
id: SETTING_IDS.VERSION as never,
name: `Version ${CURRENT_VERSION}`,
category: [SETTINGS_PREFIX, "About", "Version"],
sortOrder: 50,
type: (() => {
const span = document.createElement("span");
const link = document.createElement("a");
link.href = REPOSITORY_URL;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.paddingRight = "12px";
link.textContent = "Homepage";
span.appendChild(link);
return span;
}) as never,
defaultValue: undefined as never,
},
// --- Layout (sortOrder 40–44) ---
{
id: SETTING_IDS.DEFAULT_ALGORITHM as never,
name: "Default Layout Algorithm",
category: [SETTINGS_PREFIX, "Layout", "Algorithm"],
sortOrder: 44,
type: "combo",
tooltip:
"Default algorithm for organize commands when a group title token does not override it",
options: DEFAULT_ALGORITHM_OPTIONS.map((value) => ({
text: value[0].toUpperCase() + value.slice(1),
value,
})),
defaultValue: DEFAULT_ALGORITHM_NAME,
},
{
id: SETTINGS_IDS.DEBUG_LOGGING,
name: "Enable Debug Logging",
id: SETTING_IDS.HORIZONTAL_GAP as never,
name: "Horizontal Gap",
category: [SETTINGS_PREFIX, "Layout", "Horizontal Gap"],
sortOrder: 43,
type: "slider",
tooltip: `Space between nodes left-to-right in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.horizontalGap}`,
attrs: { min: 20, max: 300, step: 10 },
defaultValue: DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
},
{
id: SETTING_IDS.VERTICAL_GAP as never,
name: "Vertical Gap",
category: [SETTINGS_PREFIX, "Layout", "Vertical Gap"],
sortOrder: 42,
type: "slider",
tooltip: `Space between nodes top-to-bottom in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.verticalGap}`,
attrs: { min: 10, max: 150, step: 10 },
defaultValue: DEFAULT_FRAMEWORK_CONFIG.verticalGap,
},
{
id: SETTING_IDS.GROUP_PADDING as never,
name: "Group Padding",
category: [SETTINGS_PREFIX, "Layout", "Group Padding"],
sortOrder: 41,
type: "slider",
tooltip: `Inner padding of group boxes in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.groupPadding}`,
attrs: { min: 10, max: 100, step: 5 },
defaultValue: DEFAULT_FRAMEWORK_CONFIG.groupPadding,
},
{
id: SETTING_IDS.DISCONNECTED_GAP as never,
name: "Disconnected Node Gap",
category: [SETTINGS_PREFIX, "Layout", "Disconnected Gap"],
sortOrder: 40,
type: "slider",
tooltip: `Gap between disconnected nodes (e.g. notes) and the main graph in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.disconnectedGap}`,
attrs: { min: 50, max: 400, step: 10 },
defaultValue: DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
},
// --- Behavior (sortOrder 30) ---
{
id: SETTING_IDS.FIT_TO_VIEW as never,
name: "Fit to View After Organize",
category: [SETTINGS_PREFIX, "Behavior", "Fit to View"],
sortOrder: 30,
type: "boolean",
tooltip:
"Show detailed debug logs in browser console during workflow organization",
"Automatically zoom to fit the graph after organizing the full workflow. Does not apply to selected-group organize.",
defaultValue: false,
},
// --- Keybindings (sortOrder 20) ---
{
id: SETTING_IDS.KEYBINDINGS as never,
name: "Keyboard Shortcuts",
category: [SETTINGS_PREFIX, "Keybindings", "Info"],
sortOrder: 20,
type: (() => {
const span = document.createElement("span");
span.style.color = "var(--p-text-muted-color, #999)";
span.textContent =
"Default: Shift+O (Organize Workflow). Edit shortcuts in Settings > Keybinding.";
return span;
}) as never,
defaultValue: undefined as never,
},
// --- Advanced (sortOrder 10 — lowest = last in ComfyUI's descending sort) ---
{
id: SETTING_IDS.DEBUG_LOGGING as never,
name: "Enable Debug Logging",
category: [SETTINGS_PREFIX, "Advanced", "Debug Logging"],
sortOrder: 10,
type: "boolean",
tooltip:
"Show detailed debug logs in browser console during organization",
defaultValue: false,
},
],
getCanvasMenuItems(canvas) {
const items: (ContextMenuOption | null)[] = [
commands: [
{
id: "node-organizer.organize",
function: organizeContextAware,
label: "Organize",
icon: "pi pi-sitemap",
tooltip:
"Organize selected groups if any are selected, otherwise organize the full workflow",
},
{
id: "node-organizer.organize-workflow",
function: organizeWorkflow,
label: "Organize Workflow",
icon: "pi pi-sitemap",
tooltip: "Organize all nodes in the current view",
},
{
id: "node-organizer.organize-groups",
function: organizeSelectedGroups,
label: "Organize Group",
icon: "pi pi-objects-column",
tooltip: "Organize nodes within selected groups",
},
],
keybindings: [
{
commandId: "node-organizer.organize",
combo: { key: "o", shift: true },
},
],
menuCommands: [
{
path: ["Extensions", "Node Organizer"],
commands: [
"node-organizer.organize-workflow",
"node-organizer.organize-groups",
],
},
],
actionBarButtons: [
{
label: "Organize",
icon: "pi pi-sitemap",
tooltip: "Organize the current workflow",
onClick: organizeWorkflow,
},
],
getCanvasMenuItems() {
const items: Array<{
content: string;
callback: () => void;
} | null> = [
null, // separator
{
content: "Organize Workflow",
callback: () => {
const graph = canvas.getCurrentGraph() as LGraph | null;
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
try {
const result = layoutGraph(graph);
console.log(
`[node-organizer] Layout complete: ${result.nodeCount} nodes, ` +
`${result.layerCount} layers, ${result.groupCount} groups, ` +
`${result.executionMs.toFixed(1)}ms`
);
} catch (err) {
console.error("[node-organizer] Layout failed:", err);
}
},
callback: organizeWorkflow,
},
];
// Check for selected groups via app.canvas.selectedItems
const selectedGroups = getSelectedGroups();
const selectedGroups = getSelectedGroups(app.canvas);
if (selectedGroups.length === 1) {
items.push({
content: "Organize Group",
callback: () => {
const graph = canvas.getCurrentGraph() as LGraph | null;
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
try {
const groupIds = new Set([selectedGroups[0].id]);
const result = layoutSelectedGroups(graph, groupIds);
console.log(
`[node-organizer] Group organized: ${result.nodeCount} nodes, ` +
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
} catch (err) {
console.error("[node-organizer] Group layout failed:", err);
}
},
callback: organizeSelectedGroups,
});
} else if (selectedGroups.length > 1) {
items.push({
content: `Organize ${selectedGroups.length} Groups`,
callback: () => {
const graph = canvas.getCurrentGraph() as LGraph | null;
if (!graph) {
console.warn("[node-organizer] No active graph");
return;
}
try {
const groupIds = new Set(selectedGroups.map((g) => g.id));
const result = layoutSelectedGroups(graph, groupIds);
console.log(
`[node-organizer] Groups organized: ${result.nodeCount} nodes, ` +
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
} catch (err) {
console.error("[node-organizer] Groups layout failed:", err);
}
},
callback: organizeSelectedGroups,
});
}
items.push(null); // separator
return items;
},
getSelectionToolboxCommands(selectedItem) {
if (isGroup(selectedItem)) {
return ["node-organizer.organize-groups"];
}
return [];
},
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Grid layout algorithm — distributes nodes into rows and columns.
*
* Supports specifying either a row count or column count.
* Nodes fill left-to-right, top-to-bottom.
* Ignores edges (token layouts ignore DAG structure).
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
const DEFAULT_GAP = 40;
export interface GridConfig {
readonly rows?: number;
readonly columns?: number;
readonly gap?: number;
}
export function createGridAlgorithm(config: GridConfig): LayoutAlgorithm {
const gap = config.gap ?? DEFAULT_GAP;
return {
name: "grid",
layout(input: LayoutInput): LayoutOutput {
const positions = new Map<string, Position>();
const { nodes } = input;
if (nodes.length === 0) {
return { positions };
}
const n = nodes.length;
// Determine grid dimensions
let rows: number;
let cols: number;
if (config.rows !== undefined) {
rows = config.rows;
cols = Math.ceil(n / rows);
} else if (config.columns !== undefined) {
cols = config.columns;
rows = Math.ceil(n / cols);
} else {
// Default: sqrt(n) columns
cols = Math.ceil(Math.sqrt(n));
rows = Math.ceil(n / cols);
}
// Find max node dimensions for uniform cell sizing
let maxWidth = 0;
let maxHeight = 0;
for (const node of nodes) {
if (node.width > maxWidth) maxWidth = node.width;
if (node.height > maxHeight) maxHeight = node.height;
}
const cellWidth = maxWidth + gap;
const cellHeight = maxHeight + gap;
// Place nodes left-to-right, top-to-bottom
// Node index i goes to (row = floor(i / cols), col = i % cols)
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const row = Math.floor(i / cols);
const col = i % cols;
// Center node within its cell
const x = col * cellWidth + (maxWidth - node.width) / 2;
const y = row * cellHeight + (maxHeight - node.height) / 2;
positions.set(node.id, { x, y });
}
return { positions };
},
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Horizontal layout algorithm — places nodes in a single left-to-right row.
*
* Ignores edges entirely (token layouts ignore DAG structure).
* Nodes are placed in input order; vertically centered by midpoint.
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
const DEFAULT_GAP = 40;
export function createHorizontalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm {
return {
name: "horizontal",
layout(input: LayoutInput): LayoutOutput {
const positions = new Map<string, Position>();
const { nodes } = input;
if (nodes.length === 0) {
return { positions };
}
// Find max height for vertical centering
let maxHeight = 0;
for (const node of nodes) {
if (node.height > maxHeight) maxHeight = node.height;
}
// Place nodes left-to-right, centered vertically
let currentX = 0;
for (const node of nodes) {
const centeredY = maxHeight / 2 - node.height / 2;
positions.set(node.id, { x: currentX, y: centeredY });
currentX += node.width + gap;
}
return { positions };
},
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Sugiyama layered DAG layout algorithm.
*
* Implements the classic 4-phase approach:
* 1. Cycle breaking (greedy feedback arc set)
* 2. Layer assignment (longest-path)
* 3. Crossing minimization (barycenter heuristic)
* 4. Coordinate assignment (stacked + centered)
*
* All steps are pure functions — no side effects, deterministic output.
*/
import type {
LayoutAlgorithm,
LayoutInput,
LayoutOutput,
LayoutNode,
} from "../types";
import { breakCycles } from "./sugiyama/cycle-break";
import { assignLayers } from "./sugiyama/layer-assign";
import { minimizeCrossings } from "./sugiyama/crossing-minimize";
import { assignCoordinates } from "./sugiyama/coordinate-assign";
export interface SugiyamaConfig {
readonly horizontalGap: number;
readonly verticalGap: number;
readonly maxIterations: number;
}
export const DEFAULT_SUGIYAMA_CONFIG: SugiyamaConfig = {
horizontalGap: 100,
verticalGap: 40,
maxIterations: 24,
};
/**
* Create a Sugiyama layout algorithm instance.
* The returned object implements LayoutAlgorithm and can be passed to
* the layout framework.
*/
export function createSugiyamaAlgorithm(
config?: Partial<SugiyamaConfig>,
): LayoutAlgorithm {
const cfg: SugiyamaConfig = { ...DEFAULT_SUGIYAMA_CONFIG, ...config };
return {
name: "sugiyama",
layout(input: LayoutInput): LayoutOutput {
const { nodes, edges } = input;
if (nodes.length === 0) {
return { positions: new Map() };
}
// Build node lookup
const nodeMap = new Map<string, LayoutNode>();
for (const n of nodes) {
nodeMap.set(n.id, n);
}
// Phase 1: Break cycles
const { edges: acyclicEdges } = breakCycles(nodes, edges);
// Phase 2: Assign layers (longest-path)
const layerMap = assignLayers(nodes, acyclicEdges);
// Build layer arrays from the map
const layerArrays = buildLayerArrays(layerMap, nodes);
// Phase 3: Minimize crossings (barycenter heuristic)
const orderedLayers = minimizeCrossings(
layerArrays,
acyclicEdges,
nodeMap,
cfg.maxIterations,
);
// Phase 4: Assign coordinates
const positions = assignCoordinates(orderedLayers, nodeMap, {
horizontalGap: cfg.horizontalGap,
verticalGap: cfg.verticalGap,
});
return { positions };
},
};
}
/**
* Convert a layer map (node -> layer index) into an array of arrays,
* preserving original node order within each layer for determinism.
*/
function buildLayerArrays(
layerMap: ReadonlyMap<string, number>,
nodes: ReadonlyArray<LayoutNode>,
): ReadonlyArray<ReadonlyArray<string>> {
// Find max layer
let maxLayer = 0;
for (const layer of layerMap.values()) {
if (layer > maxLayer) maxLayer = layer;
}
// Build arrays preserving input order within each layer
const layers: string[][] = [];
for (let i = 0; i <= maxLayer; i++) {
layers.push([]);
}
for (const n of nodes) {
const layer = layerMap.get(n.id);
if (layer !== undefined) {
layers[layer].push(n.id);
}
}
return layers;
}
@@ -0,0 +1,99 @@
/**
* Coordinate assignment for layered graphs.
*
* Assigns X coordinates based on cumulative layer widths (left-to-right)
* and Y coordinates by stacking nodes top-to-bottom within each layer.
*/
import type { LayoutNode, Position } from "../../types";
interface CoordinateConfig {
readonly horizontalGap: number;
readonly verticalGap: number;
}
/**
* Assign (x, y) coordinates to all nodes based on their layer ordering.
*
* - X: each layer starts after the previous layer's max width + horizontalGap
* - Y: nodes within a layer are stacked vertically with verticalGap between them
* - Vertical centering: each layer is centered relative to the tallest layer
* - Overlap sweep: after centering, ensure no overlaps within each layer
*/
export function assignCoordinates(
layers: ReadonlyArray<ReadonlyArray<string>>,
nodeMap: ReadonlyMap<string, LayoutNode>,
config: CoordinateConfig,
): ReadonlyMap<string, Position> {
const positions = new Map<string, Position>();
if (layers.length === 0) return positions;
// Compute max width per layer and total height per layer
const layerMaxWidths: number[] = [];
const layerTotalHeights: number[] = [];
for (const layer of layers) {
let maxWidth = 0;
let totalHeight = 0;
for (let i = 0; i < layer.length; i++) {
const node = nodeMap.get(layer[i]);
if (!node) continue;
if (node.width > maxWidth) maxWidth = node.width;
totalHeight += node.height;
if (i > 0) totalHeight += config.verticalGap;
}
layerMaxWidths.push(maxWidth);
layerTotalHeights.push(totalHeight);
}
// Find the tallest layer for vertical centering
let maxTotalHeight = 0;
for (const h of layerTotalHeights) {
if (h > maxTotalHeight) maxTotalHeight = h;
}
// Compute X start positions for each layer
const layerXStarts: number[] = [];
let currentX = 0;
for (let li = 0; li < layers.length; li++) {
layerXStarts.push(currentX);
currentX += layerMaxWidths[li] + config.horizontalGap;
}
// Assign positions: stack top-to-bottom within each layer, then center
for (let li = 0; li < layers.length; li++) {
const layer = layers[li];
const layerX = layerXStarts[li];
// Center this layer vertically relative to tallest layer
const yOffset = (maxTotalHeight - layerTotalHeights[li]) / 2;
let currentY = yOffset;
for (const nodeId of layer) {
const node = nodeMap.get(nodeId);
if (!node) continue;
positions.set(nodeId, { x: layerX, y: currentY });
currentY += node.height + config.verticalGap;
}
// Overlap sweep: ensure no node overlaps the previous one in this layer.
// This handles edge cases where centering + variable sizes create overlaps.
for (let i = 1; i < layer.length; i++) {
const prevId = layer[i - 1];
const currId = layer[i];
const prevNode = nodeMap.get(prevId);
const prevPos = positions.get(prevId);
const currPos = positions.get(currId);
if (!prevNode || !prevPos || !currPos) continue;
const minY = prevPos.y + prevNode.height + config.verticalGap;
if (currPos.y < minY) {
positions.set(currId, { x: currPos.x, y: minY });
}
}
}
return positions;
}
@@ -0,0 +1,245 @@
/**
* Barycenter crossing minimization.
*
* Repeatedly sweeps forward and backward through layers, reordering
* nodes within each layer by the average (barycenter) position of
* their neighbors in the adjacent fixed layer.
*/
import type { LayoutNode, LayoutEdge } from "../../types";
/**
* Reorder nodes within each layer to minimize edge crossings.
* Uses the barycenter heuristic with alternating forward/backward sweeps.
*
* @param layers - Node IDs grouped by layer index
* @param edges - Directed edges (assumed acyclic, source layer < target layer)
* @param nodeMap - Lookup for node dimensions (used for tie-breaking stability)
* @param maxIterations - Number of full forward+backward sweeps (default 24)
* @returns Reordered layers
*/
export function minimizeCrossings(
layers: ReadonlyArray<ReadonlyArray<string>>,
edges: ReadonlyArray<LayoutEdge>,
_nodeMap: ReadonlyMap<string, LayoutNode>,
maxIterations: number = 24,
): ReadonlyArray<ReadonlyArray<string>> {
if (layers.length <= 1) return layers;
// Build adjacency lookup for cross-layer connections
// For each node, store indices of connected nodes in adjacent layers
const nodeToLayer = new Map<string, number>();
for (let li = 0; li < layers.length; li++) {
for (const id of layers[li]) {
nodeToLayer.set(id, li);
}
}
// Forward neighbors: node -> nodes in the next layer
const forwardNeighbors = new Map<string, string[]>();
// Backward neighbors: node -> nodes in the previous layer
const backwardNeighbors = new Map<string, string[]>();
for (const layer of layers) {
for (const id of layer) {
forwardNeighbors.set(id, []);
backwardNeighbors.set(id, []);
}
}
for (const e of edges) {
const srcLayer = nodeToLayer.get(e.source);
const tgtLayer = nodeToLayer.get(e.target);
if (srcLayer === undefined || tgtLayer === undefined) continue;
if (tgtLayer > srcLayer) {
forwardNeighbors.get(e.source)!.push(e.target);
backwardNeighbors.get(e.target)!.push(e.source);
} else if (srcLayer > tgtLayer) {
forwardNeighbors.get(e.target)!.push(e.source);
backwardNeighbors.get(e.source)!.push(e.target);
}
}
// Mutable copy of layers
let current: string[][] = layers.map((layer) => [...layer]);
let bestCrossings = countAllCrossings(current, forwardNeighbors);
let bestLayers: string[][] = current.map((layer) => [...layer]);
for (let iter = 0; iter < maxIterations; iter++) {
// Forward sweep: fix layer i, reorder layer i+1
for (let li = 1; li < current.length; li++) {
current[li] = reorderByBarycenter(
current[li],
current[li - 1],
backwardNeighbors,
);
}
// Backward sweep: fix layer i, reorder layer i-1
for (let li = current.length - 2; li >= 0; li--) {
current[li] = reorderByBarycenter(
current[li],
current[li + 1],
forwardNeighbors,
);
}
const crossings = countAllCrossings(current, forwardNeighbors);
if (crossings < bestCrossings) {
bestCrossings = crossings;
bestLayers = current.map((layer) => [...layer]);
}
// Early exit if no crossings remain
if (bestCrossings === 0) break;
}
return bestLayers;
}
/**
* Reorder `layer` based on barycenter positions in `fixedLayer`.
* `neighborsMap` maps each node in `layer` to its neighbors in `fixedLayer`.
*/
function reorderByBarycenter(
layer: ReadonlyArray<string>,
fixedLayer: ReadonlyArray<string>,
neighborsMap: ReadonlyMap<string, string[]>,
): string[] {
// Build position index for fixed layer
const fixedPosition = new Map<string, number>();
for (let i = 0; i < fixedLayer.length; i++) {
fixedPosition.set(fixedLayer[i], i);
}
// Compute barycenter for each node
const barycenters = new Map<string, number>();
for (const id of layer) {
const neighbors = neighborsMap.get(id) ?? [];
const relevantPositions: number[] = [];
for (const n of neighbors) {
const pos = fixedPosition.get(n);
if (pos !== undefined) {
relevantPositions.push(pos);
}
}
if (relevantPositions.length > 0) {
const sum = relevantPositions.reduce((a, b) => a + b, 0);
barycenters.set(id, sum / relevantPositions.length);
}
// Nodes with no connections keep their original position
}
// Sort: nodes with barycenters first (by value), then nodes without (keep original order)
const withBarycenter: string[] = [];
const withoutBarycenter: string[] = [];
const originalIndex = new Map<string, number>();
for (let i = 0; i < layer.length; i++) {
originalIndex.set(layer[i], i);
if (barycenters.has(layer[i])) {
withBarycenter.push(layer[i]);
} else {
withoutBarycenter.push(layer[i]);
}
}
withBarycenter.sort((a, b) => {
const ba = barycenters.get(a)!;
const bb = barycenters.get(b)!;
if (ba !== bb) return ba - bb;
// Stable tie-break: original order
return originalIndex.get(a)! - originalIndex.get(b)!;
});
// Merge: interleave nodes without barycenters at their original positions
// Simple approach: put barycenter-sorted nodes first, then the rest
// Better approach: insert non-barycenter nodes at their original relative positions
const result: string[] = new Array(layer.length);
const usedSlots = new Set<number>();
// Place nodes without barycenters at their original indices first
for (const id of withoutBarycenter) {
const idx = originalIndex.get(id)!;
result[idx] = id;
usedSlots.add(idx);
}
// Fill remaining slots with barycenter-sorted nodes
let bIdx = 0;
for (let i = 0; i < result.length; i++) {
if (!usedSlots.has(i)) {
result[i] = withBarycenter[bIdx++];
}
}
return result;
}
/**
* Count total edge crossings between all adjacent layer pairs.
*/
function countAllCrossings(
layers: ReadonlyArray<ReadonlyArray<string>>,
forwardNeighbors: ReadonlyMap<string, string[]>,
): number {
let total = 0;
for (let li = 0; li < layers.length - 1; li++) {
total += countCrossingsBetweenLayers(
layers[li],
layers[li + 1],
forwardNeighbors,
);
}
return total;
}
/**
* Count edge crossings between two adjacent layers.
* Two edges (u1->v1) and (u2->v2) cross if u1 is before u2 in topLayer
* but v1 is after v2 in bottomLayer (or vice versa).
*/
function countCrossingsBetweenLayers(
topLayer: ReadonlyArray<string>,
bottomLayer: ReadonlyArray<string>,
forwardNeighbors: ReadonlyMap<string, string[]>,
): number {
// Build position maps
const bottomPos = new Map<string, number>();
for (let i = 0; i < bottomLayer.length; i++) {
bottomPos.set(bottomLayer[i], i);
}
// Collect all edges as (topIndex, bottomIndex) pairs
const edgePairs: Array<{ top: number; bottom: number }> = [];
for (let ti = 0; ti < topLayer.length; ti++) {
const neighbors = forwardNeighbors.get(topLayer[ti]) ?? [];
for (const n of neighbors) {
const bi = bottomPos.get(n);
if (bi !== undefined) {
edgePairs.push({ top: ti, bottom: bi });
}
}
}
// Count crossings by comparing all pairs
// O(E^2) but fine for typical graph sizes
let crossings = 0;
for (let i = 0; i < edgePairs.length; i++) {
for (let j = i + 1; j < edgePairs.length; j++) {
const e1 = edgePairs[i];
const e2 = edgePairs[j];
if (
(e1.top < e2.top && e1.bottom > e2.bottom) ||
(e1.top > e2.top && e1.bottom < e2.bottom)
) {
crossings++;
}
}
}
return crossings;
}
@@ -0,0 +1,141 @@
/**
* Greedy cycle removal via feedback arc set.
*
* Produces an acyclic edge set by reversing the minimum number of edges
* needed to break all cycles. Uses the Berger-Shor greedy heuristic:
* repeatedly extract sinks, sources, then the node with max (out - in).
*/
import type { LayoutNode, LayoutEdge } from "../../types";
export interface CycleBreakResult {
readonly edges: ReadonlyArray<LayoutEdge>;
readonly reversedEdges: ReadonlySet<string>;
}
/**
* Break cycles in the edge set by reversing backward edges.
* Returns a new edge array (with some edges flipped) + the set of
* original "source->target" keys that were reversed.
*/
export function breakCycles(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
): CycleBreakResult {
if (nodes.length === 0 || edges.length === 0) {
return { edges, reversedEdges: new Set<string>() };
}
const nodeIds = new Set(nodes.map((n) => n.id));
// Filter edges to only those connecting known nodes
const validEdges = edges.filter(
(e) => nodeIds.has(e.source) && nodeIds.has(e.target),
);
// Build mutable adjacency structures
const outNeighbors = new Map<string, Set<string>>();
const inNeighbors = new Map<string, Set<string>>();
for (const id of nodeIds) {
outNeighbors.set(id, new Set<string>());
inNeighbors.set(id, new Set<string>());
}
for (const e of validEdges) {
outNeighbors.get(e.source)!.add(e.target);
inNeighbors.get(e.target)!.add(e.source);
}
// Greedy ordering: sinks to the right, sources to the left
const remaining = new Set(nodeIds);
const leftOrder: string[] = [];
const rightOrder: string[] = [];
while (remaining.size > 0) {
// Remove all sinks (out-degree 0 in remaining subgraph)
let changed = true;
while (changed) {
changed = false;
for (const id of remaining) {
const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining);
if (outDeg === 0) {
rightOrder.push(id);
remaining.delete(id);
changed = true;
}
}
}
// Remove all sources (in-degree 0 in remaining subgraph)
changed = true;
while (changed) {
changed = false;
for (const id of remaining) {
const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining);
if (inDeg === 0) {
leftOrder.push(id);
remaining.delete(id);
changed = true;
}
}
}
// If stuck, pick node with max (out - in) in remaining subgraph
if (remaining.size > 0) {
let best: string | undefined;
let bestDelta = -Infinity;
for (const id of remaining) {
const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining);
const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining);
const delta = outDeg - inDeg;
if (delta > bestDelta || (delta === bestDelta && (best === undefined || id < best))) {
bestDelta = delta;
best = id;
}
}
if (best !== undefined) {
leftOrder.push(best);
remaining.delete(best);
}
}
}
// Final ordering: left order + reversed right order
rightOrder.reverse();
const finalOrder = [...leftOrder, ...rightOrder];
// Build position map
const positionOf = new Map<string, number>();
for (let i = 0; i < finalOrder.length; i++) {
positionOf.set(finalOrder[i], i);
}
// An edge is "backward" if source appears AFTER target in the order
const reversedEdges = new Set<string>();
const resultEdges: LayoutEdge[] = [];
for (const e of validEdges) {
const srcPos = positionOf.get(e.source)!;
const tgtPos = positionOf.get(e.target)!;
if (srcPos > tgtPos) {
// Backward edge — reverse it
reversedEdges.add(`${e.source}->${e.target}`);
resultEdges.push({ source: e.target, target: e.source });
} else {
resultEdges.push(e);
}
}
return { edges: resultEdges, reversedEdges };
}
/** Count how many members of `neighbors` are in `subgraph`. */
function countInSubgraph(neighbors: Set<string>, subgraph: Set<string>): number {
let count = 0;
for (const n of neighbors) {
if (subgraph.has(n)) count++;
}
return count;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Sugiyama sub-module barrel export.
*/
export { breakCycles } from "./cycle-break";
export type { CycleBreakResult } from "./cycle-break";
export { assignLayers } from "./layer-assign";
export { minimizeCrossings } from "./crossing-minimize";
export { assignCoordinates } from "./coordinate-assign";
@@ -0,0 +1,107 @@
/**
* Longest-path layer assignment for DAGs.
*
* Each node is assigned to the layer equal to the length of the longest
* path from any source to that node. Sources (in-degree 0) get layer 0.
*/
import type { LayoutNode, LayoutEdge } from "../../types";
/**
* Assign layer indices to each node using longest-path from sources.
* Assumes the input is acyclic (run breakCycles first).
* Returns a map of node ID -> layer index (0-based).
*/
export function assignLayers(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
): ReadonlyMap<string, number> {
const layerMap = new Map<string, number>();
if (nodes.length === 0) return layerMap;
const nodeIds = new Set(nodes.map((n) => n.id));
// Build adjacency: predecessors and successors
const predecessors = new Map<string, string[]>();
const successors = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const id of nodeIds) {
predecessors.set(id, []);
successors.set(id, []);
inDegree.set(id, 0);
}
for (const e of edges) {
if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) continue;
successors.get(e.source)!.push(e.target);
predecessors.get(e.target)!.push(e.source);
inDegree.set(e.target, inDegree.get(e.target)! + 1);
}
// Kahn's algorithm for topological ordering, computing longest path
const queue: string[] = [];
// Initialize sources at layer 0
for (const id of nodeIds) {
if (inDegree.get(id)! === 0) {
layerMap.set(id, 0);
queue.push(id);
}
}
let head = 0;
while (head < queue.length) {
const current = queue[head++];
const currentLayer = layerMap.get(current)!;
for (const succ of successors.get(current)!) {
// Layer of successor = max of all predecessor layers + 1
const newLayer = currentLayer + 1;
const existingLayer = layerMap.get(succ);
if (existingLayer === undefined || newLayer > existingLayer) {
layerMap.set(succ, newLayer);
}
// Decrement in-degree; add to queue when all predecessors processed
const remaining = inDegree.get(succ)! - 1;
inDegree.set(succ, remaining);
if (remaining === 0) {
queue.push(succ);
}
}
}
// Handle any nodes not reached (isolated nodes with edges filtered out)
for (const id of nodeIds) {
if (!layerMap.has(id)) {
layerMap.set(id, 0);
}
}
// Enforce explicit layer constraints after longest-path assignment.
for (const node of nodes) {
if (node.layerConstraint === "first") {
layerMap.set(node.id, 0);
}
}
let maxUnconstrainedLayer = 0;
for (const node of nodes) {
if (node.layerConstraint === "last") continue;
const layer = layerMap.get(node.id) ?? 0;
if (layer > maxUnconstrainedLayer) {
maxUnconstrainedLayer = layer;
}
}
for (const node of nodes) {
if (node.layerConstraint === "last") {
const existingLayer = layerMap.get(node.id) ?? 0;
layerMap.set(node.id, Math.max(existingLayer, maxUnconstrainedLayer + 1));
}
}
return layerMap;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Vertical layout algorithm — places nodes in a single top-to-bottom column.
*
* Ignores edges entirely (token layouts ignore DAG structure).
* Nodes are placed in input order; horizontally centered by midpoint.
*/
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
const DEFAULT_GAP = 40;
export function createVerticalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm {
return {
name: "vertical",
layout(input: LayoutInput): LayoutOutput {
const positions = new Map<string, Position>();
const { nodes } = input;
if (nodes.length === 0) {
return { positions };
}
// Find max width for horizontal centering
let maxWidth = 0;
for (const node of nodes) {
if (node.width > maxWidth) maxWidth = node.width;
}
// Place nodes top-to-bottom, centered horizontally
let currentY = 0;
for (const node of nodes) {
const centeredX = maxWidth / 2 - node.width / 2;
positions.set(node.id, { x: centeredX, y: currentY });
currentY += node.height + gap;
}
return { positions };
},
};
}
-225
View File
@@ -1,225 +0,0 @@
import type { PackedRow, LayoutConfig } from "./types";
/** Node size info for packing */
interface NodeSize {
id: number;
width: number;
height: number;
}
/**
* Pack nodes into rows using First-Fit Decreasing Height (FFDH) algorithm
*
* Strategy:
* 1. Sort nodes by height descending (tall nodes first)
* 2. For each node, try to fit into existing row
* 3. If no row fits, create new row
* 4. Rows are sorted by height for visual consistency
*
* @param nodeIds - Node IDs to pack
* @param sizes - Map of node ID to size
* @param config - Layout configuration
* @returns Array of packed rows
*/
export function packNodesIntoRows(
nodeIds: number[],
sizes: Map<number, { width: number; height: number }>,
config: LayoutConfig
): PackedRow[] {
if (nodeIds.length === 0) return [];
// maxColumns: 0=auto, 1=disabled (vertical stack), 2+=fixed columns
if (config.maxColumns === 1) {
// Vertical stacking - one node per row
return packVertically(nodeIds, sizes, config.verticalGap);
}
// Build sized node list
const nodeSizes: NodeSize[] = nodeIds.map((id) => {
const size = sizes.get(id);
return {
id,
width: size?.width ?? 200,
height: size?.height ?? 100,
};
});
// Sort by height descending (FFDH: tall nodes first)
nodeSizes.sort((a, b) => b.height - a.height);
if (config.maxColumns >= 2) {
// Fixed column count
return packFixedColumns(nodeSizes, config.maxColumns, config.verticalGap);
}
// Auto mode: use maxRowWidth
return packByWidth(nodeSizes, config.maxRowWidth, config.horizontalGap, config.verticalGap);
}
/**
* Pack nodes vertically (one per row) - legacy behavior
*/
function packVertically(
nodeIds: number[],
sizes: Map<number, { width: number; height: number }>,
verticalGap: number
): PackedRow[] {
const rows: PackedRow[] = [];
let yOffset = 0;
for (const id of nodeIds) {
const size = sizes.get(id);
const height = size?.height ?? 100;
const width = size?.width ?? 200;
rows.push({
nodes: [id],
height,
yOffset,
width,
});
yOffset += height + verticalGap;
}
return rows;
}
/**
* Pack nodes into fixed number of columns
* Distributes nodes round-robin by height to balance columns
*/
function packFixedColumns(
nodeSizes: NodeSize[],
columnCount: number,
verticalGap: number
): PackedRow[] {
// Group nodes by similar height into rows
const rows: PackedRow[] = [];
let currentRow: NodeSize[] = [];
for (const node of nodeSizes) {
currentRow.push(node);
if (currentRow.length >= columnCount) {
// Row is full
const rowHeight = Math.max(...currentRow.map((n) => n.height));
const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
rows.push({
nodes: currentRow.map((n) => n.id),
height: rowHeight,
yOffset: 0, // Set later
width: rowWidth,
});
currentRow = [];
}
}
// Handle remaining nodes in last partial row
if (currentRow.length > 0) {
const rowHeight = Math.max(...currentRow.map((n) => n.height));
const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
rows.push({
nodes: currentRow.map((n) => n.id),
height: rowHeight,
yOffset: 0,
width: rowWidth,
});
}
// Calculate y offsets
let yOffset = 0;
for (const row of rows) {
row.yOffset = yOffset;
yOffset += row.height + verticalGap;
}
return rows;
}
/**
* Pack nodes by maximum row width (auto column count)
* Uses FFDH: First-Fit Decreasing Height
*/
function packByWidth(
nodeSizes: NodeSize[],
maxRowWidth: number,
horizontalGap: number,
verticalGap: number
): PackedRow[] {
const rows: Array<{
nodes: NodeSize[];
height: number;
currentWidth: number;
}> = [];
for (const node of nodeSizes) {
// Try to fit into existing row
let placed = false;
for (const row of rows) {
const newWidth = row.currentWidth + horizontalGap + node.width;
// Check if node fits and heights are compatible
// Allow 50% height variance to keep rows visually balanced
const heightRatio = row.height > 0 ? node.height / row.height : 1;
const heightCompatible = heightRatio >= 0.5 && heightRatio <= 2.0;
if (newWidth <= maxRowWidth && heightCompatible) {
row.nodes.push(node);
row.currentWidth = newWidth;
row.height = Math.max(row.height, node.height);
placed = true;
break;
}
}
if (!placed) {
// Create new row
rows.push({
nodes: [node],
height: node.height,
currentWidth: node.width,
});
}
}
// Sort rows by their tallest node height (tallest first for visual hierarchy)
rows.sort((a, b) => b.height - a.height);
// Convert to PackedRow format with y offsets
const result: PackedRow[] = [];
let yOffset = 0;
for (const row of rows) {
result.push({
nodes: row.nodes.map((n) => n.id),
height: row.height,
yOffset,
width: row.currentWidth,
});
yOffset += row.height + verticalGap;
}
return result;
}
/**
* Calculate total height of packed rows
*/
export function getPackedHeight(rows: PackedRow[]): number {
if (rows.length === 0) return 0;
const lastRow = rows[rows.length - 1];
return lastRow.yOffset + lastRow.height;
}
/**
* Calculate max width across all packed rows
*/
export function getPackedWidth(rows: PackedRow[]): number {
return Math.max(0, ...rows.map((r) => r.width));
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Compaction utilities — post-processing to remove gaps.
* All functions are pure: they return new maps, never mutate inputs.
*/
import type { Position, LayoutNode } from "./types";
/**
* Remove vertical gaps between nodes by shifting them up.
* Nodes are processed in Y order; each is shifted to the earliest available Y.
*/
export function compactVertically(
positions: ReadonlyMap<string, Position>,
nodes: ReadonlyArray<LayoutNode>,
gap: number,
): ReadonlyMap<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
// Build lookup for node dimensions
const nodeMap = new Map<string, LayoutNode>();
for (const n of nodes) {
nodeMap.set(n.id, n);
}
// Sort node IDs by their current Y position, breaking ties by X
const sortedIds = [...positions.keys()]
.filter((id) => nodeMap.has(id))
.sort((a, b) => {
const pa = positions.get(a)!;
const pb = positions.get(b)!;
if (pa.y !== pb.y) return pa.y - pb.y;
return pa.x - pb.x;
});
const result = new Map<string, Position>(positions);
let nextY = sortedIds.length > 0 ? positions.get(sortedIds[0])!.y : 0;
for (const id of sortedIds) {
const pos = positions.get(id)!;
const node = nodeMap.get(id)!;
const newY = Math.min(pos.y, nextY);
result.set(id, { x: pos.x, y: newY });
nextY = newY + node.height + gap;
}
return result;
}
/**
* Remove horizontal gaps between nodes by shifting them left.
* Nodes are processed in X order; each is shifted to the earliest available X.
*/
export function compactHorizontally(
positions: ReadonlyMap<string, Position>,
nodes: ReadonlyArray<LayoutNode>,
gap: number,
): ReadonlyMap<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
// Build lookup for node dimensions
const nodeMap = new Map<string, LayoutNode>();
for (const n of nodes) {
nodeMap.set(n.id, n);
}
// Sort node IDs by their current X position, breaking ties by Y
const sortedIds = [...positions.keys()]
.filter((id) => nodeMap.has(id))
.sort((a, b) => {
const pa = positions.get(a)!;
const pb = positions.get(b)!;
if (pa.x !== pb.x) return pa.x - pb.x;
return pa.y - pb.y;
});
const result = new Map<string, Position>(positions);
let nextX = sortedIds.length > 0 ? positions.get(sortedIds[0])!.x : 0;
for (const id of sortedIds) {
const pos = positions.get(id)!;
const node = nodeMap.get(id)!;
const newX = Math.min(pos.x, nextX);
result.set(id, { x: newX, y: pos.y });
nextX = newX + node.width + gap;
}
return result;
}
+753
View File
@@ -0,0 +1,753 @@
/**
* Core layout framework — recursive group-aware layout orchestration.
*
* This is the architectural foundation of v2. It handles:
* - Bottom-up group hierarchy processing
* - Delegating to a pluggable LayoutAlgorithm
* - Disconnected node placement (left of DAG at each level)
* - Translating nested positions to absolute coordinates
*/
import type {
LayoutNode,
LayoutEdge,
LayoutGroup,
LayoutAlgorithm,
FrameworkConfig,
FrameworkResult,
Position,
GroupBounds,
} from "./types";
import { DEFAULT_FRAMEWORK_CONFIG } from "./types";
import { tokenToAlgorithm } from "./tokens";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Layout nodes, edges, and groups using the provided algorithm.
*
* Groups are processed bottom-up: leaf groups first, then their parents.
* Each group's contents are laid out independently, producing a "virtual node"
* that participates in the parent scope's layout.
*/
export function layoutWithGroups(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
groups: ReadonlyArray<LayoutGroup>,
algorithm: LayoutAlgorithm,
config?: Partial<FrameworkConfig>,
): FrameworkResult {
const cfg: FrameworkConfig = { ...DEFAULT_FRAMEWORK_CONFIG, ...config };
// Fast path: no nodes at all
if (nodes.length === 0) {
return {
positions: new Map<string, Position>(),
groupBounds: new Map<string, GroupBounds>(),
};
}
// Build lookup tables
const nodeById = new Map<string, LayoutNode>();
for (const n of nodes) {
nodeById.set(n.id, n);
}
const groupById = new Map<string, LayoutGroup>();
for (const g of groups) {
groupById.set(g.id, g);
}
// Build group hierarchy
const { processingOrder, parentMap } = buildGroupHierarchy(groups);
// Track virtual nodes (groups that have been processed) and their bounds
const virtualNodes = new Map<string, LayoutNode>();
const groupBounds = new Map<string, GroupBounds>();
// Store relative positions for each group's members (before absolute translation)
// Key: group ID, Value: map of member ID -> position relative to group origin
const groupMemberRelativePositions = new Map<
string,
ReadonlyMap<string, Position>
>();
// Process groups bottom-up
for (const group of processingOrder) {
const { positions: memberPositions, bounds } = layoutGroupContents(
group,
groups,
nodeById,
edges,
virtualNodes,
algorithm,
cfg,
);
groupMemberRelativePositions.set(group.id, memberPositions);
groupBounds.set(group.id, bounds);
// Register as virtual node for parent's layout
virtualNodes.set(group.id, {
id: group.id,
width: bounds.width,
height: bounds.height,
});
}
// Layout root level: ungrouped nodes + top-level group virtual nodes
const groupedNodeIds = collectAllGroupedNodeIds(groups);
const topLevelGroupIds = new Set<string>();
for (const g of groups) {
if (!parentMap.has(g.id)) {
topLevelGroupIds.add(g.id);
}
}
const rootNodes: LayoutNode[] = [];
for (const n of nodes) {
if (!groupedNodeIds.has(n.id)) {
rootNodes.push(n);
}
}
for (const gId of topLevelGroupIds) {
const vn = virtualNodes.get(gId);
if (vn) rootNodes.push(vn);
}
// Root edges: between root-level items (ungrouped nodes + top-level groups)
const rootItemIds = new Set(rootNodes.map((n) => n.id));
const rootEdges = filterEdgesForScope(edges, rootItemIds, groups);
const rootPositions = layoutScope(rootNodes, rootEdges, algorithm, cfg);
// Translate all positions to absolute coordinates
const absolutePositions = new Map<string, Position>();
// Place ungrouped root nodes
for (const n of nodes) {
if (!groupedNodeIds.has(n.id)) {
const pos = rootPositions.get(n.id);
if (pos) {
absolutePositions.set(n.id, pos);
}
}
}
// Recursively translate group members to absolute coordinates
for (const gId of topLevelGroupIds) {
const groupPos = rootPositions.get(gId);
if (groupPos) {
translateGroupToAbsolute(
gId,
groupPos,
groupById,
groupMemberRelativePositions,
groupBounds,
absolutePositions,
cfg,
);
}
}
// Update group bounds with absolute positions
const absoluteGroupBounds = new Map<string, GroupBounds>();
for (const gId of topLevelGroupIds) {
const groupPos = rootPositions.get(gId);
if (groupPos) {
updateGroupBoundsAbsolute(
gId,
groupPos,
groupById,
groupMemberRelativePositions,
groupBounds,
absoluteGroupBounds,
cfg,
);
}
}
return {
positions: absolutePositions,
groupBounds: absoluteGroupBounds,
};
}
// ---------------------------------------------------------------------------
// Group hierarchy
// ---------------------------------------------------------------------------
interface GroupHierarchy {
/** Groups in bottom-up processing order (leaves first) */
readonly processingOrder: ReadonlyArray<LayoutGroup>;
/** Maps child group ID -> parent group ID */
readonly parentMap: ReadonlyMap<string, string>;
}
/**
* Build the group hierarchy: determine parent-child relationships and
* return a bottom-up topological processing order.
*/
export function buildGroupHierarchy(
groups: ReadonlyArray<LayoutGroup>,
): GroupHierarchy {
const groupById = new Map<string, LayoutGroup>();
for (const g of groups) {
groupById.set(g.id, g);
}
// Build parent map
const parentMap = new Map<string, string>();
for (const g of groups) {
for (const childId of g.childGroupIds) {
parentMap.set(childId, g.id);
}
}
// Topological sort: BFS from leaves (groups with no children, or whose
// children are all already processed)
const childCountRemaining = new Map<string, number>();
for (const g of groups) {
// Only count children that actually exist in our group list
const validChildren = g.childGroupIds.filter((cid) => groupById.has(cid));
childCountRemaining.set(g.id, validChildren.length);
}
const queue: string[] = [];
for (const [id, count] of childCountRemaining) {
if (count === 0) queue.push(id);
}
const processingOrder: LayoutGroup[] = [];
let head = 0;
while (head < queue.length) {
const id = queue[head++];
const group = groupById.get(id);
if (!group) continue;
processingOrder.push(group);
// Notify parent
const parentId = parentMap.get(id);
if (parentId !== undefined) {
const remaining = (childCountRemaining.get(parentId) ?? 1) - 1;
childCountRemaining.set(parentId, remaining);
if (remaining === 0) {
queue.push(parentId);
}
}
}
return { processingOrder, parentMap };
}
// ---------------------------------------------------------------------------
// Disconnected splitting
// ---------------------------------------------------------------------------
interface SplitResult {
readonly connected: ReadonlyArray<LayoutNode>;
readonly disconnected: ReadonlyArray<LayoutNode>;
readonly connectedEdges: ReadonlyArray<LayoutEdge>;
}
/**
* Split nodes into connected (have edges) and disconnected (no edges).
* Preserves original array order for determinism.
*/
export function splitDisconnected(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
): SplitResult {
const nodeIds = new Set(nodes.map((n) => n.id));
const hasEdge = new Set<string>();
const connectedEdges: LayoutEdge[] = [];
for (const e of edges) {
if (nodeIds.has(e.source) && nodeIds.has(e.target)) {
hasEdge.add(e.source);
hasEdge.add(e.target);
connectedEdges.push(e);
}
}
const connected: LayoutNode[] = [];
const disconnected: LayoutNode[] = [];
for (const n of nodes) {
if (hasEdge.has(n.id) || n.layerConstraint !== undefined) {
connected.push(n);
} else {
disconnected.push(n);
}
}
return { connected, disconnected, connectedEdges };
}
// ---------------------------------------------------------------------------
// Disconnected placement
// ---------------------------------------------------------------------------
interface Bounds {
readonly minX: number;
readonly minY: number;
readonly maxX: number;
readonly maxY: number;
}
/**
* Place disconnected nodes in a vertical stack to the LEFT of the DAG bounds.
* Returns positions for the disconnected nodes only.
*/
export function placeDisconnected(
disconnected: ReadonlyArray<LayoutNode>,
dagBounds: Bounds | null,
config: FrameworkConfig,
): ReadonlyMap<string, Position> {
const result = new Map<string, Position>();
if (disconnected.length === 0) return result;
// Find widest disconnected node for right-aligning the column
let maxDisconnectedWidth = 0;
for (const n of disconnected) {
if (n.width > maxDisconnectedWidth) maxDisconnectedWidth = n.width;
}
// Place left of DAG (or at origin if no DAG)
const dagMinX = dagBounds ? dagBounds.minX : 0;
const startX = dagMinX - config.disconnectedGap - maxDisconnectedWidth;
const startY = dagBounds ? dagBounds.minY : 0;
let currentY = startY;
for (const n of disconnected) {
result.set(n.id, { x: startX, y: currentY });
currentY += n.height + config.verticalGap;
}
return result;
}
// ---------------------------------------------------------------------------
// Position translation
// ---------------------------------------------------------------------------
/**
* Offset all positions by (dx, dy). Returns a new map.
*/
export function translatePositions(
positions: ReadonlyMap<string, Position>,
dx: number,
dy: number,
): ReadonlyMap<string, Position> {
const result = new Map<string, Position>();
for (const [id, pos] of positions) {
result.set(id, { x: pos.x + dx, y: pos.y + dy });
}
return result;
}
// ---------------------------------------------------------------------------
// Scope layout (handles disconnected splitting + algorithm call)
// ---------------------------------------------------------------------------
/**
* Layout a scope (set of nodes + edges), handling disconnected separation.
* Returns positions for ALL nodes in the scope (connected + disconnected).
*/
function layoutScope(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
algorithm: LayoutAlgorithm,
config: FrameworkConfig,
): ReadonlyMap<string, Position> {
if (nodes.length === 0) return new Map<string, Position>();
const { connected, disconnected, connectedEdges } = splitDisconnected(
nodes,
edges,
);
// Layout connected nodes with the algorithm
let connectedPositions: ReadonlyMap<string, Position>;
if (connected.length > 0) {
const result = algorithm.layout({
nodes: connected,
edges: connectedEdges,
});
connectedPositions = result.positions;
} else {
connectedPositions = new Map<string, Position>();
}
// Compute DAG bounds from connected positions
const dagBounds = computeBounds(connectedPositions, connected);
// Place disconnected nodes
const disconnectedPositions = placeDisconnected(
disconnected,
dagBounds,
config,
);
// Merge
const merged = new Map<string, Position>();
for (const [id, pos] of connectedPositions) {
merged.set(id, pos);
}
for (const [id, pos] of disconnectedPositions) {
merged.set(id, pos);
}
return merged;
}
// ---------------------------------------------------------------------------
// Group contents layout
// ---------------------------------------------------------------------------
interface GroupLayoutResult {
/** Member positions relative to group's (0, 0) — i.e. with padding offset */
readonly positions: ReadonlyMap<string, Position>;
/** Group bounding box (relative: x=0, y=0) */
readonly bounds: GroupBounds;
}
/**
* Layout the contents of a single group.
* Child groups that have already been processed appear as virtual nodes.
*/
function layoutGroupContents(
group: LayoutGroup,
allGroups: ReadonlyArray<LayoutGroup>,
nodeById: ReadonlyMap<string, LayoutNode>,
allEdges: ReadonlyArray<LayoutEdge>,
virtualNodes: ReadonlyMap<string, LayoutNode>,
algorithm: LayoutAlgorithm,
config: FrameworkConfig,
): GroupLayoutResult {
// Collect members: direct member nodes + child group virtual nodes
const members: LayoutNode[] = [];
const memberIdSet = new Set<string>();
for (const mId of group.memberIds) {
const node = nodeById.get(mId);
if (node) {
members.push(node);
memberIdSet.add(mId);
}
}
for (const cgId of group.childGroupIds) {
const vn = virtualNodes.get(cgId);
if (vn) {
members.push(vn);
memberIdSet.add(cgId);
}
}
// Filter edges to internal-only
const internalEdges = filterEdgesForScope(allEdges, memberIdSet, allGroups);
const groupAlgorithm = group.token
? tokenToAlgorithm(group.token, config.verticalGap)
: algorithm;
const rawPositions = group.token
? groupAlgorithm.layout({ nodes: members, edges: internalEdges }).positions
: layoutScope(members, internalEdges, groupAlgorithm, config);
// Normalize: shift so min position is at (padding, titleHeight + padding)
// Groups have a title bar at the top, so content starts below it
const topPadding = config.groupTitleHeight + config.groupPadding;
const contentBounds = computeBounds(rawPositions, members);
let shiftX: number;
let shiftY: number;
if (contentBounds) {
shiftX = config.groupPadding - contentBounds.minX;
shiftY = topPadding - contentBounds.minY;
} else {
shiftX = config.groupPadding;
shiftY = topPadding;
}
const normalizedPositions = translatePositions(rawPositions, shiftX, shiftY);
// Compute group bounds
const normalizedBounds = computeBounds(normalizedPositions, members);
let width: number;
let height: number;
if (normalizedBounds) {
width = normalizedBounds.maxX + config.groupPadding;
height = normalizedBounds.maxY + config.groupPadding;
} else {
width = config.groupPadding * 2;
height = topPadding + config.groupPadding;
}
return {
positions: normalizedPositions,
bounds: { x: 0, y: 0, width, height },
};
}
// ---------------------------------------------------------------------------
// Absolute coordinate translation (recursive)
// ---------------------------------------------------------------------------
/**
* Recursively translate a group's members to absolute coordinates.
* `groupAbsolutePos` is where the group's top-left corner is placed.
*/
function translateGroupToAbsolute(
groupId: string,
groupAbsolutePos: Position,
groupById: ReadonlyMap<string, LayoutGroup>,
groupMemberRelativePositions: ReadonlyMap<
string,
ReadonlyMap<string, Position>
>,
groupBoundsMap: ReadonlyMap<string, GroupBounds>,
absolutePositions: Map<string, Position>,
config: FrameworkConfig,
): void {
const relPositions = groupMemberRelativePositions.get(groupId);
if (!relPositions) return;
const group = groupById.get(groupId);
if (!group) return;
// Translate direct member nodes
for (const mId of group.memberIds) {
const relPos = relPositions.get(mId);
if (relPos) {
absolutePositions.set(mId, {
x: groupAbsolutePos.x + relPos.x,
y: groupAbsolutePos.y + relPos.y,
});
}
}
// Translate child groups recursively
for (const cgId of group.childGroupIds) {
const relPos = relPositions.get(cgId);
if (relPos) {
const childAbsPos: Position = {
x: groupAbsolutePos.x + relPos.x,
y: groupAbsolutePos.y + relPos.y,
};
translateGroupToAbsolute(
cgId,
childAbsPos,
groupById,
groupMemberRelativePositions,
groupBoundsMap,
absolutePositions,
config,
);
}
}
}
/**
* Recursively compute absolute group bounds.
*/
function updateGroupBoundsAbsolute(
groupId: string,
groupAbsolutePos: Position,
groupById: ReadonlyMap<string, LayoutGroup>,
groupMemberRelativePositions: ReadonlyMap<
string,
ReadonlyMap<string, Position>
>,
relativeBoundsMap: ReadonlyMap<string, GroupBounds>,
absoluteBoundsMap: Map<string, GroupBounds>,
config: FrameworkConfig,
): void {
const relBounds = relativeBoundsMap.get(groupId);
if (!relBounds) return;
absoluteBoundsMap.set(groupId, {
x: groupAbsolutePos.x,
y: groupAbsolutePos.y,
width: relBounds.width,
height: relBounds.height,
});
const group = groupById.get(groupId);
if (!group) return;
const relPositions = groupMemberRelativePositions.get(groupId);
if (!relPositions) return;
for (const cgId of group.childGroupIds) {
const relPos = relPositions.get(cgId);
if (relPos) {
const childAbsPos: Position = {
x: groupAbsolutePos.x + relPos.x,
y: groupAbsolutePos.y + relPos.y,
};
updateGroupBoundsAbsolute(
cgId,
childAbsPos,
groupById,
groupMemberRelativePositions,
relativeBoundsMap,
absoluteBoundsMap,
config,
);
}
}
}
// ---------------------------------------------------------------------------
// Edge filtering
// ---------------------------------------------------------------------------
/**
* Filter edges to only those internal to a scope.
* An edge is internal if both source and target are in scopeIds.
* For edges that connect to nodes inside child groups, we remap them
* to the child group ID (since child groups are virtual nodes in this scope).
*/
function filterEdgesForScope(
allEdges: ReadonlyArray<LayoutEdge>,
scopeIds: ReadonlySet<string>,
allGroups: ReadonlyArray<LayoutGroup>,
): ReadonlyArray<LayoutEdge> {
const groupById = new Map<string, LayoutGroup>();
for (const g of allGroups) {
groupById.set(g.id, g);
}
// Build reverse map: node ID -> which group ID contains it (for child group remapping)
const nodeToGroup = new Map<string, string>();
for (const g of allGroups) {
if (scopeIds.has(g.id)) {
// This is a child group in scope — map its deep members to this group ID
mapGroupMembersDeep(g, groupById, nodeToGroup);
}
}
const result: LayoutEdge[] = [];
const seen = new Set<string>();
for (const e of allEdges) {
// Remap source/target if they're inside a child group
const source = nodeToGroup.get(e.source) ?? e.source;
const target = nodeToGroup.get(e.target) ?? e.target;
// Both must be in scope
if (!scopeIds.has(source) || !scopeIds.has(target)) continue;
// Skip self-loops (happens when source and target are in same child group)
if (source === target) continue;
// Deduplicate
const key = `${source}->${target}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ source, target });
}
return result;
}
/**
* Recursively map all member nodes of a group (and its nested children)
* to the group's ID.
*/
function mapGroupMembersDeep(
group: LayoutGroup,
groupById: ReadonlyMap<string, LayoutGroup>,
nodeToGroup: Map<string, string>,
): void {
for (const mId of group.memberIds) {
nodeToGroup.set(mId, group.id);
}
for (const cgId of group.childGroupIds) {
const childGroup = groupById.get(cgId);
if (childGroup) {
// Map child group's members to the parent group (the one in scope)
mapGroupMembersDeepToTarget(childGroup, groupById, group.id, nodeToGroup);
}
}
}
/**
* Recursively map all members of `group` to `targetGroupId`.
*/
function mapGroupMembersDeepToTarget(
group: LayoutGroup,
groupById: ReadonlyMap<string, LayoutGroup>,
targetGroupId: string,
nodeToGroup: Map<string, string>,
): void {
for (const mId of group.memberIds) {
nodeToGroup.set(mId, targetGroupId);
}
for (const cgId of group.childGroupIds) {
const childGroup = groupById.get(cgId);
if (childGroup) {
mapGroupMembersDeepToTarget(
childGroup,
groupById,
targetGroupId,
nodeToGroup,
);
}
}
}
// ---------------------------------------------------------------------------
// Utility: collect all node IDs that belong to any group (recursively)
// ---------------------------------------------------------------------------
function collectAllGroupedNodeIds(
groups: ReadonlyArray<LayoutGroup>,
): Set<string> {
const result = new Set<string>();
for (const g of groups) {
for (const mId of g.memberIds) {
result.add(mId);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Utility: compute bounding box
// ---------------------------------------------------------------------------
function computeBounds(
positions: ReadonlyMap<string, Position>,
nodes: ReadonlyArray<LayoutNode>,
): Bounds | null {
const nodeMap = new Map<string, LayoutNode>();
for (const n of nodes) {
nodeMap.set(n.id, n);
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let found = false;
for (const [id, pos] of positions) {
const node = nodeMap.get(id);
if (!node) continue;
found = true;
if (pos.x < minX) minX = pos.x;
if (pos.y < minY) minY = pos.y;
const right = pos.x + node.width;
const bottom = pos.y + node.height;
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
if (!found) return null;
return { minX, minY, maxX, maxY };
}
-565
View File
@@ -1,565 +0,0 @@
import type {
LGraph,
LGraphNode,
LGraphGroup,
LayoutNode,
LayoutGroup,
LayoutState,
LayoutConfig,
RerouteChain,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import { classifyNodes } from "./node-classifier";
import { findRerouteChains, getRerouteNodeIds, getVirtualEdges } from "./reroute-collapse";
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
import { debugLog } from "../debug";
/**
* Runtime subgraph I/O node (has pos/size like regular nodes, not bounding)
*/
interface RuntimeIONode {
id: number;
pos: [number, number];
size: [number, number];
}
/**
* Check if an object looks like a subgraph I/O node at runtime
* Runtime has pos/size (not bounding like JSON export)
*/
function isValidIONode(obj: unknown): obj is RuntimeIONode {
if (!obj || typeof obj !== "object") return false;
const candidate = obj as Record<string, unknown>;
// Must have numeric id
if (typeof candidate.id !== "number") return false;
// Use duck typing - pos/size may be getters returning array-like objects
const pos = candidate.pos as [number, number] | undefined;
const size = candidate.size as [number, number] | undefined;
if (!pos || typeof pos[0] !== "number" || typeof pos[1] !== "number") return false;
if (!size || typeof size[0] !== "number" || typeof size[1] !== "number") return false;
return true;
}
/**
* Convert runtime I/O node to LGraphNode-like object for layout
*/
function ioNodeToLGraphNode(ioNode: RuntimeIONode, type: "input" | "output"): LGraphNode {
return {
id: ioNode.id,
type: `subgraph_${type}`,
title: type === "input" ? "Input" : "Output",
pos: [ioNode.pos[0], ioNode.pos[1]],
size: [ioNode.size[0], ioNode.size[1]],
inputs: [],
outputs: [],
};
}
/** Title bar height for groups */
const GROUP_TITLE_HEIGHT = 50;
/**
* Check if outer group's bounds fully contain inner group's bounds
*/
function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
const ox = outer.pos[0];
const oy = outer.pos[1];
const ow = outer.size[0];
const oh = outer.size[1];
const ix = inner.pos[0];
const iy = inner.pos[1];
const iw = inner.size[0];
const ih = inner.size[1];
// Inner's bounds must be fully within outer's bounds
return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
}
/**
* Build parent-child hierarchy for groups based on bounds containment
* Assigns parentGroup, childGroups, and depth to each LayoutGroup
*/
function buildGroupHierarchy(layoutGroups: LayoutGroup[]): void {
// For each group, find its parent (smallest containing group)
for (const inner of layoutGroups) {
let bestParent: LayoutGroup | null = null;
let bestArea = Infinity;
for (const outer of layoutGroups) {
if (outer === inner) continue;
if (groupContainsGroup(outer.group, inner.group)) {
const area = outer.group.size[0] * outer.group.size[1];
if (area < bestArea) {
bestArea = area;
bestParent = outer;
}
}
}
inner.parentGroup = bestParent;
if (bestParent) {
bestParent.childGroups.push(inner);
}
}
// Calculate depths using BFS from roots
const roots = layoutGroups.filter((g) => !g.parentGroup);
const queue: Array<{ group: LayoutGroup; depth: number }> = roots.map(
(g) => ({ group: g, depth: 0 })
);
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
const { group, depth } = item;
group.depth = depth;
for (const child of group.childGroups) {
queue.push({ group: child, depth: depth + 1 });
}
}
}
/**
* Check if a node's origin is inside a group's content area
*/
function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
const gx = group.pos[0];
const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
const gw = group.size[0];
const gh = group.size[1] - GROUP_TITLE_HEIGHT;
const nx = node.pos?.[0] ?? 0;
const ny = node.pos?.[1] ?? 0;
return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
}
/**
* Build layout graph from ComfyUI graph
* Groups are treated as "mega-nodes" with full hierarchy support
*/
export function buildLayoutGraph(
graph: LGraph,
config: Partial<LayoutConfig> = {}
): LayoutState {
const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
const nodes = new Map<number, LayoutNode>();
const groups: LayoutGroup[] = [];
// First pass: Extract all nodes
const graphNodes = graph._nodes ?? [];
const allNodes = new Map<number, LGraphNode>();
for (const node of graphNodes) {
if (!node || node.id === undefined) continue;
allNodes.set(node.id, node);
}
// Track subgraph I/O nodes separately (they need special position handling)
// Runtime I/O nodes have pos/size like regular nodes
const ioNodes = new Map<number, RuntimeIONode>();
// Check for subgraph I/O nodes (Subgraph extends LGraph)
const graphAny = graph as unknown as Record<string, unknown>;
if (isValidIONode(graphAny.inputNode)) {
const inputNode = graphAny.inputNode as RuntimeIONode;
const inputLGraphNode = ioNodeToLGraphNode(inputNode, "input");
allNodes.set(inputNode.id, inputLGraphNode);
ioNodes.set(inputNode.id, inputNode);
debugLog(`Found subgraph input node id=${inputNode.id} at [${inputNode.pos}]`);
}
if (isValidIONode(graphAny.outputNode)) {
const outputNode = graphAny.outputNode as RuntimeIONode;
const outputLGraphNode = ioNodeToLGraphNode(outputNode, "output");
allNodes.set(outputNode.id, outputLGraphNode);
ioNodes.set(outputNode.id, outputNode);
debugLog(`Found subgraph output node id=${outputNode.id} at [${outputNode.pos}]`);
}
// Classify nodes into connected/disconnected/reroutes
const classified = classifyNodes(graph);
const disconnectedNodes = classified.disconnected;
// Find and collapse reroute chains if enabled
let rerouteChains: RerouteChain[] = [];
let collapsedRerouteIds = new Set<number>();
if (fullConfig.collapseReroutes && classified.reroutes.size > 0) {
rerouteChains = findRerouteChains(graph, classified.reroutes);
collapsedRerouteIds = getRerouteNodeIds(rerouteChains);
}
// Second pass: Create LayoutGroup objects (without hierarchy yet)
const graphGroups = graph._groups ?? [];
for (const group of graphGroups) {
if (!group || !group.pos || !group.size) continue;
const layoutGroup: LayoutGroup = {
group,
memberIds: new Set<number>(),
childGroups: [],
parentGroup: null,
depth: 0,
width: 0,
height: 0,
};
groups.push(layoutGroup);
}
// Third pass: Build group hierarchy (parent-child relationships)
buildGroupHierarchy(groups);
// Fourth pass: Assign nodes to their INNERMOST containing group
// A node belongs only to the deepest group that contains it
// Membership is determined from original workflow positions (user's design intent)
const nodeToGroup = new Map<number, LayoutGroup>();
for (const [nodeId, node] of allNodes) {
let bestGroup: LayoutGroup | null = null;
let maxDepth = -1;
for (const layoutGroup of groups) {
if (nodeInsideGroup(node, layoutGroup.group)) {
if (layoutGroup.depth > maxDepth) {
maxDepth = layoutGroup.depth;
bestGroup = layoutGroup;
}
}
}
if (bestGroup) {
bestGroup.memberIds.add(nodeId);
nodeToGroup.set(nodeId, bestGroup);
}
}
// Log group membership
for (const layoutGroup of groups) {
const parentInfo = layoutGroup.parentGroup
? ` (inside "${layoutGroup.parentGroup.group.title}")`
: "";
debugLog(
`Group "${layoutGroup.group.title}": ${layoutGroup.memberIds.size} direct members, ${layoutGroup.childGroups.length} child groups${parentInfo}`
);
}
// Fifth pass: Calculate group sizes bottom-up (deepest first)
// Build groupsByDepth array for bottom-up processing
const maxDepth = Math.max(0, ...groups.map((g) => g.depth));
const groupsByDepth: LayoutGroup[][] = [];
for (let d = 0; d <= maxDepth; d++) {
groupsByDepth.push(groups.filter((g) => g.depth === d));
}
// Process from deepest to shallowest
for (let depth = maxDepth; depth >= 0; depth--) {
for (const layoutGroup of groupsByDepth[depth]) {
// Calculate dimensions based on internal structure
const { width: memberWidth, height: memberHeight } = calculateGroupMemberDimensions(
layoutGroup,
allNodes,
fullConfig
);
let totalWidth = memberWidth;
let totalHeight = memberHeight;
// Include child groups (already calculated in previous iterations)
// Child groups add to height below members
let childGroupsHeight = 0;
for (const child of layoutGroup.childGroups) {
totalWidth = Math.max(totalWidth, child.width);
childGroupsHeight += child.height + fullConfig.verticalGap;
}
if (memberHeight > 0 && childGroupsHeight > 0) {
totalHeight += fullConfig.verticalGap; // Gap between members and children
}
totalHeight += childGroupsHeight;
// Add padding and title
layoutGroup.width = totalWidth + fullConfig.groupPadding * 2;
layoutGroup.height =
totalHeight + fullConfig.groupPadding * 2 + GROUP_TITLE_HEIGHT;
}
}
// Sixth pass: Create LayoutNodes
// - Standalone connected nodes become regular layout nodes
// - Top-level groups get a representative node
// - Nested groups are handled internally by their parent
// - Disconnected nodes and collapsed reroutes are EXCLUDED (placed separately later)
const groupRepresentatives = new Map<LayoutGroup, number>();
for (const [id, node] of allNodes) {
// Skip disconnected nodes - they'll be placed in a separate zone
if (disconnectedNodes.has(id)) {
continue;
}
// Skip collapsed reroute nodes - they'll be restored after layout
if (collapsedRerouteIds.has(id)) {
continue;
}
const group = nodeToGroup.get(id);
if (group) {
// This node is in a group
// Only create representative for top-level groups
const topLevelGroup = getTopLevelGroup(group);
if (!groupRepresentatives.has(topLevelGroup)) {
// First member becomes the representative for the top-level group
groupRepresentatives.set(topLevelGroup, id);
// Create layout node with TOP-LEVEL GROUP dimensions
nodes.set(id, {
node,
id,
layer: -1,
orderInLayer: 0,
predecessors: [],
successors: [],
width: topLevelGroup.width,
height: topLevelGroup.height,
x: 0,
y: 0,
isGroupRepresentative: true,
group: topLevelGroup,
});
}
// Skip non-representative group members - they'll be positioned relative to group
} else {
// Standalone node (or I/O node)
const runtimeIONode = ioNodes.get(id);
nodes.set(id, {
node,
id,
layer: -1,
orderInLayer: 0,
predecessors: [],
successors: [],
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
x: 0,
y: 0,
isSubgraphIO: runtimeIONode !== undefined,
runtimeIONode,
});
}
}
// Seventh pass: Build adjacency from links
// For group members, redirect connections to the top-level group representative
// Skip links involving collapsed reroutes (handled via virtual edges)
const links = graph.links;
if (links) {
// Handle different link storage formats:
// - Map<number, LLink> (newer ComfyUI)
// - Record<number, LLink> (object with numeric keys)
// - LLink[] (array, common in subgraphs)
let linkEntries: Iterable<[number, { origin_id: number; target_id: number } | null]>;
if (links instanceof Map) {
linkEntries = links.entries();
} else if (Array.isArray(links)) {
// Array of link objects (subgraph format)
linkEntries = links.map((link, i) => [i, link] as const);
} else {
// Object with numeric keys
linkEntries = Object.entries(links).map(([k, v]) => [Number(k), v] as const);
}
for (const [, link] of linkEntries) {
if (!link || typeof link !== "object") continue;
if (typeof link.origin_id !== "number" || typeof link.target_id !== "number") continue;
let sourceId = link.origin_id;
let targetId = link.target_id;
// Skip links involving collapsed reroutes (handled via virtual edges)
if (collapsedRerouteIds.has(sourceId) || collapsedRerouteIds.has(targetId)) {
continue;
}
// Redirect group members to their top-level group representative
const sourceGroup = nodeToGroup.get(sourceId);
const targetGroup = nodeToGroup.get(targetId);
if (sourceGroup) {
const topLevel = getTopLevelGroup(sourceGroup);
sourceId = groupRepresentatives.get(topLevel)!;
}
if (targetGroup) {
const topLevel = getTopLevelGroup(targetGroup);
targetId = groupRepresentatives.get(topLevel)!;
}
// Skip internal group connections (within same top-level group)
if (sourceGroup && targetGroup) {
const sourceTop = getTopLevelGroup(sourceGroup);
const targetTop = getTopLevelGroup(targetGroup);
if (sourceTop === targetTop) {
continue;
}
}
const sourceNode = nodes.get(sourceId);
const targetNode = nodes.get(targetId);
if (sourceNode && targetNode) {
if (!sourceNode.successors.includes(targetId)) {
sourceNode.successors.push(targetId);
}
if (!targetNode.predecessors.includes(sourceId)) {
targetNode.predecessors.push(sourceId);
}
}
}
}
// Eighth pass: Add virtual edges from collapsed reroute chains
if (rerouteChains.length > 0) {
const virtualEdges = getVirtualEdges(rerouteChains);
for (const [sourceId, targetIds] of virtualEdges) {
// Handle group redirection for source
const sourceGroup = nodeToGroup.get(sourceId);
let effectiveSourceId = sourceId;
if (sourceGroup) {
const topLevel = getTopLevelGroup(sourceGroup);
effectiveSourceId = groupRepresentatives.get(topLevel)!;
}
const sourceNode = nodes.get(effectiveSourceId);
if (!sourceNode) continue;
for (const targetId of targetIds) {
// Handle group redirection for target
const targetGroup = nodeToGroup.get(targetId);
let effectiveTargetId = targetId;
if (targetGroup) {
const topLevel = getTopLevelGroup(targetGroup);
effectiveTargetId = groupRepresentatives.get(topLevel)!;
}
const targetNode = nodes.get(effectiveTargetId);
if (!targetNode) continue;
// Skip if same group
if (sourceGroup && targetGroup) {
const sourceTop = getTopLevelGroup(sourceGroup);
const targetTop = getTopLevelGroup(targetGroup);
if (sourceTop === targetTop) continue;
}
if (!sourceNode.successors.includes(effectiveTargetId)) {
sourceNode.successors.push(effectiveTargetId);
}
if (!targetNode.predecessors.includes(effectiveSourceId)) {
targetNode.predecessors.push(effectiveSourceId);
}
}
}
}
return {
nodes,
layers: [],
groups,
config: fullConfig,
nodeToGroup,
groupRepresentatives,
allNodes,
groupsByDepth,
disconnectedNodes,
rerouteChains,
};
}
/**
* Calculate group member dimensions based on internal structure
* For groups with internal connections: uses layer-aware sizing (width = sum of layer widths, height = max layer height)
* For groups without internal connections: uses simple vertical stacking (width = max, height = sum)
*/
function calculateGroupMemberDimensions(
layoutGroup: LayoutGroup,
allNodes: Map<number, LGraphNode>,
config: LayoutConfig
): { width: number; height: number } {
const memberNodes = [...layoutGroup.memberIds]
.map((id) => allNodes.get(id))
.filter((n): n is LGraphNode => n !== undefined);
if (memberNodes.length === 0) {
return { width: 0, height: 0 };
}
// Check for internal connections between members
const edges = getInternalEdges(layoutGroup.memberIds, allNodes);
if (edges.length > 0) {
// Has internal layers - calculate per-layer dimensions
const layers = assignMemberLayers(layoutGroup.memberIds, edges, allNodes);
let totalWidth = 0;
let maxHeight = 0;
for (const layer of layers) {
if (layer.length === 0) continue;
let layerMaxWidth = 0;
let layerHeight = 0;
for (const node of layer) {
layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200);
layerHeight += (node.size?.[1] ?? 100) + config.verticalGap;
}
// Remove trailing gap
if (layerHeight > 0) {
layerHeight -= config.verticalGap;
}
totalWidth += layerMaxWidth + config.horizontalGap;
maxHeight = Math.max(maxHeight, layerHeight);
}
// Remove trailing gap
if (totalWidth > 0) {
totalWidth -= config.horizontalGap;
}
return { width: totalWidth, height: maxHeight };
} else {
// No internal connections - simple vertical stacking
let maxWidth = 0;
let totalHeight = 0;
for (const node of memberNodes) {
maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200);
totalHeight += (node.size?.[1] ?? 100) + config.verticalGap;
}
// Remove trailing gap
if (totalHeight > 0) {
totalHeight -= config.verticalGap;
}
return { width: maxWidth, height: totalHeight };
}
}
/**
* Get the top-level group (root of hierarchy) for a given group
*/
function getTopLevelGroup(group: LayoutGroup): LayoutGroup {
let current = group;
while (current.parentGroup) {
current = current.parentGroup;
}
return current;
}
-147
View File
@@ -1,147 +0,0 @@
import type { LayoutState, LGraphGroup } from "./types";
import { debugLog } from "../debug";
/** Title bar height for groups */
const GROUP_TITLE_HEIGHT = 50;
/**
* Resize groups to fit their contents after layout
* Uses bottom-up processing: deepest groups first, then parents
*/
export function resizeGroupsToFit(state: LayoutState): number {
const { groupsByDepth, allNodes, config } = state;
if (!groupsByDepth || !allNodes) {
debugLog("Missing groupsByDepth or allNodes");
return 0;
}
let resizedCount = 0;
const maxDepth = groupsByDepth.length - 1;
// Process from deepest to shallowest (bottom-up)
for (let depth = maxDepth; depth >= 0; depth--) {
for (const layoutGroup of groupsByDepth[depth]) {
const { group, memberIds, childGroups } = layoutGroup;
// Skip groups with no content
if (memberIds.size === 0 && childGroups.length === 0) {
debugLog(`Skipping empty group "${group.title}"`);
continue;
}
// Calculate bounding box from positioned members and child groups
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
// Include direct member nodes
for (const nodeId of memberIds) {
const node = allNodes.get(nodeId);
if (!node) continue;
const nx = node.pos?.[0] ?? 0;
const ny = node.pos?.[1] ?? 0;
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, nx);
minY = Math.min(minY, ny);
maxX = Math.max(maxX, nx + nw);
maxY = Math.max(maxY, ny + nh);
}
// Include child groups (already resized in previous iterations)
for (const child of childGroups) {
const cg = child.group;
const cx = cg.pos[0];
const cy = cg.pos[1];
const cw = cg.size[0];
const ch = cg.size[1];
minX = Math.min(minX, cx);
minY = Math.min(minY, cy);
maxX = Math.max(maxX, cx + cw);
maxY = Math.max(maxY, cy + ch);
}
// Handle empty bounds (shouldn't happen but be safe)
if (minX === Infinity) {
debugLog(`No content bounds for group "${group.title}"`);
continue;
}
// Calculate final group bounds with padding
const padding = config.groupPadding;
const newX = minX - padding;
const newY = minY - padding - GROUP_TITLE_HEIGHT;
const newWidth = maxX - minX + padding * 2;
const newHeight = maxY - minY + padding * 2 + GROUP_TITLE_HEIGHT;
debugLog(
`Resizing group "${group.title}" (depth ${layoutGroup.depth}) to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
);
// Update group bounds
updateGroupBounds(group, newX, newY, newWidth, newHeight);
resizedCount++;
}
}
return resizedCount;
}
/**
* Update group bounds using multiple methods for compatibility
*/
function updateGroupBounds(
group: LGraphGroup,
x: number,
y: number,
width: number,
height: number
): void {
// Method 1: Direct pos/size array mutation (most common)
if (Array.isArray(group.pos)) {
group.pos[0] = x;
group.pos[1] = y;
}
if (Array.isArray(group.size)) {
group.size[0] = width;
group.size[1] = height;
}
// Method 2: Try _pos/_size internal properties
const g = group as LGraphGroup & {
_pos?: [number, number];
_size?: [number, number];
};
if (Array.isArray(g._pos)) {
g._pos[0] = x;
g._pos[1] = y;
}
if (Array.isArray(g._size)) {
g._size[0] = width;
g._size[1] = height;
}
// Method 3: Update _bounding Rectangle if present
if (group._bounding) {
const b = group._bounding;
b.x = x;
b.y = y;
b.width = width;
b.height = height;
}
// Method 4: Try bounding array (older format)
const gAny = group as LGraphGroup & { bounding?: number[] };
if (Array.isArray(gAny.bounding)) {
gAny.bounding[0] = x;
gAny.bounding[1] = y;
gAny.bounding[2] = width;
gAny.bounding[3] = height;
}
}
+23 -100
View File
@@ -1,106 +1,29 @@
import type { LGraph, LayoutConfig, LayoutResult } from "./types";
import { DEFAULT_CONFIG } from "./types";
import { buildLayoutGraph } from "./graph-builder";
import { assignLayers } from "./layer-assign";
import { minimizeCrossings } from "./ordering";
import {
assignLayerXPositions,
assignNodeYPositions,
recalculateLayerXPositions,
applyPositions,
compactVertically,
resolveAllOverlaps,
} from "./positioning";
import { resizeGroupsToFit } from "./groups";
import { debugLog } from "../debug";
/**
* Main entry point: layout the entire graph
*
* Pipeline:
* 1. Build layout graph (classify nodes, collapse reroutes, filter disconnected)
* 2. Assign layers via longest-path algorithm
* 3. Minimize edge crossings via size-aware barycenter heuristic
* 4. Assign X/Y coordinates with bin packing
* 5. Compact vertically to reduce gaps
* 6. Apply positions (includes disconnected zone and reroute restoration)
* 7. Resize groups to fit their members
* Layout module barrel export.
*/
export function layoutGraph(
graph: LGraph,
config?: Partial<LayoutConfig>
): LayoutResult {
const startTime = performance.now();
const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
// Handle empty graph
const nodeList = graph._nodes ?? [];
if (nodeList.length === 0) {
return {
nodeCount: 0,
layerCount: 0,
groupCount: 0,
executionMs: 0,
};
}
export {
layoutWithGroups,
buildGroupHierarchy,
splitDisconnected,
placeDisconnected,
translatePositions,
} from "./framework";
// Phase 1: Build layout graph (includes classification and reroute collapse)
const state = buildLayoutGraph(graph, fullConfig);
export { compactVertically, compactHorizontally } from "./compact";
// Phase 2: Assign layers
assignLayers(state);
export type {
Position,
LayoutNode,
LayoutEdge,
LayoutGroup,
LayoutToken,
LayoutInput,
LayoutOutput,
LayoutAlgorithm,
GroupBounds,
FrameworkResult,
FrameworkConfig,
} from "./types";
// Phase 3: Minimize crossings (size-aware barycenter)
minimizeCrossings(state);
// Phase 4: Assign coordinates (with bin packing)
assignLayerXPositions(state);
assignNodeYPositions(state);
// Phase 4b: Recalculate X after bin packing updates maxWidth
// (bin packing may place multiple nodes in a row, making layer wider)
recalculateLayerXPositions(state);
// Phase 5: Compact vertically
compactVertically(state);
// Phase 6: Apply to ComfyUI nodes (includes disconnected and reroute handling)
applyPositions(state);
// Phase 7: Resize groups
const groupCount = resizeGroupsToFit(state);
// Phase 8: Resolve all overlaps (groups + nodes unified)
// Uses scanline algorithm with priority-based monotonic pushing
resolveAllOverlaps(state);
// Trigger canvas redraw
if (graph.setDirtyCanvas) {
graph.setDirtyCanvas(true, true);
}
const executionMs = performance.now() - startTime;
// Count total nodes including disconnected and reroutes
const disconnectedCount = state.disconnectedNodes?.size ?? 0;
const rerouteCount = state.rerouteChains?.reduce((sum, c) => sum + c.nodes.length, 0) ?? 0;
const totalNodes = state.nodes.size + disconnectedCount + rerouteCount;
debugLog(
`Layout complete: ${totalNodes} nodes (${state.nodes.size} in DAG, ${disconnectedCount} disconnected, ${rerouteCount} reroutes), ${state.layers.length} layers in ${executionMs.toFixed(1)}ms`
);
return {
nodeCount: totalNodes,
layerCount: state.layers.length,
groupCount,
executionMs,
};
}
// Re-export types and config
export type { LayoutConfig, LayoutResult, SelectedGroupLayoutResult } from "./types";
export { DEFAULT_CONFIG } from "./types";
// Export selected groups layout
export { layoutSelectedGroups } from "./selected-groups";
export { DEFAULT_FRAMEWORK_CONFIG } from "./types";
-102
View File
@@ -1,102 +0,0 @@
import type { LayoutState, LayoutNode, Layer } from "./types";
/**
* Assign layers to nodes using longest-path algorithm
* Sources (no predecessors) get layer 0
* Each node gets max(predecessor layers) + 1
*/
export function assignLayers(state: LayoutState): void {
const { nodes } = state;
// Topological sort via Kahn's algorithm
const inDegree = new Map<number, number>();
const queue: number[] = [];
const order: number[] = [];
// Initialize in-degrees
for (const [id, node] of nodes) {
inDegree.set(id, node.predecessors.length);
if (node.predecessors.length === 0) {
queue.push(id);
}
}
// Process nodes in topological order
while (queue.length > 0) {
const nodeId = queue.shift()!;
order.push(nodeId);
const node = nodes.get(nodeId)!;
for (const succId of node.successors) {
const deg = (inDegree.get(succId) ?? 1) - 1;
inDegree.set(succId, deg);
if (deg === 0) {
queue.push(succId);
}
}
}
// Handle cycles: add remaining nodes (shouldn't happen in valid DAG)
for (const [id] of nodes) {
if (!order.includes(id)) {
order.push(id);
}
}
// Assign layers via longest path from sources
for (const nodeId of order) {
const node = nodes.get(nodeId)!;
if (node.predecessors.length === 0) {
// Source node: layer 0
node.layer = 0;
} else {
// Max layer of predecessors + 1
let maxPredLayer = 0;
for (const predId of node.predecessors) {
const pred = nodes.get(predId);
if (pred && pred.layer >= 0) {
maxPredLayer = Math.max(maxPredLayer, pred.layer + 1);
}
}
node.layer = maxPredLayer;
}
}
// Ensure all nodes have valid layer (handle edge cases)
for (const node of nodes.values()) {
if (node.layer < 0) {
node.layer = 0;
}
}
// Build layer structures
const layerMap = new Map<number, LayoutNode[]>();
for (const node of nodes.values()) {
const arr = layerMap.get(node.layer) ?? [];
arr.push(node);
layerMap.set(node.layer, arr);
}
// Sort layers by index and create Layer objects
const sortedLayerIndices = [...layerMap.keys()].sort((a, b) => a - b);
const layers: Layer[] = [];
for (const index of sortedLayerIndices) {
const layerNodes = layerMap.get(index)!;
// Sort nodes in layer by ID for initial deterministic order
layerNodes.sort((a, b) => a.id - b.id);
layerNodes.forEach((n, i) => {
n.orderInLayer = i;
});
layers.push({
index,
nodes: layerNodes,
x: 0,
maxWidth: Math.max(...layerNodes.map((n) => n.width), 100),
});
}
state.layers = layers;
}
-119
View File
@@ -1,119 +0,0 @@
import type { LGraphNode } from "./types";
/**
* Get internal edges between group members by examining node connections
* Returns array of [sourceId, targetId] pairs where both are in the group
*/
export function getInternalEdges(
memberIds: Set<number>,
allNodes: Map<number, LGraphNode>
): Array<[number, number]> {
const edges: Array<[number, number]> = [];
// Build a map of linkId -> sourceNodeId for all member outputs
const linkToSource = new Map<number, number>();
for (const memberId of memberIds) {
const node = allNodes.get(memberId);
if (!node?.outputs) continue;
for (const output of node.outputs) {
if (output.links) {
for (const linkId of output.links) {
linkToSource.set(linkId, memberId);
}
}
}
}
// Check each member's inputs to find internal connections
for (const memberId of memberIds) {
const node = allNodes.get(memberId);
if (!node?.inputs) continue;
for (const input of node.inputs) {
if (input.link !== null && input.link !== undefined) {
const sourceId = linkToSource.get(input.link);
if (sourceId !== undefined && memberIds.has(sourceId)) {
edges.push([sourceId, memberId]);
}
}
}
}
return edges;
}
/**
* Assign layers to group members using longest-path algorithm
* Returns members grouped by layer (index 0 = sources)
*/
export function assignMemberLayers(
memberIds: Set<number>,
edges: Array<[number, number]>,
allNodes: Map<number, LGraphNode>
): LGraphNode[][] {
// Build adjacency and in-degree
const successors = new Map<number, number[]>();
const inDegree = new Map<number, number>();
for (const id of memberIds) {
successors.set(id, []);
inDegree.set(id, 0);
}
for (const [source, target] of edges) {
successors.get(source)!.push(target);
inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
}
// Longest-path layer assignment via topological sort
const nodeLayer = new Map<number, number>();
const queue: number[] = [];
// Start with sources (in-degree 0)
for (const id of memberIds) {
if ((inDegree.get(id) ?? 0) === 0) {
queue.push(id);
nodeLayer.set(id, 0);
}
}
while (queue.length > 0) {
const current = queue.shift()!;
const currentLayer = nodeLayer.get(current) ?? 0;
for (const succ of successors.get(current) ?? []) {
// Update successor layer to max of current paths
const succLayer = nodeLayer.get(succ) ?? 0;
nodeLayer.set(succ, Math.max(succLayer, currentLayer + 1));
// Decrease in-degree and add to queue when all predecessors processed
const newInDegree = (inDegree.get(succ) ?? 1) - 1;
inDegree.set(succ, newInDegree);
if (newInDegree === 0) {
queue.push(succ);
}
}
}
// Handle any unvisited nodes (cycles or disconnected)
for (const id of memberIds) {
if (!nodeLayer.has(id)) {
nodeLayer.set(id, 0);
}
}
// Group by layer
const maxLayer = Math.max(...nodeLayer.values(), 0);
const layers: LGraphNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
for (const id of memberIds) {
const node = allNodes.get(id);
if (node) {
const layer = nodeLayer.get(id) ?? 0;
layers[layer].push(node);
}
}
return layers;
}
-79
View File
@@ -1,79 +0,0 @@
import type { LGraph, LLink, ClassifiedNodes } from "./types";
import { debugLog } from "../debug";
/**
* Iterate over links in a graph (handles Map, Record, and Array formats)
*/
function* iterateLinks(
links: Map<number, LLink> | Record<number, LLink> | LLink[]
): Generator<LLink> {
if (links instanceof Map) {
for (const link of links.values()) {
if (link) yield link;
}
} else if (Array.isArray(links)) {
for (const link of links) {
if (link) yield link;
}
} else {
for (const link of Object.values(links)) {
if (link) yield link;
}
}
}
/**
* Classify nodes into connected, disconnected, and reroutes
*
* - connected: nodes with at least one link
* - disconnected: nodes with no links (annotations, bookmarks, etc.)
* - reroutes: nodes with type "Reroute" (handled separately for chain collapse)
*/
export function classifyNodes(graph: LGraph): ClassifiedNodes {
const hasLinks = new Set<number>();
// Mark all nodes that appear in any link
if (graph.links) {
for (const link of iterateLinks(graph.links)) {
if (typeof link.origin_id === "number") {
hasLinks.add(link.origin_id);
}
if (typeof link.target_id === "number") {
hasLinks.add(link.target_id);
}
}
}
const connected = new Set<number>();
const disconnected = new Set<number>();
const reroutes = new Set<number>();
const nodes = graph._nodes ?? [];
for (const node of nodes) {
if (!node || node.id === undefined) continue;
const nodeType = node.type ?? "";
// Check if this is a reroute node
// Common reroute types: "Reroute", "Reroute (rgthree)", etc.
const isReroute = nodeType === "Reroute" || nodeType.startsWith("Reroute ");
if (isReroute) {
reroutes.add(node.id);
// Reroutes are also tracked as connected if they have links
if (hasLinks.has(node.id)) {
connected.add(node.id);
}
} else if (hasLinks.has(node.id)) {
connected.add(node.id);
} else {
disconnected.add(node.id);
}
}
debugLog(
`Classified: ${connected.size} connected, ${disconnected.size} disconnected, ${reroutes.size} reroutes`
);
return { connected, disconnected, reroutes };
}
-195
View File
@@ -1,195 +0,0 @@
import type { LayoutState, Layer, LayoutNode } from "./types";
/**
* Minimize edge crossings using size-aware barycenter heuristic
* Uses cumulative height positions instead of simple indices
* Alternates forward and backward sweeps
*/
export function minimizeCrossings(state: LayoutState): void {
const { layers, nodes, config } = state;
if (layers.length < 2) return;
// Initial ordering: sort by node height (tall nodes first reduces gaps)
for (const layer of layers) {
layer.nodes.sort((a, b) => b.height - a.height);
layer.nodes.forEach((n, i) => {
n.orderInLayer = i;
});
}
// Pre-compute cumulative height positions for size-aware barycenter
const heightPositions = new Map<number, number>();
updateHeightPositions(layers, heightPositions, config.verticalGap);
// Iterative improvement with forward/backward sweeps
for (let iter = 0; iter < config.maxIterations; iter++) {
const improved =
iter % 2 === 0
? sweepForward(layers, nodes, heightPositions, config.verticalGap)
: sweepBackward(layers, nodes, heightPositions, config.verticalGap);
if (!improved) break;
}
}
/**
* Compute cumulative height positions for each node
* This gives a position-like weight for size-aware ordering
*/
function updateHeightPositions(
layers: Layer[],
positions: Map<number, number>,
verticalGap: number
): void {
for (const layer of layers) {
let y = 0;
for (const node of layer.nodes) {
// Position is center of node
positions.set(node.id, y + node.height / 2);
y += node.height + verticalGap;
}
}
}
/**
* Forward sweep: order each layer based on predecessors in previous layer
* Uses height-aware positions for better size consideration
*/
function sweepForward(
layers: Layer[],
nodes: Map<number, LayoutNode>,
heightPositions: Map<number, number>,
verticalGap: number
): boolean {
let improved = false;
for (let i = 1; i < layers.length; i++) {
const currLayer = layers[i];
// Compute size-aware barycenter for each node
const barycenters = new Map<number, number>();
for (const node of currLayer.nodes) {
if (node.predecessors.length > 0) {
let sum = 0;
let count = 0;
for (const predId of node.predecessors) {
const pred = nodes.get(predId);
if (pred && pred.layer === i - 1) {
// Use height position instead of simple index
const pos = heightPositions.get(predId) ?? pred.orderInLayer;
sum += pos;
count++;
}
}
if (count > 0) {
barycenters.set(node.id, sum / count);
} else {
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
}
} else {
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
}
}
// Sort by barycenter, tie-break by ID for determinism
const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
currLayer.nodes.sort((a, b) => {
const aBar = barycenters.get(a.id) ?? 0;
const bBar = barycenters.get(b.id) ?? 0;
const diff = aBar - bBar;
return diff !== 0 ? diff : a.id - b.id;
});
// Reassign integer order
currLayer.nodes.forEach((n, idx) => {
n.orderInLayer = idx;
});
// Update height positions for next iteration
let y = 0;
for (const node of currLayer.nodes) {
heightPositions.set(node.id, y + node.height / 2);
y += node.height + verticalGap;
}
const newOrder = currLayer.nodes.map((n) => n.id).join(",");
if (oldOrder !== newOrder) improved = true;
}
return improved;
}
/**
* Backward sweep: order each layer based on successors in next layer
* Uses height-aware positions for better size consideration
*/
function sweepBackward(
layers: Layer[],
nodes: Map<number, LayoutNode>,
heightPositions: Map<number, number>,
verticalGap: number
): boolean {
let improved = false;
for (let i = layers.length - 2; i >= 0; i--) {
const currLayer = layers[i];
// Compute size-aware barycenter for each node based on successors
const barycenters = new Map<number, number>();
for (const node of currLayer.nodes) {
if (node.successors.length > 0) {
let sum = 0;
let count = 0;
for (const succId of node.successors) {
const succ = nodes.get(succId);
if (succ && succ.layer === i + 1) {
// Use height position instead of simple index
const pos = heightPositions.get(succId) ?? succ.orderInLayer;
sum += pos;
count++;
}
}
if (count > 0) {
barycenters.set(node.id, sum / count);
} else {
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
}
} else {
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
}
}
// Sort by barycenter, tie-break by ID for determinism
const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
currLayer.nodes.sort((a, b) => {
const aBar = barycenters.get(a.id) ?? 0;
const bBar = barycenters.get(b.id) ?? 0;
const diff = aBar - bBar;
return diff !== 0 ? diff : a.id - b.id;
});
// Reassign integer order
currLayer.nodes.forEach((n, idx) => {
n.orderInLayer = idx;
});
// Update height positions for next iteration
let y = 0;
for (const node of currLayer.nodes) {
heightPositions.set(node.id, y + node.height / 2);
y += node.height + verticalGap;
}
const newOrder = currLayer.nodes.map((n) => n.id).join(",");
if (oldOrder !== newOrder) improved = true;
}
return improved;
}
-846
View File
@@ -1,846 +0,0 @@
import type { LayoutState, LayoutGroup, LGraphNode, LGraphGroup, LayoutConfig, PackedRow, LayoutNode } from "./types";
/**
* Entity type for unified overlap resolution
* Represents either a group or a standalone node
*/
interface LayoutEntity {
id: string;
type: "group" | "node";
priority: number;
x: number;
y: number;
width: number;
height: number;
initialX: number; // Original position before overlap resolution
initialY: number; // Original position before overlap resolution
group?: LGraphGroup;
node?: LGraphNode;
layoutGroup?: LayoutGroup;
}
import { packNodesIntoRows, getPackedWidth } from "./bin-pack";
import { restoreReroutePositions } from "./reroute-collapse";
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
import { debugLog } from "../debug";
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
/** Title bar height for groups */
const GROUP_TITLE_HEIGHT = 50;
/**
* Calculate disconnected zone width if there are disconnected nodes
*/
function getDisconnectedZoneWidth(state: LayoutState): number {
const { disconnectedNodes, allNodes, config } = state;
if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
return 0;
}
let maxWidth = 0;
for (const nodeId of disconnectedNodes) {
const node = allNodes.get(nodeId);
if (node) {
maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200);
}
}
return maxWidth + config.disconnectedGap;
}
/**
* Assign X coordinates to layers
* Accounts for disconnected zone on the left if present
*/
export function assignLayerXPositions(state: LayoutState): void {
const { layers, config } = state;
const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
let x = config.startX + disconnectedZoneWidth;
for (const layer of layers) {
layer.x = x;
x += layer.maxWidth + config.horizontalGap;
}
}
/**
* Recalculate layer X positions after bin packing updates maxWidth
* Also updates node X positions to maintain their offset within each layer
* Must be called AFTER assignNodeYPositions which updates layer.maxWidth
*/
export function recalculateLayerXPositions(state: LayoutState): void {
const { layers, config } = state;
const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
// Store each node's offset from its layer's current X
const nodeOffsets = new Map<number, number>();
for (const layer of layers) {
for (const node of layer.nodes) {
nodeOffsets.set(node.id, node.x - layer.x);
}
}
// Recalculate layer X positions using updated maxWidth
let x = config.startX + disconnectedZoneWidth;
for (const layer of layers) {
layer.x = x;
x += layer.maxWidth + config.horizontalGap;
}
// Update node X positions based on new layer X + original offset
for (const layer of layers) {
for (const node of layer.nodes) {
const offset = nodeOffsets.get(node.id) ?? 0;
node.x = layer.x + offset;
}
}
}
/**
* Assign Y coordinates to nodes within each layer using bin packing
* Supports multi-column layout based on config.maxColumns
* Tracks reserved regions to prevent group/node overlaps within same layer
*/
export function assignNodeYPositions(state: LayoutState): void {
const { layers, config } = state;
// Store packed rows for reference
const packedRows = new Map<number, PackedRow[]>();
for (const layer of layers) {
// Separate groups (placed first, full width) from standalone nodes
const groupNodes = layer.nodes.filter((n) => n.isGroupRepresentative);
const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
let y = config.startY;
const reservedRegions: Array<{ top: number; bottom: number }> = [];
// Position groups first, tracking their Y regions
for (const node of groupNodes) {
node.x = layer.x;
node.y = y;
reservedRegions.push({ top: y, bottom: y + node.height });
y += node.height + config.verticalGap;
}
// Bin pack standalone nodes, avoiding reserved regions
if (standaloneNodes.length > 0) {
// Build size map for bin packing
const sizes = new Map<number, { width: number; height: number }>();
for (const node of standaloneNodes) {
sizes.set(node.id, { width: node.width, height: node.height });
}
// Pack nodes into rows
const nodeIds = standaloneNodes.map((n) => n.id);
const rows = packNodesIntoRows(nodeIds, sizes, config);
packedRows.set(layer.index, rows);
// Assign positions based on packed rows
for (const row of rows) {
let rowX = layer.x;
let rowY = y + row.yOffset;
// Check if row overlaps any reserved region
for (const region of reservedRegions) {
if (rowY < region.bottom && rowY + row.height > region.top) {
rowY = region.bottom + config.verticalGap;
}
}
for (const nodeId of row.nodes) {
const node = standaloneNodes.find((n) => n.id === nodeId);
if (node) {
node.x = rowX;
node.y = rowY;
rowX += node.width + config.horizontalGap;
}
}
}
// Update layer maxWidth to account for packed rows
const maxRowWidth = getPackedWidth(rows);
if (maxRowWidth > layer.maxWidth) {
layer.maxWidth = maxRowWidth;
}
}
}
state.packedRows = packedRows;
}
/**
* Place disconnected nodes in the left margin zone
* Uses bin packing for compact arrangement
*/
export function placeDisconnectedNodes(state: LayoutState): void {
const { disconnectedNodes, allNodes, nodeToGroup, config } = state;
if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
return;
}
// Filter out disconnected nodes that are inside groups - they're positioned with their group
const standaloneDisconnected: number[] = [];
for (const nodeId of disconnectedNodes) {
if (nodeToGroup?.has(nodeId)) {
continue; // Skip - this node is inside a group
}
standaloneDisconnected.push(nodeId);
}
if (standaloneDisconnected.length === 0) {
debugLog(`All ${disconnectedNodes.size} disconnected nodes are inside groups`);
return;
}
// Build size map
const sizes = new Map<number, { width: number; height: number }>();
for (const nodeId of standaloneDisconnected) {
const node = allNodes.get(nodeId);
if (node) {
sizes.set(nodeId, {
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
});
}
}
// Pack into rows using config settings
const rows = packNodesIntoRows(standaloneDisconnected, sizes, {
...config,
maxColumns: 1, // Stack vertically in left margin
});
// Position at startX, startY (left of main DAG)
const x = config.startX;
const y = config.startY;
for (const row of rows) {
for (const nodeId of row.nodes) {
const node = allNodes.get(nodeId);
if (node && node.pos) {
// Skip locked/pinned nodes
if (node.flags?.pinned || node.locked) {
continue;
}
node.pos[0] = x;
node.pos[1] = y + row.yOffset;
}
}
}
debugLog(
`Placed ${standaloneDisconnected.length} disconnected nodes in left margin (${disconnectedNodes.size - standaloneDisconnected.length} inside groups)`
);
}
/**
* Apply computed positions to actual ComfyUI nodes
* Handles standalone nodes, subgraph I/O nodes, top-level groups, and nested groups recursively
* Also restores reroute positions and places disconnected nodes
*/
export function applyPositions(state: LayoutState): void {
const { nodes, groupRepresentatives, allNodes, groups, config, rerouteChains } = state;
// First: Apply positions to standalone nodes (including I/O nodes)
for (const layoutNode of nodes.values()) {
if (layoutNode.isGroupRepresentative) {
// This is a group - handled below with recursive positioning
continue;
}
// Handle subgraph I/O nodes (update pos array directly)
if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) {
layoutNode.runtimeIONode.pos[0] = layoutNode.x;
layoutNode.runtimeIONode.pos[1] = layoutNode.y;
// Sync allNodes entry for overlap resolution (ioNodeToLGraphNode creates a copy)
if (allNodes) {
const allNode = allNodes.get(layoutNode.id);
if (allNode?.pos) {
allNode.pos[0] = layoutNode.x;
allNode.pos[1] = layoutNode.y;
}
}
debugLog(
`Positioned I/O node id=${layoutNode.id} to [${layoutNode.x}, ${layoutNode.y}]`
);
continue;
}
const comfyNode = layoutNode.node;
// Skip locked/pinned nodes
if (comfyNode.flags?.pinned || comfyNode.locked) {
continue;
}
// Update position
if (comfyNode.pos) {
comfyNode.pos[0] = layoutNode.x;
comfyNode.pos[1] = layoutNode.y;
}
}
// Second: Position group contents recursively (top-level groups only)
if (groupRepresentatives && allNodes && groups) {
// Find top-level groups (no parent)
const topLevelGroups = groups.filter((g) => !g.parentGroup);
for (const topGroup of topLevelGroups) {
const repId = groupRepresentatives.get(topGroup);
if (repId === undefined) continue;
const repNode = nodes.get(repId);
if (!repNode) continue;
// Start positioning at the group's content area
const startX = repNode.x + config.groupPadding;
const startY = repNode.y + config.groupPadding + GROUP_TITLE_HEIGHT;
positionGroupContents(topGroup, startX, startY, allNodes, config);
}
}
// Third: Place disconnected nodes in left margin
placeDisconnectedNodes(state);
// Fourth: Restore reroute positions along their chain edges
if (rerouteChains && rerouteChains.length > 0 && allNodes) {
// Build positions and sizes maps from actual nodes
const positions = new Map<number, { x: number; y: number }>();
const sizes = new Map<number, { width: number; height: number }>();
for (const [id, node] of allNodes) {
if (node.pos) {
positions.set(id, { x: node.pos[0], y: node.pos[1] });
}
if (node.size) {
sizes.set(id, { width: node.size[0], height: node.size[1] });
}
}
// Also include layout node positions (for nodes we just positioned)
for (const layoutNode of nodes.values()) {
positions.set(layoutNode.id, { x: layoutNode.x, y: layoutNode.y });
sizes.set(layoutNode.id, { width: layoutNode.width, height: layoutNode.height });
}
// Restore reroute positions
restoreReroutePositions(rerouteChains, positions, sizes);
// Apply restored positions to actual nodes
for (const chain of rerouteChains) {
for (const rerouteId of chain.nodes) {
const pos = positions.get(rerouteId);
const node = allNodes.get(rerouteId);
if (pos && node && node.pos) {
node.pos[0] = pos.x;
node.pos[1] = pos.y;
}
}
}
debugLog(
`Restored ${rerouteChains.length} reroute chains`
);
}
}
/**
* Re-layout group contents using proper Sugiyama-style layout for connected members
* or bin packing for disconnected members
* Returns the final Y position after all contents
*/
function positionGroupContents(
group: LayoutGroup,
startX: number,
startY: number,
allNodes: Map<number, LGraphNode>,
config: LayoutConfig
): number {
// Collect non-locked members
const members: LGraphNode[] = [];
for (const nodeId of group.memberIds) {
const node = allNodes.get(nodeId);
if (!node) continue;
if (node.flags?.pinned || node.locked) continue;
members.push(node);
}
// Handle empty group
if (members.length === 0 && group.childGroups.length === 0) {
return startY;
}
// Parse layout token from group title
const layoutMode = parseLayoutToken(group.group.title);
debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}, mode=${layoutMode.type}`);
let finalY = startY;
// If layout mode is specified, use token-based layout (ignores internal edges)
if (layoutMode.type !== "default" && members.length > 0) {
const arranged = arrangeByMode(members, layoutMode, startX, startY, config);
for (const pos of arranged) {
const node = allNodes.get(pos.id);
if (node?.pos) {
node.pos[0] = pos.x;
node.pos[1] = pos.y;
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
const nh = node.size?.[1] ?? 100;
finalY = Math.max(finalY, pos.y + nh);
}
}
} else if (members.length > 0) {
// Default behavior: check internal edges for layer-based or bin pack layout
const edges = getInternalEdges(group.memberIds, allNodes);
if (edges.length > 0) {
// Has internal connections - use layer-based layout
const layers = assignMemberLayers(group.memberIds, edges, allNodes);
let x = startX;
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
const layer = layers[layerIdx];
if (layer.length === 0) continue;
// Sort layer by height (tallest first for stable layout)
layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
let y = startY;
let layerMaxWidth = 0;
for (const node of layer) {
if (node.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`);
}
layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200);
y += (node.size?.[1] ?? 100) + config.verticalGap;
}
finalY = Math.max(finalY, y - config.verticalGap);
x += layerMaxWidth + config.horizontalGap;
}
} else {
// No internal connections - use simple vertical stacking with bin pack
// Sort by height for consistent layout
members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
// Build sizes map
const sizes = new Map<number, { width: number; height: number }>();
for (const node of members) {
sizes.set(node.id, {
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
});
}
// Pack into rows
const nodeIds = members.map((n) => n.id);
const rows = packNodesIntoRows(nodeIds, sizes, config);
let y = startY;
for (const row of rows) {
let x = startX;
for (const nodeId of row.nodes) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(` Member ${node.id} (${node.type}): packed pos [${x}, ${y}]`);
x += (node.size?.[0] ?? 200) + config.horizontalGap;
}
}
y += row.height + config.verticalGap;
}
finalY = y - config.verticalGap;
}
}
// Position child groups after members
for (const child of group.childGroups) {
// Calculate child group's content area
const childStartX = startX;
const childStartY = finalY + config.verticalGap;
// First, layout the child group's contents
const childEndY = positionGroupContents(child, childStartX, childStartY + GROUP_TITLE_HEIGHT, allNodes, config);
// Update child group position
if (child.group.pos) {
child.group.pos[0] = childStartX - config.groupPadding;
child.group.pos[1] = childStartY - config.groupPadding;
debugLog(` Child group "${child.group.title}": pos [${child.group.pos[0]}, ${child.group.pos[1]}]`);
}
finalY = childEndY + config.groupPadding;
}
return finalY;
}
/**
* Calculate vertical bounds of nodes in a layer
* Returns actual min/max Y by iterating all nodes, not assuming array order
* (Array order is by barycenter, not Y position after bin-packing)
*/
function getLayerBounds(nodes: LayoutNode[]): { minY: number; maxY: number } | null {
if (nodes.length === 0) return null;
let minY = Infinity;
let maxY = -Infinity;
for (const node of nodes) {
minY = Math.min(minY, node.y);
maxY = Math.max(maxY, node.y + node.height);
}
if (minY === Infinity) return null;
return { minY, maxY };
}
/**
* Compact vertical layout to reduce gaps
* Centers layers against the tallest non-group layer
* Groups are excluded from max calculation to prevent Y explosion
*/
export function compactVertically(state: LayoutState): void {
const { layers } = state;
// Calculate max height from STANDALONE nodes only (exclude groups)
// Groups are outliers that can be 4000px+, which would push all other layers down
let maxTotalHeight = 0;
for (const layer of layers) {
const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
if (standaloneNodes.length === 0) {
// Layer is all groups, skip from max calculation
continue;
}
const bounds = getLayerBounds(standaloneNodes);
if (!bounds) continue;
const layerHeight = bounds.maxY - bounds.minY;
maxTotalHeight = Math.max(maxTotalHeight, layerHeight);
}
// If no standalone layers, skip centering entirely
if (maxTotalHeight === 0) return;
// Center each layer against maxTotalHeight
for (const layer of layers) {
if (layer.nodes.length === 0) continue;
const bounds = getLayerBounds(layer.nodes);
if (!bounds) continue;
const layerHeight = bounds.maxY - bounds.minY;
const offset = (maxTotalHeight - layerHeight) / 2;
for (const node of layer.nodes) {
node.y += offset;
}
}
}
/**
* Collect all entities (groups and standalone nodes) for overlap resolution
* Assigns priority: groups=100, connected nodes=50, disconnected=10
*/
function collectEntities(state: LayoutState): LayoutEntity[] {
const entities: LayoutEntity[] = [];
const { groups, allNodes, nodeToGroup, disconnectedNodes } = state;
if (!allNodes) return entities;
// Add top-level groups (high priority - they anchor)
for (const lg of groups ?? []) {
if (lg.parentGroup) continue; // Skip nested, handled with parent
const x = lg.group.pos?.[0] ?? 0;
const y = lg.group.pos?.[1] ?? 0;
entities.push({
id: `group_${lg.group.title ?? entities.length}`,
type: "group",
priority: 100,
x,
y,
width: lg.group.size?.[0] ?? 0,
height: lg.group.size?.[1] ?? 0,
initialX: x,
initialY: y,
group: lg.group,
layoutGroup: lg,
});
}
// Add standalone nodes (lower priority)
for (const [nodeId, node] of allNodes) {
if (nodeToGroup?.has(nodeId)) continue; // Inside group
if (node.flags?.pinned || node.locked) continue;
const isDisconnected = disconnectedNodes?.has(nodeId) ?? false;
const x = node.pos?.[0] ?? 0;
const y = node.pos?.[1] ?? 0;
entities.push({
id: `node_${nodeId}`,
type: "node",
priority: isDisconnected ? 10 : 50,
x,
y,
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
initialX: x,
initialY: y,
node,
});
}
return entities;
}
/**
* Scanline overlap resolution for X-axis (push right)
* Entities are sorted by X, overlapping entities are pushed right
* Higher priority entities anchor, lower priority pushed
* Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions)
* Returns true if any entity was moved
*/
function scanlineResolveX(
entities: LayoutEntity[],
config: LayoutConfig
): boolean {
let anyChanged = false;
let iterations = 0;
const maxIterations = entities.length * 2;
let changed = true;
while (changed && iterations < maxIterations) {
changed = false;
iterations++;
// Sort by X position, then priority, then ID (deterministic)
entities.sort((a, b) => a.x - b.x || b.priority - a.priority || a.id.localeCompare(b.id));
for (let i = 0; i < entities.length; i++) {
const anchor = entities[i];
for (let j = i + 1; j < entities.length; j++) {
const pushed = entities[j];
// Check Y overlap (must overlap on Y axis to need X separation)
const yOverlap =
anchor.y < pushed.y + pushed.height + config.verticalGap &&
pushed.y < anchor.y + anchor.height + config.verticalGap;
if (!yOverlap) continue;
// Check X overlap
const requiredX = anchor.x + anchor.width + config.horizontalGap;
if (pushed.x < requiredX) {
// Overlap! Push entity j to the right (entity only, not node.pos)
const dx = requiredX - pushed.x;
pushed.x = requiredX;
changed = true;
anyChanged = true;
debugLog(`Scanline X: ${pushed.id} right ${dx.toFixed(0)}px (avoid ${anchor.id})`);
}
}
}
}
if (iterations > 1) {
debugLog(`X-axis scanline: ${iterations} iterations`);
}
return anyChanged;
}
/**
* Scanline overlap resolution for Y-axis (push down)
* Entities are sorted by Y, overlapping entities are pushed down
* Higher priority entities anchor, lower priority pushed
* Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions)
* Returns true if any entity was moved
*/
function scanlineResolveY(
entities: LayoutEntity[],
config: LayoutConfig
): boolean {
let anyChanged = false;
let iterations = 0;
const maxIterations = entities.length * 2;
let changed = true;
while (changed && iterations < maxIterations) {
changed = false;
iterations++;
// Sort by Y position, then priority, then ID (deterministic)
entities.sort((a, b) => a.y - b.y || b.priority - a.priority || a.id.localeCompare(b.id));
for (let i = 0; i < entities.length; i++) {
const anchor = entities[i];
for (let j = i + 1; j < entities.length; j++) {
const pushed = entities[j];
// Check X overlap (must overlap on X axis to need Y separation)
const xOverlap =
anchor.x < pushed.x + pushed.width + config.horizontalGap &&
pushed.x < anchor.x + anchor.width + config.horizontalGap;
if (!xOverlap) continue;
// Check Y overlap
const requiredY = anchor.y + anchor.height + config.verticalGap;
if (pushed.y < requiredY) {
// Overlap! Push entity j down (entity only, not node.pos)
const dy = requiredY - pushed.y;
pushed.y = requiredY;
changed = true;
anyChanged = true;
debugLog(`Scanline Y: ${pushed.id} down ${dy.toFixed(0)}px (avoid ${anchor.id})`);
}
}
}
}
if (iterations > 1) {
debugLog(`Y-axis scanline: ${iterations} iterations`);
}
return anyChanged;
}
/**
* Recursively shift all member nodes and nested child groups
*/
function shiftGroupContentsRecursively(
layoutGroup: LayoutGroup,
dx: number,
dy: number,
allNodes: Map<number, LGraphNode>
): void {
// Shift member nodes of this group
for (const nodeId of layoutGroup.memberIds) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] += dx;
node.pos[1] += dy;
}
}
// Shift child groups and their contents recursively
for (const child of layoutGroup.childGroups) {
if (child.group.pos) {
child.group.pos[0] += dx;
child.group.pos[1] += dy;
}
shiftGroupContentsRecursively(child, dx, dy, allNodes);
}
}
/**
* Recursively shift child groups and their members
*/
function shiftChildGroups(
layoutGroup: LayoutGroup,
dx: number,
dy: number,
allNodes: Map<number, LGraphNode>
): void {
for (const child of layoutGroup.childGroups) {
if (child.group.pos) {
child.group.pos[0] += dx;
child.group.pos[1] += dy;
}
shiftGroupContentsRecursively(child, dx, dy, allNodes);
}
}
/**
* Apply final entity positions to node.pos after scanline resolution
* Calculates deltas from initial positions and applies to groups/nodes
*/
function applyFinalPositions(
entities: LayoutEntity[],
allNodes: Map<number, LGraphNode>
): void {
for (const entity of entities) {
const dx = entity.x - entity.initialX;
const dy = entity.y - entity.initialY;
// Skip if no movement
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue;
if (entity.type === "group" && entity.group && entity.layoutGroup) {
// Set group's final position
if (entity.group.pos) {
entity.group.pos[0] = entity.x;
entity.group.pos[1] = entity.y;
}
// Shift all member nodes by the same delta
for (const nodeId of entity.layoutGroup.memberIds) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] += dx;
node.pos[1] += dy;
}
}
// Shift child groups recursively
shiftChildGroups(entity.layoutGroup, dx, dy, allNodes);
} else if (entity.type === "node" && entity.node?.pos) {
// Set node's final position
entity.node.pos[0] = entity.x;
entity.node.pos[1] = entity.y;
}
}
}
/**
* Unified overlap resolution using scanline algorithm
* Uses priority-based monotonic pushing (right/down only) to prevent oscillation
* Groups have highest priority and anchor in place
* Idempotent: decouples entity tracking from node.pos writes
*/
export function resolveAllOverlaps(state: LayoutState): void {
const { allNodes, nodes, config } = state;
if (!allNodes) return;
const entities = collectEntities(state);
if (entities.length < 2) {
debugLog(`Overlap resolution: ${entities.length} entities (skipped)`);
return;
}
// Pass 1: X-axis (left-to-right pushing) - updates entity.x only
scanlineResolveX(entities, config);
// Pass 2: Y-axis (top-to-bottom pushing) - updates entity.y only
scanlineResolveY(entities, config);
// Pass 3: Apply final positions to node.pos
applyFinalPositions(entities, allNodes);
// Pass 4: Sync I/O node positions from allNodes back to runtimeIONode
// (applyFinalPositions updates allNodes[id].pos, but runtimeIONode.pos needs syncing)
if (nodes) {
for (const layoutNode of nodes.values()) {
if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) {
const allNode = allNodes.get(layoutNode.id);
if (allNode?.pos) {
layoutNode.runtimeIONode.pos[0] = allNode.pos[0];
layoutNode.runtimeIONode.pos[1] = allNode.pos[1];
}
}
}
}
debugLog(`Overlap resolution: ${entities.length} entities processed`);
}
-277
View File
@@ -1,277 +0,0 @@
import type { LGraph, LLink, RerouteChain } from "./types";
import { debugLog } from "../debug";
/** Link info for chain detection */
interface LinkInfo {
linkId: number;
originId: number;
originSlot: number;
targetId: number;
targetSlot: number;
}
/**
* Iterate over links in a graph (handles Map, Record, and Array formats)
*/
function* iterateLinks(
links: Map<number, LLink> | Record<number, LLink> | LLink[]
): Generator<LLink> {
if (links instanceof Map) {
for (const link of links.values()) {
if (link) yield link;
}
} else if (Array.isArray(links)) {
for (const link of links) {
if (link) yield link;
}
} else {
for (const link of Object.values(links)) {
if (link) yield link;
}
}
}
/**
* Find reroute chains in the graph
*
* A chain is a sequence of reroute nodes connected linearly:
* Source -> Reroute1 -> Reroute2 -> ... -> Target(s)
*
* Returns chains that can be collapsed into virtual edges
*/
export function findRerouteChains(
graph: LGraph,
reroutes: Set<number>
): RerouteChain[] {
if (!graph.links || reroutes.size === 0) return [];
// Build link info maps
// incomingLinks: target -> [links pointing to it]
// outgoingLinks: origin -> [links from it]
const incomingLinks = new Map<number, LinkInfo[]>();
const outgoingLinks = new Map<number, LinkInfo[]>();
for (const link of iterateLinks(graph.links)) {
if (!link || typeof link.origin_id !== "number" || typeof link.target_id !== "number") {
continue;
}
const info: LinkInfo = {
linkId: link.id,
originId: link.origin_id,
originSlot: link.origin_slot,
targetId: link.target_id,
targetSlot: link.target_slot,
};
if (!incomingLinks.has(link.target_id)) {
incomingLinks.set(link.target_id, []);
}
incomingLinks.get(link.target_id)!.push(info);
if (!outgoingLinks.has(link.origin_id)) {
outgoingLinks.set(link.origin_id, []);
}
outgoingLinks.get(link.origin_id)!.push(info);
}
// Find chain starts: reroutes that are fed by a non-reroute
const visitedReroutes = new Set<number>();
const chains: RerouteChain[] = [];
for (const rerouteId of reroutes) {
if (visitedReroutes.has(rerouteId)) continue;
const incoming = incomingLinks.get(rerouteId) ?? [];
// Find non-reroute sources feeding this reroute
for (const link of incoming) {
if (reroutes.has(link.originId)) {
// Source is also a reroute, this isn't a chain start
continue;
}
// Found a chain start: non-reroute -> reroute
const chain = traceRerouteChain(
rerouteId,
link.originId,
link.originSlot,
reroutes,
outgoingLinks,
visitedReroutes
);
if (chain.nodes.length > 0) {
chains.push(chain);
}
}
}
debugLog(`Found ${chains.length} reroute chains`);
return chains;
}
/**
* Trace a reroute chain from start to end
*/
function traceRerouteChain(
startRerouteId: number,
sourceNode: number,
sourceSlot: number,
reroutes: Set<number>,
outgoingLinks: Map<number, LinkInfo[]>,
visitedReroutes: Set<number>
): RerouteChain {
const chainNodes: number[] = [];
const targets: Array<{ node: number; slot: number }> = [];
let currentId = startRerouteId;
while (reroutes.has(currentId) && !visitedReroutes.has(currentId)) {
visitedReroutes.add(currentId);
chainNodes.push(currentId);
const outgoing = outgoingLinks.get(currentId) ?? [];
if (outgoing.length === 0) {
// Dead end reroute (no outgoing connections)
break;
}
// Check where the reroute goes
let nextReroute: number | null = null;
for (const link of outgoing) {
if (reroutes.has(link.targetId) && !visitedReroutes.has(link.targetId)) {
// Continues to another reroute
nextReroute = link.targetId;
} else if (!reroutes.has(link.targetId)) {
// Goes to a non-reroute target
targets.push({
node: link.targetId,
slot: link.targetSlot,
});
}
}
if (nextReroute !== null) {
currentId = nextReroute;
} else {
// Chain ends here
break;
}
}
return {
nodes: chainNodes,
sourceNode,
sourceSlot,
targets,
};
}
/**
* Create virtual edges that bypass reroute chains
*
* For layout purposes, we replace:
* Source -> Reroute1 -> Reroute2 -> Target
* With:
* Source -> Target (virtual edge)
*
* @returns Map of source node to target nodes (for DAG building)
*/
export function getVirtualEdges(
chains: RerouteChain[]
): Map<number, Set<number>> {
const virtualEdges = new Map<number, Set<number>>();
for (const chain of chains) {
if (!virtualEdges.has(chain.sourceNode)) {
virtualEdges.set(chain.sourceNode, new Set());
}
for (const target of chain.targets) {
virtualEdges.get(chain.sourceNode)!.add(target.node);
}
}
return virtualEdges;
}
/**
* Position reroute nodes along their chain edges after layout
*
* Distributes reroutes evenly between source and target positions
*/
export function restoreReroutePositions(
chains: RerouteChain[],
positions: Map<number, { x: number; y: number }>,
sizes: Map<number, { width: number; height: number }>
): void {
for (const chain of chains) {
if (chain.nodes.length === 0 || chain.targets.length === 0) continue;
const sourcePos = positions.get(chain.sourceNode);
const sourceSize = sizes.get(chain.sourceNode);
if (!sourcePos || !sourceSize) continue;
// Calculate average target position
let avgTargetX = 0;
let avgTargetY = 0;
let validTargets = 0;
for (const target of chain.targets) {
const targetPos = positions.get(target.node);
if (targetPos) {
avgTargetX += targetPos.x;
avgTargetY += targetPos.y;
validTargets++;
}
}
if (validTargets === 0) continue;
avgTargetX /= validTargets;
avgTargetY /= validTargets;
// Source output position (right side of node)
const startX = sourcePos.x + sourceSize.width;
const startY = sourcePos.y + sourceSize.height / 2;
// Target input position
const endX = avgTargetX;
const endY = avgTargetY;
// Distribute reroutes evenly along the path
const count = chain.nodes.length;
for (let i = 0; i < count; i++) {
const t = (i + 1) / (count + 1);
const rerouteId = chain.nodes[i];
// Linear interpolation
const x = startX + (endX - startX) * t;
const y = startY + (endY - startY) * t;
// Center the reroute (typical reroute size: 90x26)
const rerouteSize = sizes.get(rerouteId) ?? { width: 90, height: 26 };
positions.set(rerouteId, {
x: x - rerouteSize.width / 2,
y: y - rerouteSize.height / 2,
});
}
}
}
/**
* Get all reroute node IDs from chains (for filtering from main layout)
*/
export function getRerouteNodeIds(chains: RerouteChain[]): Set<number> {
const ids = new Set<number>();
for (const chain of chains) {
for (const nodeId of chain.nodes) {
ids.add(nodeId);
}
}
return ids;
}
-739
View File
@@ -1,739 +0,0 @@
import type {
LGraph,
LGraphNode,
LGraphGroup,
LayoutConfig,
SelectedGroupLayoutResult,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
import { packNodesIntoRows } from "./bin-pack";
import { debugLog } from "../debug";
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
/** Title bar height for groups */
const GROUP_TITLE_HEIGHT = 50;
/**
* Update group bounds using multiple methods for compatibility
* Handles pos/size arrays, _pos/_size internal properties,
* _bounding Rectangle, and bounding array formats
*/
function updateGroupBounds(
group: LGraphGroup,
x: number,
y: number,
width: number,
height: number
): void {
// Method 1: Direct pos/size array mutation
if (Array.isArray(group.pos)) {
group.pos[0] = x;
group.pos[1] = y;
}
if (Array.isArray(group.size)) {
group.size[0] = width;
group.size[1] = height;
}
// Method 2: Try _pos/_size internal properties
const g = group as LGraphGroup & {
_pos?: [number, number];
_size?: [number, number];
};
if (Array.isArray(g._pos)) {
g._pos[0] = x;
g._pos[1] = y;
}
if (Array.isArray(g._size)) {
g._size[0] = width;
g._size[1] = height;
}
// Method 3: Update _bounding Rectangle if present
if (group._bounding) {
const b = group._bounding;
b.x = x;
b.y = y;
b.width = width;
b.height = height;
}
// Method 4: Try bounding array (older format / JSON serialization)
const gAny = group as LGraphGroup & { bounding?: number[] };
if (Array.isArray(gAny.bounding)) {
gAny.bounding[0] = x;
gAny.bounding[1] = y;
gAny.bounding[2] = width;
gAny.bounding[3] = height;
}
}
/**
* Translate group position by delta, handling all position formats
* Similar to updateGroupBounds but for relative position changes
*/
function translateGroupPosition(
group: LGraphGroup,
deltaX: number,
deltaY: number
): void {
// Method 1: Direct pos array mutation
if (Array.isArray(group.pos)) {
group.pos[0] += deltaX;
group.pos[1] += deltaY;
}
// Method 2: Try _pos internal property
const g = group as LGraphGroup & { _pos?: [number, number] };
if (Array.isArray(g._pos)) {
g._pos[0] += deltaX;
g._pos[1] += deltaY;
}
// Method 3: Update _bounding Rectangle if present
if (group._bounding) {
group._bounding.x += deltaX;
group._bounding.y += deltaY;
}
// Method 4: Try bounding array (older format / JSON serialization)
const gAny = group as LGraphGroup & { bounding?: number[] };
if (Array.isArray(gAny.bounding)) {
gAny.bounding[0] += deltaX;
gAny.bounding[1] += deltaY;
}
}
/**
* Resize a group to fit its member nodes
*/
function resizeGroupToFitMembers(
group: LGraphGroup,
memberIds: Set<number>,
allNodes: Map<number, LGraphNode>,
config: LayoutConfig
): void {
if (memberIds.size === 0) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const nodeId of memberIds) {
const node = allNodes.get(nodeId);
if (!node?.pos) continue;
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, node.pos[0]);
minY = Math.min(minY, node.pos[1]);
maxX = Math.max(maxX, node.pos[0] + nw);
maxY = Math.max(maxY, node.pos[1] + nh);
}
if (minX === Infinity) return;
// Update group position and size with padding (handles all formats)
const newX = minX - config.groupPadding;
const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
const newW = maxX - minX + config.groupPadding * 2;
const newH = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
updateGroupBounds(group, newX, newY, newW, newH);
}
/**
* Translate a group and its tracked members by a delta
*/
function translateGroupWithMembers(
group: LGraphGroup,
deltaX: number,
deltaY: number,
memberIds: Set<number>,
allNodes: Map<number, LGraphNode>,
allGroups: LGraphGroup[]
): void {
// Move the group itself (handles all formats: pos, _pos, _bounding, bounding)
translateGroupPosition(group, deltaX, deltaY);
// Translate tracked member nodes
for (const nodeId of memberIds) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] += deltaX;
node.pos[1] += deltaY;
}
}
// Find and translate nested groups inside this group
for (const nestedGroup of allGroups) {
if (nestedGroup === group) continue;
if (groupContainsGroup(group, nestedGroup)) {
translateGroupPosition(nestedGroup, deltaX, deltaY);
}
}
}
/**
* Check if outer group's bounds fully contain inner group's bounds
*/
function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
const ox = outer.pos[0];
const oy = outer.pos[1];
const ow = outer.size[0];
const oh = outer.size[1];
const ix = inner.pos[0];
const iy = inner.pos[1];
const iw = inner.size[0];
const ih = inner.size[1];
return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
}
/**
* Check if a node's origin is inside a group's content area
*/
function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
const gx = group.pos[0];
const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
const gw = group.size[0];
const gh = group.size[1] - GROUP_TITLE_HEIGHT;
const nx = node.pos?.[0] ?? 0;
const ny = node.pos?.[1] ?? 0;
return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
}
/**
* Collect all nested child groups within a parent group
*/
function collectNestedGroups(
parent: LGraphGroup,
allGroups: LGraphGroup[]
): LGraphGroup[] {
const nested: LGraphGroup[] = [];
const queue = [parent];
while (queue.length > 0) {
const current = queue.shift()!;
for (const group of allGroups) {
if (group === current) continue;
if (groupContainsGroup(current, group) && !nested.includes(group)) {
nested.push(group);
queue.push(group);
}
}
}
return nested;
}
/**
* Collect all member nodes within a group (excluding nodes in nested child groups)
*/
function collectDirectMembers(
group: LGraphGroup,
allNodes: Map<number, LGraphNode>,
nestedGroups: LGraphGroup[]
): Set<number> {
const members = new Set<number>();
for (const [nodeId, node] of allNodes) {
if (!nodeInsideGroup(node, group)) continue;
// Check if node is inside a nested group (exclude it from direct members)
let inNestedGroup = false;
for (const nested of nestedGroups) {
if (nodeInsideGroup(node, nested)) {
inNestedGroup = true;
break;
}
}
if (!inNestedGroup) {
members.add(nodeId);
}
}
return members;
}
/**
* Layout contents of a single group and resize it to fit
* Returns the bounding box of the group's contents
*/
function layoutGroupContents(
group: LGraphGroup,
memberIds: Set<number>,
nestedGroups: LGraphGroup[],
allNodes: Map<number, LGraphNode>,
config: LayoutConfig,
processedGroups: Set<LGraphGroup>
): { minX: number; minY: number; maxX: number; maxY: number } | null {
// Skip if already processed (handles nested groups selected individually)
if (processedGroups.has(group)) {
return null;
}
processedGroups.add(group);
// Collect non-locked members
const members: LGraphNode[] = [];
for (const nodeId of memberIds) {
const node = allNodes.get(nodeId);
if (!node) continue;
if (node.flags?.pinned || node.locked) continue;
members.push(node);
}
// Handle empty group with no nested groups
if (members.length === 0 && nestedGroups.length === 0) {
debugLog(`Skipping empty group "${group.title}"`);
return null;
}
// Parse layout token from group title
const layoutMode = parseLayoutToken(group.title);
debugLog(
`layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}, mode=${layoutMode.type}`
);
// Get the group's current content area start position
const startX = group.pos[0] + config.groupPadding;
const startY = group.pos[1] + config.groupPadding + GROUP_TITLE_HEIGHT;
// Track bounds of all positioned content
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let contentEndY = startY;
// If layout mode is specified, use token-based layout (ignores internal edges)
// This handles both direct member nodes AND nested groups as top-level items
if (layoutMode.type !== "default" && (members.length > 0 || nestedGroups.length > 0)) {
// First, recursively process nested groups (bottom-up) so their sizes are final
// Sort by containment depth (deepest first)
const sortedNested = [...nestedGroups].sort((a, b) => {
let aDepth = 0,
bDepth = 0;
for (const other of nestedGroups) {
if (other !== a && groupContainsGroup(other, a)) aDepth++;
if (other !== b && groupContainsGroup(other, b)) bDepth++;
}
return bDepth - aDepth; // Deepest first
});
// Track member IDs for each nested group (needed for translation later)
const nestedGroupMembers = new Map<number, Set<number>>();
for (const nested of sortedNested) {
if (processedGroups.has(nested)) continue;
const nestedMemberIds = new Set<number>();
for (const [nodeId, node] of allNodes) {
if (nodeInsideGroup(node, nested)) {
// Check not in deeper nested group
let inDeeperGroup = false;
for (const other of sortedNested) {
if (other !== nested && groupContainsGroup(nested, other) && nodeInsideGroup(node, other)) {
inDeeperGroup = true;
break;
}
}
if (!inDeeperGroup) {
nestedMemberIds.add(nodeId);
}
}
}
nestedGroupMembers.set(nested.id, nestedMemberIds);
const deeperNested = sortedNested.filter(
(g) => g !== nested && groupContainsGroup(nested, g)
);
layoutGroupContents(
nested,
nestedMemberIds,
deeperNested,
allNodes,
config,
processedGroups
);
// Resize nested group to fit its (possibly rearranged) contents
resizeGroupToFitMembers(nested, nestedMemberIds, allNodes, config);
}
// Now create combined items list: direct member nodes + direct nested groups
// Direct nested groups are those not contained by other nested groups
const directNestedGroups = nestedGroups.filter((g) => {
for (const other of nestedGroups) {
if (other !== g && groupContainsGroup(other, g)) return false;
}
return true;
});
// Create fake LGraphNode-like objects for nested groups so we can use arrangeByMode
const groupItems: LGraphNode[] = directNestedGroups.map((g) => ({
id: -g.id, // Negative ID to distinguish from real nodes
type: "__group__",
title: g.title,
pos: [g.pos[0], g.pos[1]] as [number, number],
size: [g.size[0], g.size[1]] as [number, number],
inputs: [],
outputs: [],
}));
// Combine members and group items
const allItems = [...members, ...groupItems];
if (allItems.length > 0) {
const arranged = arrangeByMode(allItems, layoutMode, startX, startY, config);
for (const pos of arranged) {
if (pos.id < 0) {
// This is a nested group (negative ID)
const groupId = -pos.id;
const nestedGroup = directNestedGroups.find((g) => g.id === groupId);
if (nestedGroup) {
const oldX = nestedGroup.pos[0];
const oldY = nestedGroup.pos[1];
const deltaX = pos.x - oldX;
const deltaY = pos.y - oldY;
// Use tracked members for accurate translation after token layout
const trackedMembers = nestedGroupMembers.get(groupId) ?? new Set();
translateGroupWithMembers(nestedGroup, deltaX, deltaY, trackedMembers, allNodes, nestedGroups);
debugLog(` Nested group ${groupId} (${nestedGroup.title}): token layout [${pos.x}, ${pos.y}]`);
const gw = nestedGroup.size[0];
const gh = nestedGroup.size[1];
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + gw);
maxY = Math.max(maxY, pos.y + gh);
contentEndY = Math.max(contentEndY, pos.y + gh);
}
} else {
// This is a regular node
const node = allNodes.get(pos.id);
if (node?.pos) {
node.pos[0] = pos.x;
node.pos[1] = pos.y;
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + nw);
maxY = Math.max(maxY, pos.y + nh);
contentEndY = Math.max(contentEndY, pos.y + nh);
}
}
}
}
} else if (members.length > 0) {
// Default behavior: check internal edges for layer-based or vertical stack layout
const edges = getInternalEdges(memberIds, allNodes);
if (edges.length > 0) {
// Has internal connections - use layer-based layout
const layers = assignMemberLayers(memberIds, edges, allNodes);
let x = startX;
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
const layer = layers[layerIdx];
if (layer.length === 0) continue;
// Sort layer by height (tallest first for stable layout)
layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
let y = startY;
let layerMaxWidth = 0;
for (const node of layer) {
if (node.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(
` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`
);
}
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + nw);
maxY = Math.max(maxY, y + nh);
layerMaxWidth = Math.max(layerMaxWidth, nw);
y += nh + config.verticalGap;
}
contentEndY = Math.max(contentEndY, y - config.verticalGap);
x += layerMaxWidth + config.horizontalGap;
}
} else {
// No internal connections - use vertical stacking
members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
// Build sizes map
const sizes = new Map<number, { width: number; height: number }>();
for (const node of members) {
sizes.set(node.id, {
width: node.size?.[0] ?? 200,
height: node.size?.[1] ?? 100,
});
}
// Pack into rows (vertical stack with maxColumns=1)
const nodeIds = members.map((n) => n.id);
const rows = packNodesIntoRows(nodeIds, sizes, {
...config,
maxColumns: 1,
});
for (const row of rows) {
const x = startX;
const y = startY + row.yOffset;
for (const nodeId of row.nodes) {
const node = allNodes.get(nodeId);
if (node?.pos) {
node.pos[0] = x;
node.pos[1] = y;
debugLog(` Member ${node.id} (${node.type}): stacked pos [${x}, ${y}]`);
const nw = node.size?.[0] ?? 200;
const nh = node.size?.[1] ?? 100;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + nw);
maxY = Math.max(maxY, y + nh);
}
}
contentEndY = Math.max(contentEndY, startY + row.yOffset + row.height);
}
}
}
// Process nested groups (bottom-up: deepest first)
// In default mode, reposition direct nested groups below member content
const sortedNested = [...nestedGroups].sort((a, b) => {
// Count how many other nested groups contain each
let depthA = 0;
let depthB = 0;
for (const other of nestedGroups) {
if (other !== a && groupContainsGroup(other, a)) depthA++;
if (other !== b && groupContainsGroup(other, b)) depthB++;
}
return depthB - depthA; // Deepest first
});
// Identify direct nested groups (not contained by other nested groups)
const directNestedGroups = sortedNested.filter((g) => {
for (const other of nestedGroups) {
if (other !== g && groupContainsGroup(other, g)) return false;
}
return true;
});
// Sort direct groups by original position for stable ordering
directNestedGroups.sort((a, b) => {
const yDiff = a.pos[1] - b.pos[1];
return yDiff !== 0 ? yDiff : a.pos[0] - b.pos[0];
});
for (const nestedGroup of directNestedGroups) {
// Skip if already processed (by token mode)
if (processedGroups.has(nestedGroup)) {
// Still include bounds
minX = Math.min(minX, nestedGroup.pos[0]);
minY = Math.min(minY, nestedGroup.pos[1]);
maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]);
maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]);
continue;
}
// Get nested group's members and children
const nestedChildren = collectNestedGroups(nestedGroup, nestedGroups);
const nestedMembers = collectDirectMembers(nestedGroup, allNodes, nestedChildren);
// In default mode, reposition nested group below current content
if (layoutMode.type === "default") {
// Position nested group's left edge at startX (same as members) for consistent alignment
// This prevents the group from shifting leftward on each layout run
const targetX = startX;
const targetY = contentEndY + config.verticalGap;
const deltaX = targetX - nestedGroup.pos[0];
const deltaY = targetY - nestedGroup.pos[1];
if (deltaX !== 0 || deltaY !== 0) {
translateGroupWithMembers(nestedGroup, deltaX, deltaY, nestedMembers, allNodes, nestedGroups);
debugLog(` Repositioned nested group "${nestedGroup.title}" to [${targetX}, ${targetY}]`);
}
}
// Recursively layout nested group contents
layoutGroupContents(
nestedGroup,
nestedMembers,
nestedChildren,
allNodes,
config,
processedGroups
);
// Resize nested group to fit its organized contents
resizeGroupToFitMembers(nestedGroup, nestedMembers, allNodes, config);
// Update contentEndY for next nested group
contentEndY = Math.max(contentEndY, nestedGroup.pos[1] + nestedGroup.size[1]);
// Update bounds to include nested group
minX = Math.min(minX, nestedGroup.pos[0]);
minY = Math.min(minY, nestedGroup.pos[1]);
maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]);
maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]);
}
// Resize group to fit contents
if (minX !== Infinity) {
const newX = minX - config.groupPadding;
const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
const newWidth = maxX - minX + config.groupPadding * 2;
const newHeight = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
debugLog(
`Resizing group "${group.title}" to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
);
updateGroupBounds(group, newX, newY, newWidth, newHeight);
return { minX, minY, maxX, maxY };
}
return null;
}
/**
* Layout only the contents of selected groups
* Ignores external connections - uses only internal edges
* Auto-includes nested groups when parent is selected
*
* @param graph - The full graph (for node/link access)
* @param selectedGroupIds - Set of group IDs to organize
* @param config - Layout configuration
*/
export function layoutSelectedGroups(
graph: LGraph,
selectedGroupIds: Set<number>,
config?: Partial<LayoutConfig>
): SelectedGroupLayoutResult {
const fullConfig = { ...DEFAULT_CONFIG, ...config };
const startTime = performance.now();
// Build allNodes map
const allNodes = new Map<number, LGraphNode>();
for (const node of graph._nodes ?? []) {
if (!node || node.id === undefined) continue;
allNodes.set(node.id, node);
}
// Get all groups
const allGroups = graph._groups ?? [];
// Find selected groups
const selectedGroups: LGraphGroup[] = [];
for (const group of allGroups) {
if (selectedGroupIds.has(group.id)) {
selectedGroups.push(group);
}
}
if (selectedGroups.length === 0) {
debugLog("No selected groups found");
return {
nodeCount: 0,
groupCount: 0,
executionMs: performance.now() - startTime,
};
}
// Filter to only top-level selected groups
// (nested groups selected individually will be handled, but if both parent and child
// are selected, we only process from the parent)
const topLevelSelected: LGraphGroup[] = [];
for (const group of selectedGroups) {
let hasSelectedParent = false;
for (const other of selectedGroups) {
if (other !== group && groupContainsGroup(other, group)) {
hasSelectedParent = true;
break;
}
}
if (!hasSelectedParent) {
topLevelSelected.push(group);
}
}
// Track processed groups to avoid double-processing
const processedGroups = new Set<LGraphGroup>();
let totalNodes = 0;
// Process each top-level selected group
for (const group of topLevelSelected) {
// Collect nested groups within this group
const nestedGroups = collectNestedGroups(group, allGroups);
// Collect direct members (excluding those in nested groups)
const memberIds = collectDirectMembers(group, allNodes, nestedGroups);
debugLog(
`Processing group "${group.title}": ${memberIds.size} direct members, ${nestedGroups.length} nested groups`
);
// Layout group contents
layoutGroupContents(
group,
memberIds,
nestedGroups,
allNodes,
fullConfig,
processedGroups
);
totalNodes += memberIds.size;
// Count nested group members too
for (const nested of nestedGroups) {
const nestedChildren = collectNestedGroups(nested, nestedGroups);
const nestedMembers = collectDirectMembers(nested, allNodes, nestedChildren);
totalNodes += nestedMembers.size;
}
}
// Trigger canvas redraw
graph.setDirtyCanvas?.(true, true);
const result: SelectedGroupLayoutResult = {
nodeCount: totalNodes,
groupCount: processedGroups.size,
executionMs: performance.now() - startTime,
};
debugLog(
`layoutSelectedGroups complete: ${result.nodeCount} nodes, ${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
);
return result;
}
-278
View File
@@ -1,278 +0,0 @@
import type { LGraphNode, LayoutConfig } from "./types";
/**
* Layout mode parsed from group title tokens
*/
export type LayoutMode =
| { type: "default" }
| { type: "horizontal" }
| { type: "vertical" }
| { type: "rows"; count: number }
| { type: "columns"; count: number };
/**
* Parse layout token from group title (case-insensitive)
* Supports: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL]
*/
export function parseLayoutToken(title: string): LayoutMode {
const upper = title.toUpperCase();
// Check for [HORIZONTAL]
if (upper.includes("[HORIZONTAL]")) {
return { type: "horizontal" };
}
// Check for [VERTICAL]
if (upper.includes("[VERTICAL]")) {
return { type: "vertical" };
}
// Check for [xROW] pattern (1-9)
const rowMatch = upper.match(/\[([1-9])ROW\]/);
if (rowMatch) {
const count = parseInt(rowMatch[1], 10);
// 1ROW is same as horizontal
if (count === 1) {
return { type: "horizontal" };
}
return { type: "rows", count };
}
// Check for [xCOL] pattern (1-9)
const colMatch = upper.match(/\[([1-9])COL\]/);
if (colMatch) {
const count = parseInt(colMatch[1], 10);
// 1COL is same as vertical
if (count === 1) {
return { type: "vertical" };
}
return { type: "columns", count };
}
return { type: "default" };
}
/**
* Node info for layout calculations
*/
interface NodeInfo {
id: number;
width: number;
height: number;
originalX: number;
originalY: number;
}
/**
* Result of arranging nodes by mode
*/
export interface ArrangedNode {
id: number;
x: number;
y: number;
}
/**
* Sort nodes by original position (left-to-right, top-to-bottom)
*/
function sortByOriginalPosition(nodes: NodeInfo[]): NodeInfo[] {
return [...nodes].sort((a, b) => {
// Primary: sort by X (left to right)
if (a.originalX !== b.originalX) {
return a.originalX - b.originalX;
}
// Secondary: sort by Y (top to bottom)
return a.originalY - b.originalY;
});
}
/**
* Arrange nodes in a single horizontal row
*/
function arrangeHorizontal(
nodes: NodeInfo[],
startX: number,
startY: number,
gap: number
): ArrangedNode[] {
// Sort by original position (left to right)
const sorted = sortByOriginalPosition(nodes);
const result: ArrangedNode[] = [];
let x = startX;
for (const node of sorted) {
result.push({ id: node.id, x, y: startY });
x += node.width + gap;
}
return result;
}
/**
* Arrange nodes in a single vertical column
*/
function arrangeVertical(
nodes: NodeInfo[],
startX: number,
startY: number,
gap: number
): ArrangedNode[] {
// Sort by original position (top to bottom, then left to right)
const sorted = [...nodes].sort((a, b) => {
if (a.originalY !== b.originalY) {
return a.originalY - b.originalY;
}
return a.originalX - b.originalX;
});
const result: ArrangedNode[] = [];
let y = startY;
for (const node of sorted) {
result.push({ id: node.id, x: startX, y });
y += node.height + gap;
}
return result;
}
/**
* Arrange nodes into N rows (round-robin distribution)
* Each row is horizontal, rows stacked vertically
*/
function arrangeRows(
nodes: NodeInfo[],
rowCount: number,
startX: number,
startY: number,
horizontalGap: number,
verticalGap: number
): ArrangedNode[] {
// Sort by original position first
const sorted = sortByOriginalPosition(nodes);
// Distribute round-robin into rows
const rows: NodeInfo[][] = Array.from({ length: rowCount }, () => []);
for (let i = 0; i < sorted.length; i++) {
rows[i % rowCount].push(sorted[i]);
}
const result: ArrangedNode[] = [];
let y = startY;
for (const row of rows) {
if (row.length === 0) continue;
let x = startX;
let rowMaxHeight = 0;
for (const node of row) {
result.push({ id: node.id, x, y });
x += node.width + horizontalGap;
rowMaxHeight = Math.max(rowMaxHeight, node.height);
}
y += rowMaxHeight + verticalGap;
}
return result;
}
/**
* Arrange nodes into N columns (round-robin distribution)
* Each column is vertical, columns side-by-side
*/
function arrangeColumns(
nodes: NodeInfo[],
colCount: number,
startX: number,
startY: number,
horizontalGap: number,
verticalGap: number
): ArrangedNode[] {
// Sort by original position first
const sorted = sortByOriginalPosition(nodes);
// Distribute round-robin into columns
const cols: NodeInfo[][] = Array.from({ length: colCount }, () => []);
for (let i = 0; i < sorted.length; i++) {
cols[i % colCount].push(sorted[i]);
}
const result: ArrangedNode[] = [];
let x = startX;
for (const col of cols) {
if (col.length === 0) continue;
let y = startY;
let colMaxWidth = 0;
for (const node of col) {
result.push({ id: node.id, x, y });
y += node.height + verticalGap;
colMaxWidth = Math.max(colMaxWidth, node.width);
}
x += colMaxWidth + horizontalGap;
}
return result;
}
/**
* Arrange nodes according to layout mode
*/
export function arrangeByMode(
members: LGraphNode[],
mode: LayoutMode,
startX: number,
startY: number,
config: LayoutConfig
): ArrangedNode[] {
// Build node info list with original positions
const nodes: NodeInfo[] = members.map((n) => ({
id: n.id,
width: n.size?.[0] ?? 200,
height: n.size?.[1] ?? 100,
originalX: n.pos?.[0] ?? 0,
originalY: n.pos?.[1] ?? 0,
}));
if (nodes.length === 0) {
return [];
}
switch (mode.type) {
case "horizontal":
return arrangeHorizontal(nodes, startX, startY, config.horizontalGap);
case "vertical":
return arrangeVertical(nodes, startX, startY, config.verticalGap);
case "rows":
return arrangeRows(
nodes,
mode.count,
startX,
startY,
config.horizontalGap,
config.verticalGap
);
case "columns":
return arrangeColumns(
nodes,
mode.count,
startX,
startY,
config.horizontalGap,
config.verticalGap
);
default:
// Default mode - caller should use existing behavior
return [];
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Token parser and algorithm factory.
*
* Parses group titles for layout tokens like [HORIZONTAL], [2ROW], [3COL]
* and creates the corresponding LayoutAlgorithm instances.
*/
import type { LayoutToken, LayoutAlgorithm } from "./types";
import { createHorizontalAlgorithm } from "./algorithms/horizontal";
import { createVerticalAlgorithm } from "./algorithms/vertical";
import { createGridAlgorithm } from "./algorithms/grid";
/**
* Regex to match layout tokens in group titles.
* Matches: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL]
* Case-insensitive.
*/
const TOKEN_REGEX = /\[(HORIZONTAL|VERTICAL|([1-9])ROW|([1-9])COL)\]/i;
/**
* Parse a group title for a layout token.
* Returns null if no valid token is found.
*
* @example
* parseLayoutToken("My Group [HORIZONTAL]") // { mode: 'horizontal' }
* parseLayoutToken("[2ROW]") // { mode: 'grid', count: 2, dimension: 'row' }
* parseLayoutToken("No token") // null
*/
export function parseLayoutToken(title: string): LayoutToken | null {
const match = TOKEN_REGEX.exec(title);
if (!match) return null;
const fullMatch = match[1].toUpperCase();
if (fullMatch === "HORIZONTAL") {
return { mode: "horizontal" };
}
if (fullMatch === "VERTICAL") {
return { mode: "vertical" };
}
// [N]ROW pattern
if (match[2] !== undefined) {
const count = parseInt(match[2], 10);
if (count === 1) {
return { mode: "horizontal" };
}
return { mode: "grid", count, dimension: "row" };
}
// [N]COL pattern
if (match[3] !== undefined) {
const count = parseInt(match[3], 10);
if (count === 1) {
return { mode: "vertical" };
}
return { mode: "grid", count, dimension: "col" };
}
return null;
}
/**
* Create a LayoutAlgorithm from a LayoutToken.
*
* @param token - The parsed layout token
* @param gap - Gap between nodes (default: 40)
*/
export function tokenToAlgorithm(token: LayoutToken, gap?: number): LayoutAlgorithm {
switch (token.mode) {
case "horizontal":
return createHorizontalAlgorithm(gap);
case "vertical":
return createVerticalAlgorithm(gap);
case "grid": {
if (token.dimension === "row") {
return createGridAlgorithm({ rows: token.count, gap });
}
return createGridAlgorithm({ columns: token.count, gap });
}
}
}
+70 -254
View File
@@ -1,274 +1,90 @@
/**
* Minimal type definitions for ComfyUI/LiteGraph
* These match the runtime global objects, not the package exports
* Pure layout types — no ComfyUI/LGraph dependencies.
* These are the abstract types for the v2 layout framework.
*/
/** Point as [x, y] */
export type Point = [x: number, y: number];
/** Size as [width, height] */
export type Size = [width: number, height: number];
/** Node input slot */
export interface NodeInputSlot {
name: string;
type: string;
link: number | null;
/** Position as a simple object */
export interface Position {
readonly x: number;
readonly y: number;
}
/** Node output slot */
export interface NodeOutputSlot {
name: string;
type: string;
links: number[] | null;
}
/** Node flags */
export interface NodeFlags {
pinned?: boolean;
collapsed?: boolean;
}
/** LiteGraph Node */
export interface LGraphNode {
id: number;
type: string;
title: string;
pos: Point;
size: Size;
inputs: NodeInputSlot[];
outputs: NodeOutputSlot[];
flags?: NodeFlags;
locked?: boolean;
order?: number;
}
/** LiteGraph Group */
export interface LGraphGroup {
id: number;
title: string;
pos: Point;
size: Size;
_nodes?: LGraphNode[];
_children?: Set<{ id: number }>;
_bounding?: {
x: number;
y: number;
width: number;
height: number;
set?: (x: number, y: number, w: number, h: number) => void;
};
resizeTo?: (items: Iterable<LGraphNode>, padding?: number) => void;
}
/** LiteGraph Link */
export interface LLink {
id: number;
origin_id: number;
origin_slot: number;
target_id: number;
target_slot: number;
type: string;
}
/** Subgraph I/O node (runtime format with pos/size like regular nodes) */
export interface SubgraphIONode {
id: number;
pos: [number, number];
size: [number, number];
}
/** LiteGraph Graph (or Subgraph with I/O nodes) */
export interface LGraph {
_nodes: LGraphNode[];
_groups: LGraphGroup[];
/** Links can be Map, Record, or Array depending on context */
links: Map<number, LLink> | Record<number, LLink> | LLink[];
setDirtyCanvas?: (fg: boolean, bg: boolean) => void;
/** Subgraph input node (only on Subgraph, not root LGraph) */
inputNode?: SubgraphIONode;
/** Subgraph output node (only on Subgraph, not root LGraph) */
outputNode?: SubgraphIONode;
}
/** Layout node with computed metadata */
/** A node to be laid out — just an ID and dimensions */
export interface LayoutNode {
/** Reference to ComfyUI node */
node: LGraphNode;
/** Node ID */
id: number;
/** DAG depth (0 = sources/inputs) */
layer: number;
/** Vertical position within layer */
orderInLayer: number;
/** Node IDs this depends on (predecessors) */
predecessors: number[];
/** Node IDs that depend on this (successors) */
successors: number[];
/** Node width (or group width if representative) */
width: number;
/** Node height (or group height if representative) */
height: number;
/** Computed X coordinate */
x: number;
/** Computed Y coordinate */
y: number;
/** True if this node represents a group in the layout */
isGroupRepresentative?: boolean;
/** The group this node represents (if isGroupRepresentative) */
group?: LayoutGroup;
/** True if this is a subgraph I/O node (input or output boundary) */
isSubgraphIO?: boolean;
/** Reference to runtime I/O node for position updates (has pos/size) */
runtimeIONode?: { id: number; pos: [number, number]; size: [number, number] };
readonly id: string;
readonly width: number;
readonly height: number;
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
readonly layerConstraint?: "first" | "last";
}
/** Layer = vertical column of nodes */
export interface Layer {
/** Layer index (0 = leftmost) */
index: number;
/** Nodes in this layer */
nodes: LayoutNode[];
/** Computed X position for this layer */
x: number;
/** Max node width in this layer */
maxWidth: number;
/** A directed edge between nodes */
export interface LayoutEdge {
readonly source: string;
readonly target: string;
}
/** Layout group tracking with hierarchy support */
/** Token parsed from group title like [HORIZONTAL], [2ROW], etc. */
export interface LayoutToken {
readonly mode: "horizontal" | "vertical" | "grid";
readonly count?: number;
readonly dimension?: "row" | "col";
}
/** A group of nodes, possibly with nested child groups */
export interface LayoutGroup {
/** Reference to ComfyUI group */
group: LGraphGroup;
/** Node IDs directly contained (not in child groups) */
memberIds: Set<number>;
/** Child groups (nested inside this group) */
childGroups: LayoutGroup[];
/** Parent group (group containing this one), null for top-level */
parentGroup: LayoutGroup | null;
/** Nesting depth (0 = top-level) */
depth: number;
/** Calculated width for the group */
width: number;
/** Calculated height for the group */
height: number;
readonly id: string;
readonly title: string;
readonly memberIds: ReadonlyArray<string>;
readonly childGroupIds: ReadonlyArray<string>;
readonly token?: LayoutToken;
}
/** Layout configuration */
export interface LayoutConfig {
/** Horizontal gap between layers */
horizontalGap: number;
/** Vertical gap between nodes in same layer */
verticalGap: number;
/** Padding inside groups */
groupPadding: number;
/** Layout origin X */
startX: number;
/** Layout origin Y */
startY: number;
/** Max barycenter iterations */
maxIterations: number;
/** Max columns per layer (0=auto, 1=disabled/vertical stack, 2+=fixed) */
maxColumns: number;
/** Max row width before new row when maxColumns=0 */
maxRowWidth: number;
/** Collapse reroute chains into edge waypoints */
collapseReroutes: boolean;
/** Gap between disconnected zone and DAG */
disconnectedGap: number;
/** Input to a layout algorithm — one level of the hierarchy */
export interface LayoutInput {
readonly nodes: ReadonlyArray<LayoutNode>;
readonly edges: ReadonlyArray<LayoutEdge>;
}
/** Default configuration */
export const DEFAULT_CONFIG: LayoutConfig = {
/** Output from a layout algorithm — position map */
export interface LayoutOutput {
readonly positions: ReadonlyMap<string, Position>;
}
/** The pluggable algorithm interface */
export interface LayoutAlgorithm {
readonly name: string;
layout(input: LayoutInput): LayoutOutput;
}
/** Group bounds after layout (position + size) */
export interface GroupBounds {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/** Full result from the framework (after group recursion) */
export interface FrameworkResult {
readonly positions: ReadonlyMap<string, Position>;
readonly groupBounds: ReadonlyMap<string, GroupBounds>;
}
/** Configuration for the framework */
export interface FrameworkConfig {
readonly horizontalGap: number;
readonly verticalGap: number;
readonly groupPadding: number;
readonly groupTitleHeight: number;
readonly disconnectedGap: number;
}
/** Default config */
export const DEFAULT_FRAMEWORK_CONFIG: FrameworkConfig = {
horizontalGap: 100,
verticalGap: 40,
groupPadding: 30,
startX: 100,
startY: 100,
maxIterations: 24,
maxColumns: 0,
maxRowWidth: 800,
collapseReroutes: true,
groupTitleHeight: 46,
disconnectedGap: 150,
};
/** Node classification result */
export interface ClassifiedNodes {
/** Nodes with connections (participate in DAG) */
connected: Set<number>;
/** Nodes without any connections */
disconnected: Set<number>;
/** Reroute nodes (for special handling) */
reroutes: Set<number>;
}
/** Reroute chain for collapse/restore */
export interface RerouteChain {
/** Reroute node IDs in order */
nodes: number[];
/** Source node before chain */
sourceNode: number;
/** Source slot index */
sourceSlot: number;
/** Target nodes after chain */
targets: Array<{ node: number; slot: number }>;
}
/** Packed row within a layer (for bin packing) */
export interface PackedRow {
/** Node IDs in this row */
nodes: number[];
/** Max node height in row */
height: number;
/** Vertical offset from layer start */
yOffset: number;
/** Total width of nodes + gaps in row */
width: number;
}
/** Graph layout state */
export interface LayoutState {
/** All layout nodes by ID (includes group representatives) */
nodes: Map<number, LayoutNode>;
/** Layers of nodes */
layers: Layer[];
/** Groups */
groups: LayoutGroup[];
/** Config */
config: LayoutConfig;
/** Maps node ID to its group (if any) */
nodeToGroup?: Map<number, LayoutGroup>;
/** Maps group to its representative node ID */
groupRepresentatives?: Map<LayoutGroup, number>;
/** All original ComfyUI nodes by ID */
allNodes?: Map<number, LGraphNode>;
/** Groups organized by depth (index 0 = deepest) for bottom-up processing */
groupsByDepth?: LayoutGroup[][];
/** Disconnected node IDs (placed in separate zone) */
disconnectedNodes?: Set<number>;
/** Collapsed reroute chains (restored after layout) */
rerouteChains?: RerouteChain[];
/** Packed rows per layer (for bin-packed layout) */
packedRows?: Map<number, PackedRow[]>;
}
/** Layout result */
export interface LayoutResult {
/** Number of nodes processed */
nodeCount: number;
/** Number of layers */
layerCount: number;
/** Number of groups resized */
groupCount: number;
/** Execution time in ms */
executionMs: number;
}
/** Result from selected group layout */
export interface SelectedGroupLayoutResult {
/** Number of nodes organized */
nodeCount: number;
/** Number of groups organized */
groupCount: number;
/** Execution time in ms */
executionMs: number;
}
+32
View File
@@ -0,0 +1,32 @@
import { isGroup, type CanvasItem } from "./utils";
export interface CanvasLike<TGraph = unknown> {
readonly graph: TGraph | null;
readonly getCurrentGraph?: () => TGraph | null;
readonly selectedItems?: Iterable<CanvasItem>;
}
export interface AppLike<TGraph = unknown> {
readonly canvas?: CanvasLike<TGraph> | null;
readonly graph?: TGraph | null;
}
export function getCurrentGraph<TGraph>(app: AppLike<TGraph>): TGraph | null {
const canvas = app.canvas;
if (!canvas) return null;
return canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph ?? null;
}
export function getSelectedGroups(
canvas: CanvasLike | null | undefined,
): Array<{ id: number; title: string }> {
if (!canvas?.selectedItems) return [];
const groups: Array<{ id: number; title: string }> = [];
for (const item of canvas.selectedItems) {
if (isGroup(item)) {
groups.push({ id: item.id as number, title: item.title });
}
}
return groups;
}
+26
View File
@@ -0,0 +1,26 @@
export const EXTENSION_NAME = "comfy-node-organizer";
export const SETTINGS_PREFIX = "Node Organizer";
export const REPOSITORY_URL =
"https://github.com/PBandDev/comfyui-node-organizer";
export const CURRENT_VERSION = "1.3.0";
export const DEFAULT_ALGORITHM_OPTIONS = [
"sugiyama",
"horizontal",
"vertical",
] as const;
export type DefaultAlgorithmName =
(typeof DEFAULT_ALGORITHM_OPTIONS)[number];
export const SETTING_IDS = {
VERSION: `${SETTINGS_PREFIX}.About`,
KEYBINDINGS: `${SETTINGS_PREFIX}.Keybindings`,
DEFAULT_ALGORITHM: `${SETTINGS_PREFIX}.Default Algorithm`,
HORIZONTAL_GAP: `${SETTINGS_PREFIX}.Horizontal Gap`,
VERTICAL_GAP: `${SETTINGS_PREFIX}.Vertical Gap`,
GROUP_PADDING: `${SETTINGS_PREFIX}.Group Padding`,
DISCONNECTED_GAP: `${SETTINGS_PREFIX}.Disconnected Gap`,
FIT_TO_VIEW: `${SETTINGS_PREFIX}.Fit to View`,
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
} as const;
+25
View File
@@ -0,0 +1,25 @@
export interface CanvasItem {
readonly id: number | string;
readonly pos: ArrayLike<number>;
readonly size?: ArrayLike<number>;
readonly title?: string;
readonly type?: string | null;
readonly constructor?: { readonly name?: string };
selected?: boolean;
}
export interface GroupItem extends CanvasItem {
readonly title: string;
}
export function isGroup(item: CanvasItem): item is GroupItem {
if (typeof item.title !== "string") {
return false;
}
if (item.constructor?.name === "LGraphGroup") {
return true;
}
return typeof item.type !== "string";
}
+22
View File
@@ -0,0 +1,22 @@
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const fixturesRoot = resolve(
fileURLToPath(new URL("../fixtures/", import.meta.url)),
);
export function loadWorkflowData(path: string): Record<string, unknown> {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
}
export function listRepoFixtures(): string[] {
return readdirSync(fixturesRoot)
.filter((entry) => entry.endsWith(".json"))
.map((entry) => entry.replace(/\.json$/, ""))
.sort((a, b) => a.localeCompare(b));
}
export function loadFixture(name: string): Record<string, unknown> {
return loadWorkflowData(resolve(fixturesRoot, `${name}.json`));
}
+106
View File
@@ -0,0 +1,106 @@
import { spawn } from "node:child_process";
import { writeFileSync, existsSync } from "node:fs";
import { resolve, join } from "node:path";
import { e2eConfig } from "../../e2e.config.ts";
const isWindows = process.platform === "win32";
const projectRoot = resolve(import.meta.dirname, "..", "..");
const venvDir = resolve(projectRoot, e2eConfig.venvDir);
const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir);
const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid");
const comfyBin = isWindows
? join(venvDir, "Scripts", "comfy.exe")
: join(venvDir, "bin", "comfy");
async function isComfyRunning(): Promise<boolean> {
try {
const response = await fetch(e2eConfig.comfyUrl);
return response.ok;
} catch {
return false;
}
}
async function waitForComfy(timeoutMs: number): Promise<void> {
const start = Date.now();
const pollInterval = 2000;
while (Date.now() - start < timeoutMs) {
if (await isComfyRunning()) {
return;
}
await new Promise((r) => setTimeout(r, pollInterval));
}
throw new Error(
`ComfyUI did not start within ${timeoutMs / 1000}s on ${e2eConfig.comfyUrl}`
);
}
export default async function globalSetup(): Promise<void> {
// Check if ComfyUI is already running
if (await isComfyRunning()) {
console.log(
`[e2e] ComfyUI already running on ${e2eConfig.comfyUrl}, reusing.`
);
// Write PID file with "external" marker so teardown knows not to kill it
writeFileSync(pidFile, "external", "utf-8");
return;
}
// Verify comfy binary exists
if (!existsSync(comfyBin)) {
throw new Error(
`comfy-cli not found at ${comfyBin}. Run \`pnpm setup:e2e\` first.`
);
}
console.log(`[e2e] Starting ComfyUI on port ${e2eConfig.port}...`);
const child = spawn(
comfyBin,
[
"--skip-prompt",
"--workspace",
comfyInstallDir,
"launch",
"--",
"--cpu",
"--port",
String(e2eConfig.port),
],
{
stdio: "pipe",
detached: !isWindows,
}
);
// Log stdout/stderr for debugging
child.stdout.on("data", (data: Buffer) => {
process.stdout.write(`[comfy] ${data.toString()}`);
});
child.stderr.on("data", (data: Buffer) => {
process.stderr.write(`[comfy] ${data.toString()}`);
});
child.on("error", (err) => {
console.error(`[e2e] Failed to start ComfyUI: ${err.message}`);
});
if (child.pid === undefined) {
throw new Error("Failed to spawn ComfyUI process — no PID assigned.");
}
// Store PID for teardown
writeFileSync(pidFile, String(child.pid), "utf-8");
console.log(`[e2e] ComfyUI spawned with PID ${child.pid}`);
// Detach so the process survives if this script exits
child.unref();
// Wait for ComfyUI to be ready
await waitForComfy(e2eConfig.timeouts.comfyStartup);
console.log(`[e2e] ComfyUI is ready on ${e2eConfig.comfyUrl}`);
}
+54
View File
@@ -0,0 +1,54 @@
import { execSync } from "node:child_process";
import { readFileSync, unlinkSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { e2eConfig } from "../../e2e.config.ts";
const isWindows = process.platform === "win32";
const projectRoot = resolve(import.meta.dirname, "..", "..");
const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid");
function killProcess(pid: number): void {
try {
if (isWindows) {
// On Windows, process.kill doesn't reliably kill child trees.
// Use taskkill /F /T to force-kill the process tree.
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
} else {
// On Unix, kill the process group (negative PID)
process.kill(-pid, "SIGTERM");
}
console.log(`[e2e] Killed ComfyUI process (PID ${pid}).`);
} catch {
// Process may already be dead
console.log(
`[e2e] ComfyUI process (PID ${pid}) already exited or could not be killed.`
);
}
}
export default async function globalTeardown(): Promise<void> {
if (!existsSync(pidFile)) {
console.log("[e2e] No PID file found, nothing to tear down.");
return;
}
const content = readFileSync(pidFile, "utf-8").trim();
if (content === "external") {
console.log(
"[e2e] ComfyUI was already running before tests — leaving it alone."
);
unlinkSync(pidFile);
return;
}
const pid = parseInt(content, 10);
if (isNaN(pid)) {
console.log(`[e2e] Invalid PID in file: "${content}". Cleaning up.`);
unlinkSync(pidFile);
return;
}
killProcess(pid);
unlinkSync(pidFile);
}
+1035
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
import {
assertCorrectnessInvariants,
extractGraphState,
loadWorkflow,
triggerOrganize,
waitForComfyUI,
} from "./helpers";
import {
listInstalledTemplates,
loadWorkflowData,
} from "./installed-templates";
const installedTemplates = listInstalledTemplates();
test.describe("Installed workflow template invariants", () => {
test("discovers installed workflow templates", () => {
expect(installedTemplates.length).toBeGreaterThan(0);
});
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
for (const template of installedTemplates) {
test(`${template.id}: organizes and passes correctness invariants`, async ({
page,
}) => {
await loadWorkflow(page, loadWorkflowData(template.path));
await triggerOrganize(page);
const state = await extractGraphState(page);
expect(state.nodes.length).toBeGreaterThan(0);
assertCorrectnessInvariants(state);
});
}
});
+80
View File
@@ -0,0 +1,80 @@
import { execFileSync } from "node:child_process";
import { join, resolve } from "node:path";
import { e2eConfig } from "../../e2e.config";
import { loadWorkflowData } from "./fixtures";
export interface InstalledTemplateEntry {
id: string;
path: string;
}
export function isWorkflowData(value: Record<string, unknown>): boolean {
return Array.isArray(value.nodes);
}
function isInstalledTemplateEntry(
value: unknown,
): value is InstalledTemplateEntry {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
candidate.id.length > 0 &&
typeof candidate.path === "string" &&
candidate.path.length > 0
);
}
export function parseInstalledTemplateManifest(
stdout: string,
): InstalledTemplateEntry[] {
const parsed: unknown = JSON.parse(stdout);
if (!Array.isArray(parsed) || !parsed.every(isInstalledTemplateEntry)) {
throw new Error("Installed template discovery returned an invalid manifest");
}
if (parsed.length === 0) {
throw new Error("Installed template discovery returned zero templates");
}
return parsed;
}
function getTestVenvPythonPath(): string {
const venvRoot = resolve(e2eConfig.venvDir);
return process.platform === "win32"
? join(venvRoot, "Scripts", "python.exe")
: join(venvRoot, "bin", "python");
}
export function listInstalledTemplates(): InstalledTemplateEntry[] {
const script = [
"import json",
"from comfyui_workflow_templates import get_asset_path, iter_templates",
"entries = []",
"for template in iter_templates():",
" json_assets = [asset for asset in template.assets if asset.filename.endswith('.json')]",
" if len(json_assets) != 1:",
" raise RuntimeError(f'Template {template.template_id} expected exactly one JSON asset, found {len(json_assets)}')",
" entries.append({'id': template.template_id, 'path': get_asset_path(template.template_id, json_assets[0].filename)})",
"print(json.dumps(sorted(entries, key=lambda entry: entry['id'])))",
].join("\n");
const stdout = execFileSync(getTestVenvPythonPath(), ["-c", script], {
encoding: "utf-8",
});
const workflowEntries = parseInstalledTemplateManifest(stdout).filter((entry) =>
isWorkflowData(loadWorkflowData(entry.path)),
);
if (workflowEntries.length === 0) {
throw new Error("Installed template discovery returned zero workflow templates");
}
return workflowEntries;
}
export { loadWorkflowData };
+98
View File
@@ -0,0 +1,98 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
triggerOrganizeGroup,
} from "./helpers";
import { loadFixture } from "./fixtures";
test.describe("Organize Group", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("group-test-simple: organizes a single group", async ({ page }) => {
const workflow = loadFixture("group-test-simple");
await loadWorkflow(page, workflow);
// Get group titles from the workflow
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown>;
const graph = canvas.graph as Record<string, unknown>;
const groups = graph._groups as Array<{ title: string }>;
return groups.map((g) => g.title);
});
if (groupTitles.length > 0) {
await triggerOrganizeGroup(page, groupTitles[0]);
const state = await extractGraphState(page);
// All coordinates should be finite after organize
for (const node of state.nodes) {
expect(Number.isFinite(node.pos[0])).toBe(true);
expect(Number.isFinite(node.pos[1])).toBe(true);
}
}
});
test("group-test: organizes groups with nested structure", async ({
page,
}) => {
const workflow = loadFixture("group-test");
await loadWorkflow(page, workflow);
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown>;
const graph = canvas.graph as Record<string, unknown>;
const groups = graph._groups as Array<{ title: string }>;
return groups.map((g) => g.title);
});
// Organize each group
for (const title of groupTitles) {
await triggerOrganizeGroup(page, title);
}
const state = await extractGraphState(page);
// Basic sanity: all coordinates finite
for (const node of state.nodes) {
expect(Number.isFinite(node.pos[0])).toBe(true);
expect(Number.isFinite(node.pos[1])).toBe(true);
}
});
test("nested-groups: organize group preserves other groups", async ({
page,
}) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
const beforeState = await extractGraphState(page);
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown>;
const graph = canvas.graph as Record<string, unknown>;
const groups = graph._groups as Array<{ title: string }>;
return groups.map((g) => g.title);
});
if (groupTitles.length > 0) {
await triggerOrganizeGroup(page, groupTitles[0]);
const afterState = await extractGraphState(page);
// Total node count should be the same
expect(afterState.nodes.length).toBe(beforeState.nodes.length);
// Total group count should be the same
expect(afterState.groups.length).toBe(beforeState.groups.length);
}
});
});
+183
View File
@@ -0,0 +1,183 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
extractGroupMemberships,
triggerOrganize,
assertInvariants,
assertIdempotent,
} from "./helpers";
import { loadFixture } from "./fixtures";
test.describe("Organize Workflow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("simple-dag: organizes and passes invariants", async ({ page }) => {
const workflow = loadFixture("simple-dag");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
expect(state.nodes.length).toBe(7);
assertInvariants(state);
});
test("simple-dag: idempotent", async ({ page }) => {
const workflow = loadFixture("simple-dag");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("complex-parallel: organizes and passes invariants", async ({
page,
}) => {
const workflow = loadFixture("complex-parallel");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
expect(state.nodes.length).toBeGreaterThan(10);
assertInvariants(state);
});
test("complex-parallel: idempotent", async ({ page }) => {
const workflow = loadFixture("complex-parallel");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("nested-groups: organizes and passes invariants", async ({ page }) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
assertInvariants(state);
});
test("nested-groups: groups contain their members after organize", async ({
page,
}) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
// Groups should exist and have valid bounds
expect(state.groups.length).toBeGreaterThan(0);
for (const g of state.groups) {
expect(g.size[0]).toBeGreaterThan(0);
expect(g.size[1]).toBeGreaterThan(0);
}
});
test("group-test: preserves group hierarchy and direct memberships", async ({
page,
}) => {
const workflow = loadFixture("group-test");
await loadWorkflow(page, workflow);
const beforeMemberships = await extractGroupMemberships(page);
await triggerOrganize(page);
const afterMemberships = await extractGroupMemberships(page);
expect(afterMemberships).toEqual(beforeMemberships);
});
test("nested-groups: idempotent", async ({ page }) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("nested-wrapper: organizes and passes invariants", async ({ page }) => {
const workflow = loadFixture("nested-wrapper");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
assertInvariants(state);
});
test("default workflow: organizes and passes invariants", async ({
page,
}) => {
// Don't load a fixture — use whatever ComfyUI loads by default
await triggerOrganize(page);
const state = await extractGraphState(page);
expect(state.nodes.length).toBeGreaterThan(0);
assertInvariants(state);
});
test("default workflow: idempotent", async ({ page }) => {
await triggerOrganize(page);
await assertIdempotent(page);
});
test("group-test: geometry-based group hierarchy and direct memberships match runtime after organize", async ({
page,
}) => {
const workflow = loadFixture("group-test");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const memberships = await extractGroupMemberships(page);
expect(memberships).toEqual([
{
title: "GetNodes [HORIZONTAL]",
parentTitle: "Previews [2ROW]",
memberNodeIds: [5, 6],
},
{
title: "Preview Nodes [HORIZONTAL]",
parentTitle: "Previews [2ROW]",
memberNodeIds: [3, 4],
},
{
title: "Previews [2ROW]",
parentTitle: null,
memberNodeIds: [],
},
]);
});
test("nested-groups: geometry-based group hierarchy and direct memberships match runtime after organize", async ({
page,
}) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const memberships = await extractGroupMemberships(page);
expect(memberships).toEqual([
{
title: "Group",
parentTitle: "Step 2 - Prompt",
memberNodeIds: [15],
},
{
title: "Step 1 - Load model",
parentTitle: null,
memberNodeIds: [4],
},
{
title: "Step 2 - Prompt",
parentTitle: null,
memberNodeIds: [6, 7],
},
{
title: "Step 3 - Image size",
parentTitle: null,
memberNodeIds: [5],
},
]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Regression tests for v1 bugs that must not recur in v2.
*
* Each test targets a specific issue from the v1 changeset history:
* - 0006: Y explosion (groups centering against tallest layer)
* - 0007: Group overlap (no collision detection across layers)
* - 0008: Nested group breakout (parent groups expanded 1600+ px)
* - 0011: Oscillation (infinite loop in overlap resolution)
* - 0013: Idempotency broken
*/
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
triggerOrganize,
assertInvariants,
assertIdempotent,
} from "./helpers";
import { loadFixture } from "./fixtures";
test.describe("Regression: v1 bug fixes", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("no Y explosion: layout height stays reasonable", async ({ page }) => {
// 0006: Y explosion — groups centering against tallest layer
// caused layouts to be thousands of pixels tall
const workflow = loadFixture("complex-parallel");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
const ys = state.nodes.map((n) => n.pos[1] + n.size[1]);
const maxY = Math.max(...ys);
const minY = Math.min(...state.nodes.map((n) => n.pos[1]));
const totalHeight = maxY - minY;
// Height should not explode beyond a reasonable bound
// (v1 bug produced heights of 11,500px for ~25 nodes)
expect(totalHeight).toBeLessThan(5000);
});
test("no group overlap after organize", async ({ page }) => {
// 0007: Groups overlapping each other after layout
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
assertInvariants(state);
});
test("nested groups stay contained", async ({ page }) => {
// 0008: Nested groups breaking out of parent bounds
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
// Groups should have reasonable sizes (v1 bug expanded by 1600+ px)
for (const g of state.groups) {
expect(g.size[0]).toBeLessThan(5000);
expect(g.size[1]).toBeLessThan(5000);
}
});
test("no oscillation: organize completes quickly", async ({ page }) => {
// 0011: Infinite loop in overlap resolution
// If organize completes within timeout, there's no oscillation
const workflow = loadFixture("complex-parallel");
await loadWorkflow(page, workflow);
const start = Date.now();
await triggerOrganize(page);
const elapsed = Date.now() - start;
// Should complete in under 5 seconds (v1 bug caused infinite loops)
expect(elapsed).toBeLessThan(5000);
});
test("idempotency: simple-dag", async ({ page }) => {
// 0013: Layout not stable on repeated runs
const workflow = loadFixture("simple-dag");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("idempotency: nested-groups", async ({ page }) => {
const workflow = loadFixture("nested-groups");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("idempotency: complex-parallel", async ({ page }) => {
const workflow = loadFixture("complex-parallel");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
extractGroupMemberships,
triggerOrganize,
triggerOrganizeGroup,
assertInvariants,
assertIdempotent,
expectGraphCanvasScreenshot,
setBooleanSetting,
} from "./helpers";
import { loadFixture } from "./fixtures";
import { SETTING_IDS } from "../../src/settings";
test.describe("SDXL Simple Example", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false);
});
test("organize workflow preserves counts and passes invariants", async ({
page,
}) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
const beforeState = await extractGraphState(page);
expect(beforeState.nodes.length).toBe(25);
expect(beforeState.groups.length).toBe(10);
expect(beforeState.links.length).toBe(23);
await triggerOrganize(page);
const afterState = await extractGraphState(page);
expect(afterState.nodes.length).toBe(beforeState.nodes.length);
expect(afterState.groups.length).toBe(beforeState.groups.length);
expect(afterState.links.length).toBe(beforeState.links.length);
assertInvariants(afterState);
});
test("organize workflow preserves group hierarchy and direct memberships", async ({
page,
}) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
const beforeMemberships = await extractGroupMemberships(page);
await triggerOrganize(page);
const afterMemberships = await extractGroupMemberships(page);
expect(afterMemberships).toEqual(beforeMemberships);
});
test("organize workflow is idempotent", async ({ page }) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await assertIdempotent(page);
});
test("organize top-level group preserves child group memberships and graph counts", async ({
page,
}) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
const beforeMemberships = await extractGroupMemberships(page);
const expectedMemberships = beforeMemberships.filter(
(group) =>
group.title === "Base Prompt" ||
group.title === "Load in BASE SDXL Model",
);
await triggerOrganizeGroup(page, "Base");
const afterMemberships = await extractGroupMemberships(page);
expect(
afterMemberships.filter(
(group) =>
group.title === "Base Prompt" ||
group.title === "Load in BASE SDXL Model",
),
).toEqual(expectedMemberships);
const state = await extractGraphState(page);
expect(state.nodes.length).toBe(25);
expect(state.groups.length).toBe(10);
});
test("organize prompt group preserves its direct memberships", async ({
page,
}) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
const beforeMemberships = await extractGroupMemberships(page);
await triggerOrganizeGroup(page, "Text Prompts");
const afterMemberships = await extractGroupMemberships(page);
expect(
afterMemberships.find((group) => group.title === "Text Prompts"),
).toEqual(beforeMemberships.find((group) => group.title === "Text Prompts"));
});
test("graph canvas after organize", async ({ page }) => {
const workflow = loadFixture("sdxl_simple_example");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
await expectGraphCanvasScreenshot(page, "sdxl-simple-example-organized.png");
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+105
View File
@@ -0,0 +1,105 @@
import { test, expect } from "@playwright/test";
import { SETTING_IDS } from "../../src/settings";
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
import { loadFixture } from "./fixtures";
import {
waitForComfyUI,
loadWorkflow,
triggerOrganize,
setNumericSettingOverride,
clearNumericSettingOverrides,
extractSpacingMeasurements,
} from "./helpers";
test.describe("Spacing settings", () => {
async function applyDefaultSpacingOverrides(
page: import("@playwright/test").Page,
): Promise<void> {
await setNumericSettingOverride(
page,
SETTING_IDS.HORIZONTAL_GAP,
DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
);
await setNumericSettingOverride(
page,
SETTING_IDS.VERTICAL_GAP,
DEFAULT_FRAMEWORK_CONFIG.verticalGap,
);
await setNumericSettingOverride(
page,
SETTING_IDS.GROUP_PADDING,
DEFAULT_FRAMEWORK_CONFIG.groupPadding,
);
await setNumericSettingOverride(
page,
SETTING_IDS.DISCONNECTED_GAP,
DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
);
}
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await clearNumericSettingOverrides(page);
await applyDefaultSpacingOverrides(page);
await loadWorkflow(page, loadFixture("spacing-settings"));
});
test.afterEach(async ({ page }) => {
await clearNumericSettingOverrides(page);
});
test("horizontal gap setting increases consecutive layer spacing", async ({
page,
}) => {
await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 40);
await triggerOrganize(page);
const compact = await extractSpacingMeasurements(page);
await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 220);
await triggerOrganize(page);
const wide = await extractSpacingMeasurements(page);
expect(wide.horizontalLayerGap).toBeGreaterThan(compact.horizontalLayerGap);
});
test("vertical gap setting increases main-layer sibling spacing", async ({
page,
}) => {
await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 20);
await triggerOrganize(page);
const compact = await extractSpacingMeasurements(page);
await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 180);
await triggerOrganize(page);
const roomy = await extractSpacingMeasurements(page);
expect(roomy.verticalSiblingGap).toBeGreaterThan(compact.verticalSiblingGap);
});
test("group padding setting increases inner group margin", async ({ page }) => {
await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 10);
await triggerOrganize(page);
const compact = await extractSpacingMeasurements(page);
await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 120);
await triggerOrganize(page);
const roomy = await extractSpacingMeasurements(page);
expect(roomy.groupPadding).toBeGreaterThan(compact.groupPadding);
});
test("disconnected gap setting increases distance to disconnected node", async ({
page,
}) => {
await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 40);
await triggerOrganize(page);
const compact = await extractSpacingMeasurements(page);
await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 320);
await triggerOrganize(page);
const roomy = await extractSpacingMeasurements(page);
expect(roomy.disconnectedGap).toBeGreaterThan(compact.disconnectedGap);
});
});
+189
View File
@@ -0,0 +1,189 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
triggerOrganize,
assertInvariants,
assertIdempotent,
} from "./helpers";
import { loadFixture } from "./fixtures";
test.describe("Smoke tests", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("ComfyUI loads with extension commands registered", async ({
page,
}) => {
const extensionState = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const command = em.command as Record<string, unknown>;
const commands = command.commands as Array<{ id: string }>;
const setting = em.setting as {
get: <T>(id: string) => T | undefined;
};
return {
hasOrganizeWorkflow: commands.some(
(c) => c.id === "node-organizer.organize-workflow",
),
hasOrganizeGroups: commands.some(
(c) => c.id === "node-organizer.organize-groups",
),
defaultAlgorithm: setting.get<string>("Node Organizer.Default Algorithm"),
};
});
expect(extensionState.hasOrganizeWorkflow).toBe(true);
expect(extensionState.hasOrganizeGroups).toBe(true);
expect(extensionState.defaultAlgorithm).toBe("sugiyama");
});
test("default workflow has nodes", async ({ page }) => {
const state = await extractGraphState(page);
expect(state.nodes.length).toBeGreaterThan(0);
expect(state.links.length).toBeGreaterThan(0);
});
test("Organize Workflow runs without error", async ({ page }) => {
await triggerOrganize(page);
const state = await extractGraphState(page);
for (const node of state.nodes) {
expect(Number.isFinite(node.pos[0])).toBe(true);
expect(Number.isFinite(node.pos[1])).toBe(true);
}
});
test("Organize Workflow produces valid layout", async ({ page }) => {
await triggerOrganize(page);
const state = await extractGraphState(page);
assertInvariants(state);
});
test("Organize Workflow is idempotent", async ({ page }) => {
await triggerOrganize(page);
await assertIdempotent(page);
});
test("isGroup discriminator matches runtime default workflow", async ({
page,
}) => {
const { groups, nodes } = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as Record<string, unknown>;
const extractInfo = (item: Record<string, unknown>) => ({
id: item.id as number | string,
constructorName: (item.constructor as { name?: string } | undefined)
?.name,
typeofType: typeof item.type,
hasStringTitle: typeof item.title === "string",
});
const rawGroups =
(graph._groups as Array<Record<string, unknown>> | undefined) ?? [];
const rawNodes =
(graph._nodes as Array<Record<string, unknown>> | undefined) ?? [];
return {
groups: rawGroups.map(extractInfo),
nodes: rawNodes.map(extractInfo),
};
});
// Every group must be identifiable by isGroup logic
for (const g of groups) {
const identifiable =
g.constructorName === "LGraphGroup" ||
(g.hasStringTitle && g.typeofType !== "string");
expect(
identifiable,
`Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`,
).toBe(true);
}
// No node should be a false positive (matches duck-typing but is not a group)
for (const n of nodes) {
const falsePositive =
n.hasStringTitle &&
n.typeofType !== "string" &&
n.constructorName !== "LGraphGroup";
expect(
falsePositive,
`Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`,
).toBe(false);
}
});
test("isGroup discriminator matches runtime grouped workflow", async ({
page,
}) => {
const workflow = loadFixture("group-test");
await loadWorkflow(page, workflow);
const { groups, nodes } = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as Record<string, unknown>;
const extractInfo = (item: Record<string, unknown>) => ({
id: item.id as number | string,
constructorName: (item.constructor as { name?: string } | undefined)
?.name,
typeofType: typeof item.type,
hasStringTitle: typeof item.title === "string",
});
const rawGroups =
(graph._groups as Array<Record<string, unknown>> | undefined) ?? [];
const rawNodes =
(graph._nodes as Array<Record<string, unknown>> | undefined) ?? [];
return {
groups: rawGroups.map(extractInfo),
nodes: rawNodes.map(extractInfo),
};
});
// Must have groups in this fixture
expect(groups.length).toBeGreaterThan(0);
// Every group must be identifiable by isGroup logic
for (const g of groups) {
const identifiable =
g.constructorName === "LGraphGroup" ||
(g.hasStringTitle && g.typeofType !== "string");
expect(
identifiable,
`Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`,
).toBe(true);
}
// No node should be a false positive
for (const n of nodes) {
const falsePositive =
n.hasStringTitle &&
n.typeofType !== "string" &&
n.constructorName !== "LGraphGroup";
expect(
falsePositive,
`Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`,
).toBe(false);
}
});
});
+273
View File
@@ -0,0 +1,273 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
triggerOrganize,
extractGraphState,
} from "./helpers";
import { loadFixture } from "./fixtures";
import { SETTING_IDS } from "../../src/settings";
test.describe("Subgraph layout", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("organize keeps subgraph boundary nodes at the execution graph edges", async ({
page,
}) => {
await loadWorkflow(page, loadFixture("subgraph-io"));
const state = await page.evaluate(async () => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as {
graph?: {
_nodes?: Array<Record<string, unknown>>;
};
getCurrentGraph?: () => {
_nodes?: Array<Record<string, unknown>>;
inputNode?: Record<string, unknown>;
outputNode?: Record<string, unknown>;
links?:
| Map<number, Record<string, unknown>>
| Record<string, Record<string, unknown>>;
};
openSubgraph: (
subgraph: Record<string, unknown>,
fromNode: Record<string, unknown>,
) => void;
};
const rootGraph = (canvas.graph ??
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
const rootNodes = rootGraph._nodes ?? [];
const subgraphNode = rootNodes.find((n) => !!n.subgraph);
if (!subgraphNode?.subgraph) {
throw new Error("No subgraph node found in fixture");
}
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
const currentGraph = (canvas.getCurrentGraph?.() ??
canvas.graph) as {
_nodes: Array<Record<string, unknown>>;
inputNode: Record<string, unknown>;
outputNode: Record<string, unknown>;
links:
| Map<number, Record<string, unknown>>
| Record<string, Record<string, unknown>>;
};
return {
rootNodeIds: rootNodes.map((n) => n.id as number),
subgraphNodeCount: currentGraph._nodes.length,
beforeInputX: Number((currentGraph.inputNode.pos as ArrayLike<number>)[0]),
beforeOutputX: Number((currentGraph.outputNode.pos as ArrayLike<number>)[0]),
};
});
expect(state.subgraphNodeCount).toBeGreaterThan(0);
await triggerOrganize(page);
const after = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as {
graph?: {
_nodes?: Array<Record<string, unknown>>;
};
getCurrentGraph?: () => {
_nodes?: Array<Record<string, unknown>>;
inputNode?: Record<string, unknown>;
outputNode?: Record<string, unknown>;
links?:
| Map<number, Record<string, unknown>>
| Record<string, Record<string, unknown>>;
};
};
const currentGraph = (canvas.getCurrentGraph?.() ??
canvas.graph) as {
_nodes: Array<Record<string, unknown>>;
inputNode: Record<string, unknown>;
outputNode: Record<string, unknown>;
links:
| Map<number, Record<string, unknown>>
| Record<string, Record<string, unknown>>;
};
const pack = (node: Record<string, unknown>) => {
const pos = node.pos as ArrayLike<number>;
const size = node.size as ArrayLike<number>;
return {
id: node.id as number,
x: Number(pos[0]),
y: Number(pos[1]),
width: Number(size[0]),
height: Number(size[1]),
};
};
return {
inputNode: pack(currentGraph.inputNode),
outputNode: pack(currentGraph.outputNode),
nodes: currentGraph._nodes.map(pack),
links:
currentGraph.links instanceof Map
? Array.from(currentGraph.links.values()).map((link) => ({
origin_id: link.origin_id as number,
target_id: link.target_id as number,
}))
: Object.values(
currentGraph.links as Record<string, Record<string, unknown>>,
).map((link) => ({
origin_id: link.origin_id as number,
target_id: link.target_id as number,
})),
};
});
const connectedNodeIds = new Set<number>();
for (const link of after.links) {
if (link.origin_id > 0) connectedNodeIds.add(link.origin_id);
if (link.target_id > 0) connectedNodeIds.add(link.target_id);
}
const connectedNodes = after.nodes.filter((node) =>
connectedNodeIds.has(node.id),
);
const leftmostNodeX = Math.min(...connectedNodes.map((node) => node.x));
const rightmostNodeEdge = Math.max(
...connectedNodes.map((node) => node.x + node.width),
);
expect(after.inputNode.x).toBeLessThanOrEqual(leftmostNodeX);
expect(after.outputNode.x + after.outputNode.width).toBeGreaterThanOrEqual(
rightmostNodeEdge,
);
});
test("fit-to-view inside subgraph includes boundary nodes in bounds", async ({
page,
}) => {
await loadWorkflow(page, loadFixture("subgraph-io"));
// Enable fit-to-view
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: boolean) => void };
setting.set(settingId, true);
}, SETTING_IDS.FIT_TO_VIEW);
// Navigate into the subgraph
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as {
graph?: { _nodes?: Array<Record<string, unknown>> };
openSubgraph: (
subgraph: Record<string, unknown>,
fromNode: Record<string, unknown>,
) => void;
};
const rootGraph = (canvas.graph ??
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph);
if (!subgraphNode?.subgraph) {
throw new Error("No subgraph node found in fixture");
}
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
});
// Organize inside the subgraph with fit-to-view enabled
await triggerOrganize(page);
await page.waitForTimeout(1500);
// Get viewport scale and the actual node/boundary bounds
const result = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown>;
ds: { scale: number };
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph) as {
_nodes: Array<Record<string, unknown>>;
inputNode?: Record<string, unknown>;
outputNode?: Record<string, unknown>;
};
const bounds = { minX: Infinity, maxX: -Infinity };
for (const node of graph._nodes) {
const pos = node.pos as ArrayLike<number>;
const size = node.size as ArrayLike<number>;
const x = Number(pos[0]);
const right = x + Number(size[0]);
if (x < bounds.minX) bounds.minX = x;
if (right > bounds.maxX) bounds.maxX = right;
}
if (graph.inputNode) {
const pos = graph.inputNode.pos as ArrayLike<number>;
const size = graph.inputNode.size as ArrayLike<number>;
const x = Number(pos[0]);
const right = x + Number(size[0]);
if (x < bounds.minX) bounds.minX = x;
if (right > bounds.maxX) bounds.maxX = right;
}
if (graph.outputNode) {
const pos = graph.outputNode.pos as ArrayLike<number>;
const size = graph.outputNode.size as ArrayLike<number>;
const x = Number(pos[0]);
const right = x + Number(size[0]);
if (x < bounds.minX) bounds.minX = x;
if (right > bounds.maxX) bounds.maxX = right;
}
return {
scale: canvas.ds.scale,
totalWidth: bounds.maxX - bounds.minX,
hasInputNode: !!graph.inputNode,
hasOutputNode: !!graph.outputNode,
};
});
expect(result.hasInputNode).toBe(true);
expect(result.hasOutputNode).toBe(true);
// If boundary nodes are included in bounds, scale should be reasonable (< 2).
// Without them, the bounds are too small and scale is too high.
expect(result.scale).toBeLessThan(2);
});
test("extractGraphState includes subgraph boundary nodes", async ({ page }) => {
await loadWorkflow(page, loadFixture("subgraph-io"));
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as {
graph?: {
_nodes?: Array<Record<string, unknown>>;
};
getCurrentGraph?: () => unknown;
openSubgraph: (
subgraph: Record<string, unknown>,
fromNode: Record<string, unknown>,
) => void;
};
const rootGraph = (canvas.graph ??
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph);
if (!subgraphNode?.subgraph) {
throw new Error("No subgraph node found in fixture");
}
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
});
const state = await extractGraphState(page);
const nodeIds = state.nodes.map((node) => node.id);
expect(nodeIds).toContain(-10);
expect(nodeIds).toContain(-20);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
extractGroupMemberships,
triggerOrganize,
} from "./helpers";
import { loadFixture } from "./fixtures";
test.describe("Token Layout", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("token-testing: organizes and all coordinates finite", async ({
page,
}) => {
const workflow = loadFixture("token-testing");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
for (const node of state.nodes) {
expect(Number.isFinite(node.pos[0])).toBe(true);
expect(Number.isFinite(node.pos[1])).toBe(true);
}
});
test("token-testing: groups sized to fit after organize", async ({
page,
}) => {
const workflow = loadFixture("token-testing");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state = await extractGraphState(page);
// All groups should have positive dimensions
for (const g of state.groups) {
expect(g.size[0]).toBeGreaterThan(0);
expect(g.size[1]).toBeGreaterThan(0);
}
});
test("token-testing: idempotent", async ({ page }) => {
const workflow = loadFixture("token-testing");
await loadWorkflow(page, workflow);
await triggerOrganize(page);
const state1 = await extractGraphState(page);
// Organize again via command
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const command = em.command as { execute: (id: string) => void };
command.execute("node-organizer.organize-workflow");
});
await page.waitForTimeout(1000);
const state2 = await extractGraphState(page);
// Positions should be identical within tolerance
for (const n1 of state1.nodes) {
const n2 = state2.nodes.find((n) => n.id === n1.id);
expect(n2).toBeDefined();
if (n2) {
expect(Math.abs(n1.pos[0] - n2.pos[0])).toBeLessThan(2);
expect(Math.abs(n1.pos[1] - n2.pos[1])).toBeLessThan(2);
}
}
});
test("token-testing: preserves group hierarchy and direct memberships", async ({
page,
}) => {
const workflow = loadFixture("token-testing");
await loadWorkflow(page, workflow);
const beforeMemberships = await extractGroupMemberships(page);
await triggerOrganize(page);
const afterMemberships = await extractGroupMemberships(page);
expect(afterMemberships).toEqual(beforeMemberships);
});
});
+352
View File
@@ -0,0 +1,352 @@
import { readFileSync } from "node:fs";
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
triggerOrganize,
triggerOrganizeGroup,
} from "./helpers";
import { loadFixture } from "./fixtures";
import { SETTING_IDS } from "../../src/settings";
const packageVersion = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
) as { version: string };
function didAnyNodeMove(
before: Awaited<ReturnType<typeof extractGraphState>>,
after: Awaited<ReturnType<typeof extractGraphState>>,
): boolean {
for (const node of before.nodes) {
const next = after.nodes.find((candidate) => candidate.id === node.id);
if (!next) continue;
if (
Math.abs(node.pos[0] - next.pos[0]) > 1 ||
Math.abs(node.pos[1] - next.pos[1]) > 1
) {
return true;
}
}
return false;
}
test.describe("UI smoke", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
});
test("action bar organize button runs layout", async ({ page }) => {
const before = await extractGraphState(page);
await page.getByRole("button", { name: "Organize" }).click();
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("Shift+O keybinding runs layout", async ({ page }) => {
const before = await extractGraphState(page);
await page.keyboard.press("Shift+O");
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("context-aware Shift+O organizes selected groups when groups are selected", async ({ page }) => {
await loadWorkflow(page, loadFixture("group-test-simple"));
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups.map((group) => group.title);
});
expect(groupTitles.length).toBeGreaterThan(0);
// Select the first group programmatically
await page.evaluate((title: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph) as Record<string, unknown>;
const groups = graph._groups as Array<Record<string, unknown>>;
const group = groups.find((g) => g.title === title);
if (!group) throw new Error(`Group "${title}" not found`);
canvas.selectedItems = new Set([group]);
}, groupTitles[0]);
const before = await extractGraphState(page);
// Use Shift+O — should organize the selected group, not the full workflow
await page.keyboard.press("Shift+O");
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("Organize Group runs layout on the selected group", async ({ page }) => {
await loadWorkflow(page, loadFixture("group-test-simple"));
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups.map((group) => group.title);
});
expect(groupTitles.length).toBeGreaterThan(0);
const before = await extractGraphState(page);
await triggerOrganizeGroup(page, groupTitles[0]);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
});
test.describe("Settings panel", () => {
async function openNodeOrganizerSettings(page: import("@playwright/test").Page): Promise<void> {
// The sidebar settings button includes the keyboard shortcut in its label
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
await page.waitForTimeout(500);
// Navigate to our extension's settings section
await page.getByText("Node Organizer", { exact: true }).click();
await page.waitForTimeout(500);
}
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("Node Organizer appears in settings sidebar", async ({ page }) => {
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
await expect(
page.getByText("Node Organizer", { exact: true }),
).toBeVisible();
});
test("settings panel shows About section with version and homepage", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(
page.getByText(`Version ${packageVersion.version}`),
).toBeVisible();
await expect(page.getByRole("link", { name: "Homepage" })).toBeVisible();
});
test("settings panel shows Layout section with sliders", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(page.getByText("Horizontal Gap")).toBeVisible();
await expect(page.getByText("Vertical Gap")).toBeVisible();
await expect(page.getByText("Group Padding")).toBeVisible();
await expect(page.getByText("Disconnected Node Gap")).toBeVisible();
});
test("settings panel shows Behavior and Keybindings sections", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(
page.getByText("Fit to View After Organize"),
).toBeVisible();
await expect(page.getByText("Keyboard Shortcuts")).toBeVisible();
});
test("settings sections appear in correct order", async ({ page }) => {
await openNodeOrganizerSettings(page);
// Collect all visible text and check the relative order of section headings
const allText = await page.locator("body").innerText();
const sectionNames = ["About", "Layout", "Behavior", "Keybindings", "Advanced"];
const positions = sectionNames.map((name) => ({
name,
pos: allText.indexOf(name),
}));
// All sections should be found
for (const { name, pos } of positions) {
expect(pos, `Section "${name}" not found in settings panel`).toBeGreaterThanOrEqual(0);
}
// Verify ordering
for (let i = 1; i < positions.length; i++) {
expect(
positions[i].pos,
`"${positions[i].name}" should appear after "${positions[i - 1].name}"`,
).toBeGreaterThan(positions[i - 1].pos);
}
});
});
test.describe("Fit to view after organize", () => {
async function enableFitToView(page: import("@playwright/test").Page): Promise<void> {
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: boolean) => void };
setting.set(settingId, true);
}, SETTING_IDS.FIT_TO_VIEW);
}
async function getViewport(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown>;
const ds = canvas.ds as { offset: [number, number]; scale: number };
return { offset: [...ds.offset], scale: ds.scale };
});
}
test("viewport changes after organizing full workflow", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
await enableFitToView(page);
const viewportBefore = await getViewport(page);
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewportAfter = await getViewport(page);
const offsetChanged =
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
const scaleChanged =
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
expect(offsetChanged || scaleChanged).toBe(true);
});
test("fit-to-view fits entire graph, not just selected node", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
await enableFitToView(page);
// Select a single node (last node in the graph)
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph) as Record<string, unknown>;
const nodes = graph._nodes as Array<Record<string, unknown>>;
if (nodes.length > 0) {
canvas.selectedItems = new Set([nodes[nodes.length - 1]]);
}
});
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewport = await getViewport(page);
// With fit-to-all-items, the scale should be < 2 (fitting the whole graph).
// If it zoomed to a single selected node, scale would be much larger (e.g. 3+).
// This threshold catches the bug where a selected node is zoomed into.
expect(viewport.scale).toBeLessThan(2);
});
test("fit-to-view does not run for selected-group organize", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("group-test-simple"));
await enableFitToView(page);
// Organize full workflow first to get a stable viewport
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewportBefore = await getViewport(page);
const groupTitle = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups[0]?.title ?? null;
});
if (groupTitle) {
await triggerOrganizeGroup(page, groupTitle);
await page.waitForTimeout(500);
const viewportAfter = await getViewport(page);
const offsetChanged =
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
const scaleChanged =
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
expect(offsetChanged || scaleChanged).toBe(false);
}
});
});
test.describe("Spacing settings affect layout", () => {
test("custom disconnectedGap changes disconnected node placement", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
// Organize with default settings
await triggerOrganize(page);
await page.waitForTimeout(1500);
const defaultState = await extractGraphState(page);
// Set a very large disconnected gap
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: number) => void };
setting.set(settingId, 400);
}, SETTING_IDS.DISCONNECTED_GAP);
// Re-organize with the new setting
await triggerOrganize(page);
await page.waitForTimeout(1500);
const customState = await extractGraphState(page);
// If there are any disconnected nodes, their positions should differ
// For workflows where all nodes are connected, positions are the same
// This test verifies the setting is read — not that every fixture has disconnected nodes
expect(customState.nodes.length).toBe(defaultState.nodes.length);
// Reset setting
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: number) => void };
setting.set(settingId, 150);
}, SETTING_IDS.DISCONNECTED_GAP);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { test } from "@playwright/test";
import type { Page } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
triggerOrganize,
expectGraphCanvasScreenshot,
setBooleanSetting,
} from "./helpers";
import { loadFixture } from "./fixtures";
import { SETTING_IDS } from "../../src/settings";
async function openFirstSubgraph(page: Page) {
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as {
graph?: { _nodes?: Array<Record<string, unknown>> };
openSubgraph: (
subgraph: Record<string, unknown>,
fromNode: Record<string, unknown>,
) => void;
};
const rootGraph = (canvas.graph ??
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
const subgraphNode = (rootGraph._nodes ?? []).find((node) => !!node.subgraph);
if (!subgraphNode?.subgraph) {
throw new Error("No subgraph node found in fixture");
}
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
});
}
test.describe("Visual Regression", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false);
});
test("simple-dag graph canvas after organize", async ({ page }) => {
await loadWorkflow(page, loadFixture("simple-dag"));
await triggerOrganize(page);
await expectGraphCanvasScreenshot(page, "simple-dag-organized.png");
});
test("nested-groups graph canvas after organize", async ({ page }) => {
await loadWorkflow(page, loadFixture("nested-groups"));
await triggerOrganize(page);
await expectGraphCanvasScreenshot(page, "nested-groups-organized.png");
});
test("token-testing graph canvas after organize", async ({ page }) => {
await loadWorkflow(page, loadFixture("token-testing"));
await triggerOrganize(page);
await expectGraphCanvasScreenshot(page, "token-testing-organized.png");
});
test("subgraph graph canvas after organize", async ({ page }) => {
await loadWorkflow(page, loadFixture("subgraph-io"));
await openFirstSubgraph(page);
await triggerOrganize(page);
await expectGraphCanvasScreenshot(page, "subgraph-io-organized.png");
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
{
"id": "3d90f8be-23c2-4fd8-b76f-0db8f5975d3f",
"revision": 0,
"last_node_id": 8,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [40, 280],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [1]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [2, 3]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [7]
}
],
"title": "Model Loader",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [380, 120],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 2
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"title": "Positive Prompt",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"glowing test scene"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [380, 360],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [5]
}
],
"title": "Negative Prompt",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"blurry, noisy"
]
},
{
"id": 4,
"type": "EmptyLatentImage",
"pos": [410, 620],
"size": [315, 106],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [6]
}
],
"title": "Latent Source",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 5,
"type": "KSampler",
"pos": [900, 280],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 5
},
{
"name": "latent_image",
"type": "LATENT",
"link": 6
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"title": "Sampler",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 6,
"type": "VAEDecode",
"pos": [1260, 320],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"title": "Decode",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 7,
"type": "SaveImage",
"pos": [1510, 320],
"size": [210, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"title": "Save",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"spacing-settings"
]
},
{
"id": 8,
"type": "EmptyLatentImage",
"pos": [1780, 60],
"size": [315, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": []
}
],
"title": "Disconnected Note",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.6.0",
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [256, 256, 1]
}
],
"links": [
[1, 1, 0, 5, 0, "MODEL"],
[2, 1, 1, 2, 0, "CLIP"],
[3, 1, 1, 3, 0, "CLIP"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"]
],
"groups": [
{
"id": 1,
"title": "Prompt Cluster",
"bounding": [330, 80, 520, 470],
"color": "#3f789e",
"font_size": 22,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.55,
"offset": [420, 210]
},
"workflowRendererVersion": "LG",
"ue_links": [],
"frontendVersion": "1.37.0"
},
"version": 0.4
}
-361
View File
@@ -1,361 +0,0 @@
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import type {
LGraph,
LGraphNode,
LGraphGroup,
LLink,
} from "../src/layout/types";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Raw ComfyUI workflow JSON structure */
interface WorkflowJSON {
nodes: Array<{
id: number;
type: string;
title?: string;
pos: [number, number];
size: [number, number];
flags?: { pinned?: boolean; collapsed?: boolean };
locked?: boolean;
order?: number;
inputs?: Array<{ name: string; type: string; link: number | null }>;
outputs?: Array<{ name: string; type: string; links: number[] | null }>;
}>;
links: Array<[number, number, number, number, number, string]>;
groups?: Array<{
id?: number;
title: string;
bounding: [number, number, number, number];
color?: string;
font_size?: number;
flags?: Record<string, unknown>;
}>;
definitions?: {
subgraphs?: SubgraphDefinition[];
};
}
/** Subgraph definition from workflow JSON */
interface SubgraphDefinition {
id: string;
name: string;
inputNode?: { id: number; bounding: [number, number, number, number] };
outputNode?: { id: number; bounding: [number, number, number, number] };
nodes: Array<{
id: number;
type: string;
title?: string;
pos: [number, number];
size: [number, number];
flags?: { pinned?: boolean; collapsed?: boolean };
locked?: boolean;
order?: number;
inputs?: Array<{ name: string; type: string; link: number | null }>;
outputs?: Array<{ name: string; type: string; links: number[] | null }>;
}>;
groups?: Array<{
id?: number;
title: string;
bounding: [number, number, number, number];
color?: string;
font_size?: number;
flags?: Record<string, unknown>;
}>;
links: Array<{
id: number;
origin_id: number;
origin_slot: number;
target_id: number;
target_slot: number;
type: string;
}>;
}
/**
* Load a workflow fixture and convert to LGraph format
*/
export function loadFixture(filename: string): LGraph {
const fixturePath = join(__dirname, "fixtures", filename);
const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON;
return convertWorkflowToLGraph(json);
}
/**
* Load a subgraph fixture (extracts first subgraph from workflow JSON)
* Returns LGraph with inputNode/outputNode in runtime format (pos/size)
*/
export function loadSubgraphFixture(filename: string): LGraph {
const fixturePath = join(__dirname, "fixtures", filename);
const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON;
const subgraph = json.definitions?.subgraphs?.[0];
if (!subgraph) {
throw new Error(`No subgraph found in ${filename}`);
}
return convertSubgraphToLGraph(subgraph);
}
/**
* Convert subgraph definition to LGraph format with I/O nodes
*/
function convertSubgraphToLGraph(subgraph: SubgraphDefinition): LGraph {
// Convert nodes
const nodes: LGraphNode[] = subgraph.nodes.map((n) => ({
id: n.id,
type: n.type,
title: n.title ?? n.type,
pos: [...n.pos],
size: [...n.size],
inputs:
n.inputs?.map((i) => ({
name: i.name,
type: i.type,
link: i.link,
})) ?? [],
outputs:
n.outputs?.map((o) => ({
name: o.name,
type: o.type,
links: o.links ? [...o.links] : null,
})) ?? [],
flags: n.flags ? { ...n.flags } : undefined,
locked: n.locked,
order: n.order,
}));
// Convert links from object array format to Map
const links = new Map<number, LLink>();
for (const link of subgraph.links) {
links.set(link.id, {
id: link.id,
origin_id: link.origin_id,
origin_slot: link.origin_slot,
target_id: link.target_id,
target_slot: link.target_slot,
type: link.type,
});
}
// Convert groups
const groups: LGraphGroup[] = (subgraph.groups ?? []).map((g, idx) => ({
id: g.id ?? idx,
title: g.title,
pos: [g.bounding[0], g.bounding[1]],
size: [g.bounding[2], g.bounding[3]],
}));
// Create graph with I/O nodes in runtime format (pos/size from bounding)
const graph: LGraph = {
_nodes: nodes,
_groups: groups,
links,
};
if (subgraph.inputNode) {
const [x, y, w, h] = subgraph.inputNode.bounding;
graph.inputNode = {
id: subgraph.inputNode.id,
pos: [x, y],
size: [w, h],
};
}
if (subgraph.outputNode) {
const [x, y, w, h] = subgraph.outputNode.bounding;
graph.outputNode = {
id: subgraph.outputNode.id,
pos: [x, y],
size: [w, h],
};
}
return graph;
}
/**
* Convert ComfyUI workflow JSON to LGraph format for layout algorithm
*/
export function convertWorkflowToLGraph(workflow: WorkflowJSON): LGraph {
// Convert nodes
const nodes: LGraphNode[] = workflow.nodes.map((n) => ({
id: n.id,
type: n.type,
title: n.title ?? n.type,
pos: [...n.pos],
size: [...n.size],
inputs:
n.inputs?.map((i) => ({
name: i.name,
type: i.type,
link: i.link,
})) ?? [],
outputs:
n.outputs?.map((o) => ({
name: o.name,
type: o.type,
links: o.links ? [...o.links] : null,
})) ?? [],
flags: n.flags ? { ...n.flags } : undefined,
locked: n.locked,
order: n.order,
}));
// Convert links: [linkId, originId, originSlot, targetId, targetSlot, type]
const links = new Map<number, LLink>();
for (const [
linkId,
originId,
originSlot,
targetId,
targetSlot,
type,
] of workflow.links) {
links.set(linkId, {
id: linkId,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type,
});
}
// Convert groups
const groups: LGraphGroup[] = (workflow.groups ?? []).map((g, idx) => ({
id: g.id ?? idx,
title: g.title,
pos: [g.bounding[0], g.bounding[1]],
size: [g.bounding[2], g.bounding[3]],
}));
return {
_nodes: nodes,
_groups: groups,
links,
};
}
/**
* Capture current positions of all nodes, groups, and I/O nodes for comparison
*/
export function capturePositions(
graph: LGraph
): Map<string, { x: number; y: number; width: number; height: number }> {
const positions = new Map<
string,
{ x: number; y: number; width: number; height: number }
>();
for (const node of graph._nodes) {
positions.set(`node_${node.id}`, {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
});
}
for (const group of graph._groups) {
positions.set(`group_${group.id}`, {
x: group.pos[0],
y: group.pos[1],
width: group.size[0],
height: group.size[1],
});
}
// Capture I/O node positions if present
if (graph.inputNode?.pos && graph.inputNode?.size) {
positions.set(`io_${graph.inputNode.id}`, {
x: graph.inputNode.pos[0],
y: graph.inputNode.pos[1],
width: graph.inputNode.size[0],
height: graph.inputNode.size[1],
});
}
if (graph.outputNode?.pos && graph.outputNode?.size) {
positions.set(`io_${graph.outputNode.id}`, {
x: graph.outputNode.pos[0],
y: graph.outputNode.pos[1],
width: graph.outputNode.size[0],
height: graph.outputNode.size[1],
});
}
return positions;
}
/**
* Get links as an iterable array
*/
function getLinksArray(links: LGraph["links"]): LLink[] {
if (links instanceof Map) {
return Array.from(links.values());
}
if (Array.isArray(links)) {
return links;
}
return Object.values(links);
}
/**
* Deep clone a graph for idempotency testing
* Preserves I/O nodes if present
*/
export function cloneGraph(graph: LGraph): LGraph {
const linksArr = getLinksArray(graph.links);
const cloned = convertWorkflowToLGraph({
nodes: graph._nodes.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
pos: [...n.pos],
size: [...n.size],
flags: n.flags ? { ...n.flags } : undefined,
locked: n.locked,
order: n.order,
inputs: n.inputs?.map((i) => ({ ...i })),
outputs: n.outputs?.map((o) => ({
...o,
links: o.links ? [...o.links] : null,
})),
})),
links: linksArr.map((l) => [
l.id,
l.origin_id,
l.origin_slot,
l.target_id,
l.target_slot,
l.type,
]),
groups: graph._groups.map((g) => ({
id: g.id,
title: g.title,
bounding: [g.pos[0], g.pos[1], g.size[0], g.size[1]],
})),
});
// Preserve I/O nodes if present
if (graph.inputNode) {
cloned.inputNode = {
id: graph.inputNode.id,
pos: [...graph.inputNode.pos],
size: [...graph.inputNode.size],
};
}
if (graph.outputNode) {
cloned.outputNode = {
id: graph.outputNode.id,
pos: [...graph.outputNode.pos],
size: [...graph.outputNode.size],
};
}
return cloned;
}
-180
View File
@@ -1,180 +0,0 @@
import { describe, it, beforeEach, expect } from "vitest";
import { loadFixture, loadSubgraphFixture, cloneGraph } from "../helpers";
import { layoutGraph } from "../../src/layout/index";
import {
assertNoOverlaps,
assertNodesInsideGroups,
assertFiniteCoordinates,
assertTopologicalOrder,
assertIdempotent,
assertGroupMembershipPreserved,
} from "../invariants";
import type { LGraph } from "../../src/layout/types";
/**
* Regression tests for the layout algorithm
* Each fixture is run through all invariants to catch regressions
*/
describe("layoutGraph regression tests", () => {
// Fixtures that currently pass all tests
const passingFixtures = [
"simple-dag.json", // 7 nodes, no groups, basic txt2img DAG
"nested-groups.json", // Nested groups with node inside inner group
"nested-wrapper.json", // Group Wrapper containing nested groups
"complex-parallel.json", // 26 nodes, parallel KSampler High/Low, 5 groups
];
for (const fixture of passingFixtures) {
describe(fixture, () => {
let graph: LGraph;
let originalGraph: LGraph;
beforeEach(() => {
graph = loadFixture(fixture);
originalGraph = cloneGraph(graph); // Capture state before layout
layoutGraph(graph);
});
it("has no overlapping entities", () => {
assertNoOverlaps(graph);
});
it("keeps nodes inside their groups", () => {
assertNodesInsideGroups(graph);
});
it("has finite coordinates", () => {
assertFiniteCoordinates(graph);
});
it("preserves topological order", () => {
assertTopologicalOrder(graph);
});
it("is idempotent (stable after multiple runs)", () => {
assertIdempotent(graph);
});
it("preserves group membership", () => {
assertGroupMembershipPreserved(originalGraph, graph);
});
});
}
});
/**
* Subgraph fixture tests (with I/O nodes)
* Uses loadSubgraphFixture to extract subgraph from workflow JSON
*/
describe("subgraph layout tests", () => {
const subgraphFixtures = [
"subgraph-io.json", // Subgraph with I/O nodes, 12 nodes, 4 groups
];
for (const fixture of subgraphFixtures) {
describe(fixture, () => {
let graph: LGraph;
let originalGraph: LGraph;
beforeEach(() => {
graph = loadSubgraphFixture(fixture);
originalGraph = cloneGraph(graph);
layoutGraph(graph);
});
it("has no overlapping entities (including I/O nodes)", () => {
assertNoOverlaps(graph);
});
it("keeps nodes inside their groups", () => {
assertNodesInsideGroups(graph);
});
it("has finite coordinates", () => {
assertFiniteCoordinates(graph);
});
it("preserves topological order", () => {
assertTopologicalOrder(graph);
});
it("is idempotent (stable after multiple runs)", () => {
assertIdempotent(graph);
});
it("preserves group membership", () => {
assertGroupMembershipPreserved(originalGraph, graph);
});
});
}
});
describe("layout algorithm properties", () => {
it("handles empty graph", () => {
const graph: LGraph = {
_nodes: [],
_groups: [],
links: new Map(),
};
const result = layoutGraph(graph);
expect(result.nodeCount).toBe(0);
expect(result.layerCount).toBe(0);
});
it("handles single node", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Test",
pos: [0, 0],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [],
links: new Map(),
};
const result = layoutGraph(graph);
expect(result.nodeCount).toBe(1);
assertFiniteCoordinates(graph);
});
it("handles disconnected nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Note 1",
pos: [0, 0],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Note 2",
pos: [500, 500],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [],
links: new Map(),
};
layoutGraph(graph);
assertNoOverlaps(graph);
assertFiniteCoordinates(graph);
});
});
-931
View File
@@ -1,931 +0,0 @@
import { describe, it, beforeEach, expect } from "vitest";
import { loadFixture, capturePositions } from "../helpers";
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
import { parseLayoutToken } from "../../src/layout/title-tokens";
import {
assertNodesInsideGroups,
assertFiniteCoordinates,
} from "../invariants";
import type { LGraph } from "../../src/layout/types";
/**
* Tests for layoutSelectedGroups function
* Note: External overlaps are acceptable per requirements
*/
describe("layoutSelectedGroups", () => {
describe("single group with internal connections", () => {
let graph: LGraph;
beforeEach(() => {
// nested-groups.json has groups with internal connections
// Group 1 "Step 1 - Load model" contains node 4 (CheckpointLoaderSimple)
graph = loadFixture("nested-groups.json");
});
it("organizes nodes within selected group", () => {
// Select group 1 which contains node 4
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBeGreaterThanOrEqual(0);
expect(result.groupCount).toBe(1);
assertFiniteCoordinates(graph);
});
it("resizes group to fit contents", () => {
layoutSelectedGroups(graph, new Set([1]));
const groupAfter = graph._groups.find((g) => g.id === 1);
// Group size should be finite and positive
expect(Number.isFinite(groupAfter!.size[0])).toBe(true);
expect(Number.isFinite(groupAfter!.size[1])).toBe(true);
expect(groupAfter!.size[0]).toBeGreaterThan(0);
expect(groupAfter!.size[1]).toBeGreaterThan(0);
});
it("preserves external node positions", () => {
// Capture positions of nodes NOT in group 1
const nodesBefore = new Map<number, [number, number]>();
for (const node of graph._nodes) {
// Node 4 is in group 1, all others should be unchanged
if (node.id !== 4) {
nodesBefore.set(node.id, [...node.pos]);
}
}
layoutSelectedGroups(graph, new Set([1]));
// Verify external nodes haven't moved
for (const [nodeId, posBefore] of nodesBefore) {
const node = graph._nodes.find((n) => n.id === nodeId);
expect(node).toBeDefined();
expect(node!.pos[0]).toBe(posBefore[0]);
expect(node!.pos[1]).toBe(posBefore[1]);
}
});
it("is idempotent", () => {
layoutSelectedGroups(graph, new Set([1]));
const positions1 = capturePositions(graph);
layoutSelectedGroups(graph, new Set([1]));
const positions2 = capturePositions(graph);
// Compare positions with small tolerance
for (const [id, pos1] of positions1) {
const pos2 = positions2.get(id);
if (pos2) {
expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5);
expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5);
}
}
});
});
describe("single group with disconnected nodes", () => {
let graph: LGraph;
beforeEach(() => {
// Create a graph with a group containing disconnected nodes
graph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Note 1",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Note 2",
pos: [150, 250],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 3,
type: "Note",
title: "External Node",
pos: [500, 500],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 350],
},
],
links: new Map(),
};
});
it("stacks disconnected nodes vertically", () => {
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBe(2);
expect(result.groupCount).toBe(1);
// Nodes should have same X (stacked vertically)
const node1 = graph._nodes.find((n) => n.id === 1)!;
const node2 = graph._nodes.find((n) => n.id === 2)!;
expect(node1.pos[0]).toBe(node2.pos[0]);
assertFiniteCoordinates(graph);
});
it("resizes group to fit stacked nodes", () => {
layoutSelectedGroups(graph, new Set([1]));
const group = graph._groups.find((g) => g.id === 1)!;
expect(Number.isFinite(group.size[0])).toBe(true);
expect(Number.isFinite(group.size[1])).toBe(true);
expect(group.size[0]).toBeGreaterThan(0);
expect(group.size[1]).toBeGreaterThan(0);
});
});
describe("multiple groups", () => {
let graph: LGraph;
beforeEach(() => {
graph = loadFixture("nested-groups.json");
});
it("organizes each group independently", () => {
// Select groups 1 and 2 (not nested)
const result = layoutSelectedGroups(graph, new Set([1, 2]));
expect(result.groupCount).toBeGreaterThanOrEqual(2);
assertFiniteCoordinates(graph);
});
it("handles non-overlapping groups", () => {
layoutSelectedGroups(graph, new Set([1, 2]));
// Both groups should have valid coordinates
const group1 = graph._groups.find((g) => g.id === 1)!;
const group2 = graph._groups.find((g) => g.id === 2)!;
expect(Number.isFinite(group1.pos[0])).toBe(true);
expect(Number.isFinite(group2.pos[0])).toBe(true);
});
});
describe("nested groups", () => {
let graph: LGraph;
beforeEach(() => {
// nested-groups.json has Group 3 containing Group 4 (nested)
graph = loadFixture("nested-groups.json");
});
it("auto-includes nested groups when parent selected", () => {
// Select group 3 which contains group 4
const result = layoutSelectedGroups(graph, new Set([3]));
// Should include the nested group
expect(result.groupCount).toBeGreaterThanOrEqual(1);
assertFiniteCoordinates(graph);
});
it("positions nested groups correctly", () => {
layoutSelectedGroups(graph, new Set([3]));
// Group 4 should still be inside Group 3
const group3 = graph._groups.find((g) => g.id === 3)!;
const group4 = graph._groups.find((g) => g.id === 4)!;
// Check group 4 is within group 3 bounds
expect(group4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
expect(group4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1]);
expect(group4.pos[0] + group4.size[0]).toBeLessThanOrEqual(
group3.pos[0] + group3.size[0]
);
expect(group4.pos[1] + group4.size[1]).toBeLessThanOrEqual(
group3.pos[1] + group3.size[1]
);
});
it("keeps nodes inside selected groups", () => {
layoutSelectedGroups(graph, new Set([3]));
// Nodes inside group 3 should remain inside it
assertNodesInsideGroups(graph);
});
});
describe("edge cases", () => {
it("handles empty graph", () => {
const graph: LGraph = {
_nodes: [],
_groups: [],
links: new Map(),
};
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result.nodeCount).toBe(0);
expect(result.groupCount).toBe(0);
});
it("handles non-existent group IDs", () => {
const graph = loadFixture("nested-groups.json");
const result = layoutSelectedGroups(graph, new Set([999]));
expect(result.groupCount).toBe(0);
});
it("handles empty groups", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "External",
pos: [500, 500],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Empty Group",
pos: [50, 50],
size: [200, 200],
},
],
links: new Map(),
};
// Should not crash
const result = layoutSelectedGroups(graph, new Set([1]));
// Empty group is still processed, just has no content
expect(result.nodeCount).toBe(0);
assertFiniteCoordinates(graph);
});
it("handles locked nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Locked Node",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
locked: true,
},
{
id: 2,
type: "Note",
title: "Normal Node",
pos: [100, 250],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 400],
},
],
links: new Map(),
};
const lockedPosBefore = [...graph._nodes[0].pos];
layoutSelectedGroups(graph, new Set([1]));
// Locked node should not move
expect(graph._nodes[0].pos[0]).toBe(lockedPosBefore[0]);
expect(graph._nodes[0].pos[1]).toBe(lockedPosBefore[1]);
});
it("handles group with only locked nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Locked Node",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
locked: true,
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [350, 250],
},
],
links: new Map(),
};
// Should not crash
const result = layoutSelectedGroups(graph, new Set([1]));
expect(result).toBeDefined();
});
});
describe("groups with internal connections", () => {
it("uses layer-based layout for connected nodes", () => {
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "NodeA",
title: "Node A",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
},
{
id: 2,
type: "NodeB",
title: "Node B",
pos: [100, 300],
size: [200, 100],
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test Group",
pos: [50, 50],
size: [400, 400],
},
],
links: new Map([
[
1,
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: "FLOAT",
},
],
]),
};
layoutSelectedGroups(graph, new Set([1]));
// With layer-based layout, source (node 1) should be left of target (node 2)
const node1 = graph._nodes.find((n) => n.id === 1)!;
const node2 = graph._nodes.find((n) => n.id === 2)!;
expect(node1.pos[0]).toBeLessThan(node2.pos[0]);
assertFiniteCoordinates(graph);
});
});
describe("parseLayoutToken", () => {
it("parses [HORIZONTAL] token", () => {
expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({
type: "horizontal",
});
});
it("parses [VERTICAL] token", () => {
expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" });
});
it("parses [xROW] tokens", () => {
expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 });
expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 });
});
it("parses [xCOL] tokens", () => {
expect(parseLayoutToken("Group [2COL]")).toEqual({
type: "columns",
count: 2,
});
expect(parseLayoutToken("[4COL] Test")).toEqual({
type: "columns",
count: 4,
});
});
it("treats [1ROW] as horizontal", () => {
expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" });
});
it("treats [1COL] as vertical", () => {
expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" });
});
it("is case-insensitive", () => {
expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" });
expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" });
expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 });
expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 });
});
it("returns default for no token", () => {
expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" });
expect(parseLayoutToken("")).toEqual({ type: "default" });
});
it("returns first matching token", () => {
// HORIZONTAL comes before xROW in parsing order
expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({
type: "horizontal",
});
});
});
describe("groups with layout tokens", () => {
function createTestGraph(groupTitle: string): LGraph {
return {
_nodes: [
{
id: 1,
type: "Note",
title: "Node 1",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Node 2",
pos: [100, 250],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 3,
type: "Note",
title: "Node 3",
pos: [100, 400],
size: [200, 100],
inputs: [],
outputs: [],
},
{
id: 4,
type: "Note",
title: "Node 4",
pos: [100, 550],
size: [200, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: groupTitle,
pos: [50, 50],
size: [500, 700],
},
],
links: new Map(),
};
}
it("[HORIZONTAL] arranges nodes in single row", () => {
const graph = createTestGraph("Test [HORIZONTAL]");
layoutSelectedGroups(graph, new Set([1]));
// All nodes should have same Y (single row)
const ys = graph._nodes.map((n) => n.pos[1]);
expect(new Set(ys).size).toBe(1);
// Nodes should be spread horizontally
const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b);
expect(xs[3]).toBeGreaterThan(xs[0]);
assertFiniteCoordinates(graph);
});
it("[VERTICAL] arranges nodes in single column", () => {
const graph = createTestGraph("Test [VERTICAL]");
layoutSelectedGroups(graph, new Set([1]));
// All nodes should have same X (single column)
const xs = graph._nodes.map((n) => n.pos[0]);
expect(new Set(xs).size).toBe(1);
// Nodes should be spread vertically
const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b);
expect(ys[3]).toBeGreaterThan(ys[0]);
assertFiniteCoordinates(graph);
});
it("[2ROW] distributes nodes into 2 rows", () => {
const graph = createTestGraph("Test [2ROW]");
layoutSelectedGroups(graph, new Set([1]));
// Should have exactly 2 distinct Y values (2 rows)
const ys = new Set(graph._nodes.map((n) => n.pos[1]));
expect(ys.size).toBe(2);
assertFiniteCoordinates(graph);
});
it("[3COL] distributes nodes into 3 columns", () => {
const graph = createTestGraph("Test [3COL]");
layoutSelectedGroups(graph, new Set([1]));
// Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution)
const xs = new Set(graph._nodes.map((n) => n.pos[0]));
expect(xs.size).toBe(3);
assertFiniteCoordinates(graph);
});
it("lowercase token works", () => {
const graph = createTestGraph("Test [horizontal]");
layoutSelectedGroups(graph, new Set([1]));
// All nodes should have same Y (single row)
const ys = graph._nodes.map((n) => n.pos[1]);
expect(new Set(ys).size).toBe(1);
assertFiniteCoordinates(graph);
});
it("ignores DAG structure with token", () => {
// Create graph with connected nodes
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "NodeA",
title: "Node A",
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
},
{
id: 2,
type: "NodeB",
title: "Node B",
pos: [100, 300],
size: [200, 100],
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Test [HORIZONTAL]",
pos: [50, 50],
size: [500, 400],
},
],
links: new Map([
[
1,
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: "FLOAT",
},
],
]),
};
layoutSelectedGroups(graph, new Set([1]));
// With [HORIZONTAL], both nodes should have same Y regardless of connection
const node1 = graph._nodes.find((n) => n.id === 1)!;
const node2 = graph._nodes.find((n) => n.id === 2)!;
expect(node1.pos[1]).toBe(node2.pos[1]);
assertFiniteCoordinates(graph);
});
it("nested groups respect their own tokens", () => {
const graph: LGraph = {
_nodes: [
// Nodes in outer group (not in inner)
{
id: 1,
type: "Note",
title: "Outer 1",
pos: [100, 100],
size: [100, 100],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Outer 2",
pos: [100, 250],
size: [100, 100],
inputs: [],
outputs: [],
},
// Nodes in inner group
{
id: 3,
type: "Note",
title: "Inner 1",
pos: [350, 150],
size: [100, 100],
inputs: [],
outputs: [],
},
{
id: 4,
type: "Note",
title: "Inner 2",
pos: [350, 300],
size: [100, 100],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Outer [HORIZONTAL]",
pos: [50, 50],
size: [600, 500],
},
{
id: 2,
title: "Inner [VERTICAL]",
pos: [300, 100],
size: [200, 350],
},
],
links: new Map(),
};
layoutSelectedGroups(graph, new Set([1]));
// Inner group nodes should have same X (vertical)
const inner1 = graph._nodes.find((n) => n.id === 3)!;
const inner2 = graph._nodes.find((n) => n.id === 4)!;
expect(inner1.pos[0]).toBe(inner2.pos[0]);
assertFiniteCoordinates(graph);
});
it("resizes group to fit token-arranged contents", () => {
const graph = createTestGraph("Test [HORIZONTAL]");
layoutSelectedGroups(graph, new Set([1]));
const group = graph._groups.find((g) => g.id === 1)!;
// Group should be wider than tall (horizontal layout)
// With 4 nodes of 200px width + gaps, width should be significant
expect(group.size[0]).toBeGreaterThan(600);
assertFiniteCoordinates(graph);
});
});
describe("nested group + disconnected nodes overlap regression (group-test-simple.json)", () => {
it("positions nested groups below disconnected nodes (no token)", () => {
const graph = loadFixture("group-test-simple.json");
layoutSelectedGroups(graph, new Set([1])); // "Group Wrapper"
const innerGroup = graph._groups.find((g) => g.id === 2)!;
const node3 = graph._nodes.find((n) => n.id === 3)!;
const node4 = graph._nodes.find((n) => n.id === 4)!;
// Inner group should be BELOW the preview images
const node3Bottom = node3.pos[1] + node3.size[1];
const node4Bottom = node4.pos[1] + node4.size[1];
const membersBottom = Math.max(node3Bottom, node4Bottom);
expect(innerGroup.pos[1]).toBeGreaterThan(membersBottom);
});
it("maintains no overlaps after default layout", () => {
const graph = loadFixture("group-test-simple.json");
layoutSelectedGroups(graph, new Set([1]));
assertNodesInsideGroups(graph);
assertFiniteCoordinates(graph);
});
it("is idempotent with nested groups in default mode", () => {
const graph = loadFixture("group-test-simple.json");
layoutSelectedGroups(graph, new Set([1]));
const positions1 = capturePositions(graph);
layoutSelectedGroups(graph, new Set([1]));
const positions2 = capturePositions(graph);
// Compare positions with small tolerance
for (const [id, pos1] of positions1) {
const pos2 = positions2.get(id);
if (pos2) {
expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5);
expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5);
}
}
});
it("multiple sibling nested groups stack vertically", () => {
const graph: LGraph = {
_nodes: [
// Disconnected node in parent
{
id: 1,
type: "Note",
title: "Parent Node",
pos: [100, 100] as [number, number],
size: [200, 100] as [number, number],
inputs: [],
outputs: [],
},
// Node in first nested group
{
id: 2,
type: "Note",
title: "Nested 1 Node",
pos: [100, 300] as [number, number],
size: [200, 100] as [number, number],
inputs: [],
outputs: [],
},
// Node in second nested group
{
id: 3,
type: "Note",
title: "Nested 2 Node",
pos: [100, 500] as [number, number],
size: [200, 100] as [number, number],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Parent Group",
pos: [50, 50] as [number, number],
size: [400, 600] as [number, number],
},
{
id: 2,
title: "Nested 1",
pos: [80, 250] as [number, number],
size: [300, 180] as [number, number],
},
{
id: 3,
title: "Nested 2",
pos: [80, 450] as [number, number],
size: [300, 180] as [number, number],
},
],
links: new Map(),
};
layoutSelectedGroups(graph, new Set([1]));
const nested1 = graph._groups.find((g) => g.id === 2)!;
const nested2 = graph._groups.find((g) => g.id === 3)!;
const parentNode = graph._nodes.find((n) => n.id === 1)!;
// Both nested groups should be below the parent node
const parentNodeBottom = parentNode.pos[1] + parentNode.size[1];
expect(nested1.pos[1]).toBeGreaterThan(parentNodeBottom);
expect(nested2.pos[1]).toBeGreaterThan(parentNodeBottom);
// Nested groups should not overlap each other
const nested1Bottom = nested1.pos[1] + nested1.size[1];
expect(nested2.pos[1]).toBeGreaterThanOrEqual(nested1Bottom);
assertFiniteCoordinates(graph);
});
});
describe("nested group position update regression (group-test.json)", () => {
it("nested group position updates when parent has token layout", () => {
const graph = loadFixture("group-test.json");
// Group 1 "Previews [2ROW]" contains nested Group 3 "Preview Nodes [HORIZONTAL]"
// Nodes 3, 4 are inside Group 3
layoutSelectedGroups(graph, new Set([1]));
// Group 3 should be repositioned to contain its member nodes
const group3 = graph._groups.find((g) => g.id === 3)!;
const node3 = graph._nodes.find((n) => n.id === 3)!;
const node4 = graph._nodes.find((n) => n.id === 4)!;
// Nodes must be inside group bounds (x >= group.x, y >= group.y + title_height)
const GROUP_TITLE_HEIGHT = 50;
expect(node3.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
expect(node3.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT);
expect(node4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
expect(node4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT);
// Nodes must be inside group bounds (right/bottom edges)
expect(node3.pos[0] + node3.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]);
expect(node3.pos[1] + node3.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]);
expect(node4.pos[0] + node4.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]);
expect(node4.pos[1] + node4.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]);
assertFiniteCoordinates(graph);
});
it("maintains node-group containment after [2ROW] token layout", () => {
const graph = loadFixture("group-test.json");
layoutSelectedGroups(graph, new Set([1]));
assertNodesInsideGroups(graph);
assertFiniteCoordinates(graph);
});
it("updates bounding array when translating nested group", () => {
// Simulate production environment where groups have bounding array
const graph: LGraph = {
_nodes: [
{
id: 1,
type: "Note",
title: "Note 1",
pos: [180, 180] as [number, number],
size: [200, 100] as [number, number],
inputs: [],
outputs: [],
},
{
id: 2,
type: "Note",
title: "Note 2",
pos: [500, 180] as [number, number],
size: [200, 100] as [number, number],
inputs: [],
outputs: [],
},
],
_groups: [
{
id: 1,
title: "Outer [HORIZONTAL]",
pos: [100, 50] as [number, number],
size: [700, 300] as [number, number],
},
{
id: 2,
title: "Inner Group",
pos: [450, 130] as [number, number],
size: [300, 200] as [number, number],
},
],
links: new Map(),
};
// Add bounding arrays to simulate ComfyUI runtime format
const group1 = graph._groups[0] as typeof graph._groups[0] & { bounding: number[] };
const group2 = graph._groups[1] as typeof graph._groups[1] & { bounding: number[] };
group1.bounding = [100, 50, 700, 300];
group2.bounding = [450, 130, 300, 200];
layoutSelectedGroups(graph, new Set([1]));
// After layout, bounding should be updated to match pos
expect(group2.bounding[0]).toBe(group2.pos[0]);
expect(group2.bounding[1]).toBe(group2.pos[1]);
expect(group2.bounding[2]).toBe(group2.size[0]);
expect(group2.bounding[3]).toBe(group2.size[1]);
assertFiniteCoordinates(graph);
});
});
});
-135
View File
@@ -1,135 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { loadFixture } from "../helpers";
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
import { layoutGraph } from "../../src/layout/index";
import { assertFiniteCoordinates } from "../invariants";
import type { LGraph } from "../../src/layout/types";
/**
* Regression tests for token-based layout issues
* Based on real workflow: token-testing.json
*/
describe("token layout regression", () => {
let graph: LGraph;
beforeEach(() => {
graph = loadFixture("token-testing.json");
});
describe("[1ROW] ordering", () => {
// Group 8 "[1ROW]" contains nodes 41, 42 (GetNode), 43, 44 (PreviewImage), 47 (Note)
// GetNodes are originally at X=3290, PreviewImages at X=3600
it("preserves left-to-right order (sources before targets)", () => {
layoutSelectedGroups(graph, new Set([8]));
const node41 = graph._nodes.find((n) => n.id === 41)!; // GetNode
const node42 = graph._nodes.find((n) => n.id === 42)!; // GetNode
const node43 = graph._nodes.find((n) => n.id === 43)!; // PreviewImage
const node44 = graph._nodes.find((n) => n.id === 44)!; // PreviewImage
// GetNodes (originally left at X=3290) should remain left of PreviewImages (X=3600)
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
expect(node42.pos[0]).toBeLessThan(node44.pos[0]);
assertFiniteCoordinates(graph);
});
it("places all nodes at same Y in horizontal row", () => {
layoutSelectedGroups(graph, new Set([8]));
const groupNodes = graph._nodes.filter((n) =>
[41, 42, 43, 44, 47].includes(n.id)
);
const ys = new Set(groupNodes.map((n) => n.pos[1]));
// All nodes should have same Y (single row)
expect(ys.size).toBe(1);
});
});
describe("[3COL] with nested groups", () => {
// Group 9 "Full Workflow Group [3COL]" contains nested group 10 "[HORIZONTAL]"
// Group 10 contains nodes 55, 56, 57 (isolated notes)
it("includes nested groups in column arrangement", () => {
layoutSelectedGroups(graph, new Set([9]));
const group10 = graph._groups.find((g) => g.id === 10)!;
// Nested group should be positioned
expect(group10.pos[0]).toBeGreaterThan(0);
expect(group10.pos[1]).toBeGreaterThan(0);
assertFiniteCoordinates(graph);
});
it("nested group respects its own token when selected directly", () => {
// Test group 10 "[HORIZONTAL]" directly
layoutSelectedGroups(graph, new Set([10]));
const innerNodes = graph._nodes.filter((n) =>
[55, 56, 57].includes(n.id)
);
const ys = new Set(innerNodes.map((n) => n.pos[1]));
// Inner nodes should all be at same Y (horizontal)
expect(ys.size).toBe(1);
});
it("does not overlap nested groups with other content", () => {
layoutSelectedGroups(graph, new Set([9]));
const group10 = graph._groups.find((g) => g.id === 10)!;
// Group 10 should have valid bounds
expect(group10.size[0]).toBeGreaterThan(0);
expect(group10.size[1]).toBeGreaterThan(0);
assertFiniteCoordinates(graph);
});
});
describe("Organize Workflow with tokens", () => {
it("respects group tokens in full workflow layout", () => {
layoutGraph(graph);
// Group 8 "[1ROW]" - nodes should be at same Y
const group8Nodes = graph._nodes.filter((n) =>
[41, 42, 43, 44, 47].includes(n.id)
);
const g8Ys = new Set(group8Nodes.map((n) => n.pos[1]));
expect(g8Ys.size).toBe(1);
assertFiniteCoordinates(graph);
});
it("preserves ordering in workflow layout", () => {
layoutGraph(graph);
// In "[1ROW]" group, GetNodes should be left of PreviewImages
const node41 = graph._nodes.find((n) => n.id === 41)!;
const node43 = graph._nodes.find((n) => n.id === 43)!;
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
});
});
describe("[HORIZONTAL] token", () => {
// Group 7 "[HORIZONTAL]" contains nodes 32, 33, 34, 35 (PreviewImage nodes)
it("arranges nodes horizontally", () => {
layoutSelectedGroups(graph, new Set([7]));
const nodes = graph._nodes.filter((n) =>
[32, 33, 34, 35].includes(n.id)
);
const ys = new Set(nodes.map((n) => n.pos[1]));
// All nodes should have same Y
expect(ys.size).toBe(1);
assertFiniteCoordinates(graph);
});
});
});
-384
View File
@@ -1,384 +0,0 @@
import { expect } from "vitest";
import type { LGraph, LGraphNode, LGraphGroup } from "../src/layout/types";
import { capturePositions } from "./helpers";
import { layoutGraph } from "../src/layout/index";
/** Bounding box for collision detection */
interface BoundingBox {
id: string;
x: number;
y: number;
width: number;
height: number;
}
/**
* Check if two bounding boxes overlap (AABB collision)
* Uses a small gap tolerance to avoid false positives from floating point
*/
function boxesOverlap(a: BoundingBox, b: BoundingBox, gap = 0): boolean {
return (
a.x < b.x + b.width + gap &&
a.x + a.width + gap > b.x &&
a.y < b.y + b.height + gap &&
a.y + a.height + gap > b.y
);
}
/**
* Get all entities (nodes, groups, and I/O nodes) as bounding boxes
*/
function getAllEntities(graph: LGraph): BoundingBox[] {
const entities: BoundingBox[] = [];
for (const node of graph._nodes) {
entities.push({
id: `node_${node.id}`,
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
});
}
for (const group of graph._groups) {
entities.push({
id: `group_${group.id}`,
x: group.pos[0],
y: group.pos[1],
width: group.size[0],
height: group.size[1],
});
}
// Include subgraph I/O nodes if present
if (graph.inputNode?.pos && graph.inputNode?.size) {
entities.push({
id: `io_${graph.inputNode.id}`,
x: graph.inputNode.pos[0],
y: graph.inputNode.pos[1],
width: graph.inputNode.size[0],
height: graph.inputNode.size[1],
});
}
if (graph.outputNode?.pos && graph.outputNode?.size) {
entities.push({
id: `io_${graph.outputNode.id}`,
x: graph.outputNode.pos[0],
y: graph.outputNode.pos[1],
width: graph.outputNode.size[0],
height: graph.outputNode.size[1],
});
}
return entities;
}
/**
* Determine which nodes are inside which groups based on position
*/
function getNodesInsideGroups(graph: LGraph): Map<number, LGraphGroup> {
const nodeToGroup = new Map<number, LGraphGroup>();
for (const node of graph._nodes) {
const nodeX = node.pos[0];
const nodeY = node.pos[1];
const nodeRight = nodeX + node.size[0];
const nodeBottom = nodeY + node.size[1];
// Find the smallest group that contains this node
let bestGroup: LGraphGroup | undefined;
let bestArea = Infinity;
for (const group of graph._groups) {
const groupX = group.pos[0];
const groupY = group.pos[1];
const groupRight = groupX + group.size[0];
const groupBottom = groupY + group.size[1];
// Check if node is inside group bounds
if (
nodeX >= groupX &&
nodeY >= groupY &&
nodeRight <= groupRight &&
nodeBottom <= groupBottom
) {
const area = group.size[0] * group.size[1];
if (area < bestArea) {
bestArea = area;
bestGroup = group;
}
}
}
if (bestGroup) {
nodeToGroup.set(node.id, bestGroup);
}
}
return nodeToGroup;
}
/**
* Check if a node bounding box is fully inside a group bounding box
*/
function isNodeInsideGroup(node: BoundingBox, group: BoundingBox): boolean {
return (
node.x >= group.x &&
node.y >= group.y &&
node.x + node.width <= group.x + group.width &&
node.y + node.height <= group.y + group.height
);
}
/**
* Assert no two standalone entities overlap
* Nodes inside groups are excluded from overlap checks with their containing groups
* (including parent groups in nested group hierarchies)
*/
export function assertNoOverlaps(graph: LGraph): void {
const entities = getAllEntities(graph);
const overlaps: string[] = [];
for (let i = 0; i < entities.length; i++) {
for (let j = i + 1; j < entities.length; j++) {
const a = entities[i];
const b = entities[j];
// Skip node-vs-group overlap if node is fully inside that group
// This handles nested groups: a node inside inner group is also inside outer group
if (a.id.startsWith("node_") && b.id.startsWith("group_")) {
if (isNodeInsideGroup(a, b)) continue;
}
if (b.id.startsWith("node_") && a.id.startsWith("group_")) {
if (isNodeInsideGroup(b, a)) continue;
}
// Skip group-vs-group overlap if one contains the other (nested groups)
if (a.id.startsWith("group_") && b.id.startsWith("group_")) {
// Check if one fully contains the other
const aContainsB =
a.x <= b.x &&
a.y <= b.y &&
a.x + a.width >= b.x + b.width &&
a.y + a.height >= b.y + b.height;
const bContainsA =
b.x <= a.x &&
b.y <= a.y &&
b.x + b.width >= a.x + a.width &&
b.y + b.height >= a.y + a.height;
if (aContainsB || bContainsA) continue;
}
if (boxesOverlap(a, b)) {
overlaps.push(`${a.id} overlaps ${b.id}`);
}
}
}
expect(overlaps, `Found ${overlaps.length} overlapping entities`).toEqual([]);
}
/**
* Assert all nodes that were inside groups remain inside their groups
* This catches bug 0008 where nodes ended up outside their groups
*/
export function assertNodesInsideGroups(graph: LGraph): void {
const errors: string[] = [];
for (const group of graph._groups) {
const groupX = group.pos[0];
const groupY = group.pos[1];
const groupRight = groupX + group.size[0];
const groupBottom = groupY + group.size[1];
// Find nodes that should be inside this group (based on current containment)
for (const node of graph._nodes) {
const nodeX = node.pos[0];
const nodeY = node.pos[1];
const nodeRight = nodeX + node.size[0];
const nodeBottom = nodeY + node.size[1];
// Check if node center is inside group
const nodeCenterX = nodeX + node.size[0] / 2;
const nodeCenterY = nodeY + node.size[1] / 2;
if (
nodeCenterX >= groupX &&
nodeCenterX <= groupRight &&
nodeCenterY >= groupY &&
nodeCenterY <= groupBottom
) {
// Node center is inside group - verify entire node fits
if (nodeX < groupX || nodeY < groupY || nodeRight > groupRight || nodeBottom > groupBottom) {
errors.push(
`Node ${node.id} (${node.title}) extends outside group "${group.title}": ` +
`node [${nodeX}, ${nodeY}, ${nodeRight}, ${nodeBottom}] vs group [${groupX}, ${groupY}, ${groupRight}, ${groupBottom}]`
);
}
}
}
}
expect(errors, `Found ${errors.length} nodes outside their groups`).toEqual([]);
}
/**
* Assert all coordinates are finite numbers
* Note: Negative coordinates are allowed since some workflows may position nodes above/left of origin
* This catches NaN and Infinity bugs
*/
export function assertFiniteCoordinates(graph: LGraph): void {
const errors: string[] = [];
for (const node of graph._nodes) {
if (!Number.isFinite(node.pos[0]) || !Number.isFinite(node.pos[1])) {
errors.push(`Node ${node.id} has non-finite position: [${node.pos[0]}, ${node.pos[1]}]`);
}
}
for (const group of graph._groups) {
if (!Number.isFinite(group.pos[0]) || !Number.isFinite(group.pos[1])) {
errors.push(`Group ${group.id} has non-finite position: [${group.pos[0]}, ${group.pos[1]}]`);
}
if (!Number.isFinite(group.size[0]) || !Number.isFinite(group.size[1])) {
errors.push(`Group ${group.id} has non-finite size: [${group.size[0]}, ${group.size[1]}]`);
}
}
// Check subgraph I/O nodes if present
if (graph.inputNode?.pos) {
if (!Number.isFinite(graph.inputNode.pos[0]) || !Number.isFinite(graph.inputNode.pos[1])) {
errors.push(`I/O node ${graph.inputNode.id} has non-finite position: [${graph.inputNode.pos[0]}, ${graph.inputNode.pos[1]}]`);
}
}
if (graph.outputNode?.pos) {
if (!Number.isFinite(graph.outputNode.pos[0]) || !Number.isFinite(graph.outputNode.pos[1])) {
errors.push(`I/O node ${graph.outputNode.id} has non-finite position: [${graph.outputNode.pos[0]}, ${graph.outputNode.pos[1]}]`);
}
}
expect(errors, `Found ${errors.length} invalid coordinates`).toEqual([]);
}
/**
* Assert topological order is preserved: if A→B link exists, A must be positioned left of B
* This catches fundamental DAG ordering bugs
*/
export function assertTopologicalOrder(graph: LGraph): void {
const errors: string[] = [];
const nodeMap = new Map<number, LGraphNode>();
for (const node of graph._nodes) {
nodeMap.set(node.id, node);
}
// Check each link
const links = graph.links instanceof Map ? graph.links.values() : Object.values(graph.links);
for (const link of links) {
const sourceNode = nodeMap.get(link.origin_id);
const targetNode = nodeMap.get(link.target_id);
if (!sourceNode || !targetNode) continue;
// Skip reroute nodes (they're positioned along edges)
if (sourceNode.type === "Reroute" || targetNode.type === "Reroute") continue;
// Source should be left of target (or at same X if in same layer)
const sourceRight = sourceNode.pos[0] + sourceNode.size[0];
if (sourceRight > targetNode.pos[0] + targetNode.size[0]) {
errors.push(
`Link ${link.id}: source node ${sourceNode.id} (${sourceNode.title}) at X=${sourceNode.pos[0]} ` +
`is right of target node ${targetNode.id} (${targetNode.title}) at X=${targetNode.pos[0]}`
);
}
}
expect(errors, `Found ${errors.length} topological order violations`).toEqual([]);
}
/**
* Assert layout is idempotent: running twice produces same positions
* This catches oscillation bugs like 0011
*/
export function assertIdempotent(graph: LGraph): void {
// Capture positions after first layout (already run by caller)
const positions1 = capturePositions(graph);
// Run layout again
layoutGraph(graph);
// Capture positions after second layout
const positions2 = capturePositions(graph);
// Compare
const differences: string[] = [];
for (const [id, pos1] of positions1) {
const pos2 = positions2.get(id);
if (!pos2) {
differences.push(`${id} missing after second layout`);
continue;
}
// Allow small movements (up to 5 pixels) due to rounding in group resize/centering
const tolerance = 5;
if (
Math.abs(pos1.x - pos2.x) > tolerance ||
Math.abs(pos1.y - pos2.y) > tolerance
) {
differences.push(
`${id} moved: [${pos1.x.toFixed(1)}, ${pos1.y.toFixed(1)}] → [${pos2.x.toFixed(1)}, ${pos2.y.toFixed(1)}]`
);
}
}
expect(differences, `Layout is not idempotent: ${differences.length} entities moved`).toEqual([]);
}
/**
* Assert that nodes which were inside groups before layout remain inside their groups after
* This catches regressions where layout breaks group containment
* Must be called with graph state BEFORE and AFTER layout
*/
export function assertGroupMembershipPreserved(
originalGraph: LGraph,
layoutedGraph: LGraph
): void {
const errors: string[] = [];
// Get which nodes were inside which groups BEFORE layout
const beforeMembership = getNodesInsideGroups(originalGraph);
// For each node that was in a group, verify it's still in that group after layout
for (const [nodeId, originalGroup] of beforeMembership) {
const node = layoutedGraph._nodes.find((n) => n.id === nodeId);
const group = layoutedGraph._groups.find((g) => g.id === originalGroup.id);
if (!node || !group) continue;
// Check if node is still fully inside the group
const nodeRight = node.pos[0] + node.size[0];
const nodeBottom = node.pos[1] + node.size[1];
const groupRight = group.pos[0] + group.size[0];
const groupBottom = group.pos[1] + group.size[1];
if (
node.pos[0] < group.pos[0] ||
node.pos[1] < group.pos[1] ||
nodeRight > groupRight ||
nodeBottom > groupBottom
) {
errors.push(
`Node ${nodeId} (${node.title}) was in group "${originalGroup.title}" but is now outside: ` +
`node [${node.pos[0].toFixed(0)}, ${node.pos[1].toFixed(0)}] vs group [${group.pos[0].toFixed(0)}, ${group.pos[1].toFixed(0)}, ${group.size[0].toFixed(0)}, ${group.size[1].toFixed(0)}]`
);
}
}
expect(
errors,
`Found ${errors.length} nodes that moved outside their original groups`
).toEqual([]);
}
+744
View File
@@ -0,0 +1,744 @@
/**
* Tests for src/adapter.ts — the bridge between ComfyUI graph objects
* and the pure layout framework types.
*/
import { describe, it, expect } from "vitest";
import {
extractLayoutInput,
extractGroupLayoutInput,
applyLayoutOutput,
anchorSelectedGroupLayoutResult,
} from "../../src/adapter";
import type { GraphLike } from "../../src/adapter";
import type {
Position,
GroupBounds,
LayoutGroup,
FrameworkResult,
} from "../../src/layout/types";
// ---------------------------------------------------------------------------
// Helper: create a minimal graph-like object
// ---------------------------------------------------------------------------
function makeGraph(opts: {
nodes?: Array<{
id: number;
type: string;
title: string;
pos: number[];
size: number[];
}>;
inputNode?: {
id: number;
pos: number[];
size: number[];
};
outputNode?: {
id: number;
pos: number[];
size: number[];
};
groups?: Array<{
id: number;
title: string;
pos: number[];
size: number[];
childrenIds?: number[];
includeChildrenSet?: boolean;
}>;
links?: Array<{
id: number;
origin_id: number;
target_id: number;
}>;
}): GraphLike {
const nodes = (opts.nodes ?? []).map((n) => ({
id: n.id,
type: n.type,
title: n.title,
pos: [...n.pos],
size: [...n.size],
inputs: [],
outputs: [],
}));
const groups = (opts.groups ?? []).map((g) => {
const childrenIdSet = new Set(g.childrenIds ?? []);
// _children includes all nodes/groups spatially inside
const children =
g.includeChildrenSet === false
? undefined
: new Set(
nodes
.filter((n) => isInsideGroup(n, g))
.map((n) => ({ id: n.id })),
);
if (children) {
for (const cid of childrenIdSet) {
children.add({ id: cid });
}
}
return {
id: g.id,
title: g.title,
pos: [...g.pos],
size: [...g.size],
_children: children as ReadonlySet<{ readonly id: number }> | undefined,
};
});
const linksMap = new Map<
number,
{ id: number; origin_id: number; target_id: number }
>();
for (const link of opts.links ?? []) {
linksMap.set(link.id, link);
}
const graph = {
_nodes: nodes,
_groups: groups,
links: linksMap,
setDirtyCanvas: undefined,
};
if (opts.inputNode) {
Object.assign(graph, {
inputNode: {
id: opts.inputNode.id,
pos: [...opts.inputNode.pos],
size: [...opts.inputNode.size],
},
});
}
if (opts.outputNode) {
Object.assign(graph, {
outputNode: {
id: opts.outputNode.id,
pos: [...opts.outputNode.pos],
size: [...opts.outputNode.size],
},
});
}
return graph as GraphLike;
}
function isInsideGroup(
node: { pos: number[]; size: number[] },
group: { pos: number[]; size: number[] },
): boolean {
return (
node.pos[0] >= group.pos[0] &&
node.pos[1] >= group.pos[1] &&
node.pos[0] + node.size[0] <= group.pos[0] + group.size[0] &&
node.pos[1] + node.size[1] <= group.pos[1] + group.size[1]
);
}
// ---------------------------------------------------------------------------
// extractLayoutInput
// ---------------------------------------------------------------------------
describe("extractLayoutInput", () => {
it("extracts nodes with correct ids and dimensions", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "KSampler", title: "Sampler", pos: [0, 0], size: [200, 100] },
{ id: 2, type: "VAEDecode", title: "Decode", pos: [300, 0], size: [150, 80] },
],
});
const { nodes } = extractLayoutInput(graph);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toEqual({ id: "1", width: 200, height: 100 });
expect(nodes[1]).toEqual({ id: "2", width: 150, height: 80 });
});
it("extracts edges from links map", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [200, 0], size: [100, 50] },
],
links: [{ id: 1, origin_id: 1, target_id: 2 }],
});
const { edges } = extractLayoutInput(graph);
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({ source: "1", target: "2" });
});
it("returns empty arrays for an empty graph", () => {
const graph = makeGraph({});
const { nodes, edges, groups } = extractLayoutInput(graph);
expect(nodes).toHaveLength(0);
expect(edges).toHaveLength(0);
expect(groups).toHaveLength(0);
});
it("builds group hierarchy from spatial containment", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
],
groups: [
{ id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] },
{ id: 101, title: "Inner", pos: [100, 100], size: [300, 200] },
],
});
const { groups } = extractLayoutInput(graph);
// Outer group should list Inner as child
const outer = groups.find((g) => g.id === "group:100");
expect(outer).toBeDefined();
expect(outer!.childGroupIds).toContain("group:101");
// Inner group should contain node 1, outer should contain node 2 (not in inner)
const inner = groups.find((g) => g.id === "group:101");
expect(inner).toBeDefined();
expect(inner!.memberIds).toContain("1");
expect(inner!.childGroupIds).toHaveLength(0);
// Node 2 is in outer but not in inner
expect(outer!.memberIds).toContain("2");
expect(outer!.memberIds).not.toContain("1"); // 1 is in inner, not direct member of outer
});
it("infers group members from spatial containment when _children is missing", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
],
groups: [
{
id: 100,
title: "Outer",
pos: [0, 0],
size: [1000, 500],
includeChildrenSet: false,
},
{
id: 101,
title: "Inner",
pos: [100, 100],
size: [300, 200],
includeChildrenSet: false,
},
],
});
const { groups } = extractLayoutInput(graph);
const outer = groups.find((group) => group.id === "group:100");
const inner = groups.find((group) => group.id === "group:101");
expect(inner?.memberIds).toEqual(["1"]);
expect(outer?.memberIds).toEqual(["2"]);
expect(outer?.childGroupIds).toEqual(["group:101"]);
});
it("treats a node whose center is inside a group as a member", () => {
const graph = makeGraph({
nodes: [
{
id: 1,
type: "PreviewImage",
title: "Preview",
pos: [120, 50],
size: [120, 120],
},
],
groups: [
{
id: 100,
title: "Wrapper",
pos: [100, 100],
size: [100, 100],
includeChildrenSet: false,
},
],
});
const { groups } = extractLayoutInput(graph);
expect(groups[0]?.memberIds).toEqual(["1"]);
});
it("excludes a node whose center is outside the group", () => {
const graph = makeGraph({
nodes: [
{
id: 1,
type: "PreviewImage",
title: "Preview",
pos: [10, 50],
size: [120, 120],
},
],
groups: [
{
id: 100,
title: "Wrapper",
pos: [100, 100],
size: [100, 100],
includeChildrenSet: false,
},
],
});
const { groups } = extractLayoutInput(graph);
expect(groups[0]?.memberIds).toEqual([]);
});
it("assigns the nearest containing group as parent", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [140, 140], size: [80, 40] },
],
groups: [
{
id: 100,
title: "Outer",
pos: [0, 0],
size: [1000, 1000],
includeChildrenSet: false,
},
{
id: 101,
title: "Middle",
pos: [100, 100],
size: [400, 400],
includeChildrenSet: false,
},
{
id: 102,
title: "Inner",
pos: [120, 120],
size: [200, 200],
includeChildrenSet: false,
},
],
});
const { groups } = extractLayoutInput(graph);
const outer = groups.find((group) => group.id === "group:100");
const middle = groups.find((group) => group.id === "group:101");
const inner = groups.find((group) => group.id === "group:102");
expect(outer?.childGroupIds).toEqual(["group:101"]);
expect(middle?.childGroupIds).toEqual(["group:102"]);
expect(inner?.memberIds).toEqual(["1"]);
});
it("namespaces group ids to avoid collisions with node ids", () => {
const graph = makeGraph({
nodes: [
{ id: 3, type: "KSampler", title: "KSampler", pos: [500, 100], size: [100, 50] },
],
groups: [
{
id: 3,
title: "Prompt Group",
pos: [0, 0],
size: [1000, 500],
includeChildrenSet: false,
},
],
});
const { nodes, groups } = extractLayoutInput(graph);
expect(nodes[0].id).toBe("3");
expect(groups[0].id).toBe("group:3");
});
it("parses layout tokens from group titles", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
],
groups: [
{
id: 100,
title: "My Group [HORIZONTAL]",
pos: [0, 0],
size: [500, 300],
},
],
});
const { groups } = extractLayoutInput(graph);
expect(groups[0].token).toBeDefined();
expect(groups[0].id).toBe("group:100");
expect(groups[0].token!.mode).toBe("horizontal");
});
it("handles groups with no token in title", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
],
groups: [
{ id: 100, title: "Plain Group", pos: [0, 0], size: [500, 300] },
],
});
const { groups } = extractLayoutInput(graph);
expect(groups[0].token).toBeUndefined();
});
it("handles links stored as Record (old ComfyUI format)", () => {
const nodes = [
{
id: 1,
type: "A",
title: "A",
pos: [0, 0],
size: [100, 50],
inputs: [] as Array<{ link: number | null }>,
outputs: [] as Array<{ links: number[] | null }>,
},
{
id: 2,
type: "B",
title: "B",
pos: [200, 0],
size: [100, 50],
inputs: [] as Array<{ link: number | null }>,
outputs: [] as Array<{ links: number[] | null }>,
},
];
// Use Record instead of Map for links
const linksRecord: Record<number, { id: number; origin_id: number; target_id: number }> = {
1: { id: 1, origin_id: 1, target_id: 2 },
};
const graph: GraphLike = {
_nodes: nodes,
_groups: [],
links: linksRecord as GraphLike["links"],
};
const { edges } = extractLayoutInput(graph);
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({ source: "1", target: "2" });
});
it("includes subgraph boundary nodes in extracted layout input", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
],
inputNode: { id: -10, pos: [-300, 0], size: [120, 220] },
outputNode: { id: -20, pos: [300, 0], size: [120, 60] },
links: [
{ id: 1, origin_id: -10, target_id: 1 },
{ id: 2, origin_id: 1, target_id: -20 },
],
});
const { nodes, edges } = extractLayoutInput(graph);
expect(nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "-10", width: 120, height: 220 }),
expect.objectContaining({ id: "1", width: 100, height: 50 }),
expect.objectContaining({ id: "-20", width: 120, height: 60 }),
]),
);
expect(edges).toEqual(
expect.arrayContaining([
{ source: "-10", target: "1" },
{ source: "1", target: "-20" },
]),
);
});
});
// ---------------------------------------------------------------------------
// applyLayoutOutput
// ---------------------------------------------------------------------------
describe("applyLayoutOutput", () => {
it("applies node positions from layout result", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [0, 0], size: [100, 50] },
],
});
const positions = new Map<string, Position>([
["1", { x: 100, y: 200 }],
["2", { x: 300, y: 400 }],
]);
const groupBounds = new Map<string, GroupBounds>();
applyLayoutOutput(graph, { positions, groupBounds });
expect(graph._nodes[0].pos[0]).toBe(100);
expect(graph._nodes[0].pos[1]).toBe(200);
expect(graph._nodes[1].pos[0]).toBe(300);
expect(graph._nodes[1].pos[1]).toBe(400);
});
it("applies group bounds from layout result", () => {
const graph = makeGraph({
groups: [
{ id: 100, title: "Group", pos: [0, 0], size: [100, 100] },
],
});
const positions = new Map<string, Position>();
const groupBounds = new Map<string, GroupBounds>([
["group:100", { x: 50, y: 60, width: 500, height: 300 }],
]);
applyLayoutOutput(graph, { positions, groupBounds });
expect(graph._groups[0].pos[0]).toBe(50);
expect(graph._groups[0].pos[1]).toBe(60);
expect(graph._groups[0].size[0]).toBe(500);
expect(graph._groups[0].size[1]).toBe(300);
});
it("mutates Float64Array in-place", () => {
const node = {
id: 1,
type: "A",
title: "A",
pos: new Float64Array([0, 0]),
size: new Float64Array([100, 50]),
inputs: [] as Array<{ link: number | null }>,
outputs: [] as Array<{ links: number[] | null }>,
};
const graph: GraphLike = {
_nodes: [node],
_groups: [],
links: new Map(),
};
const positions = new Map<string, Position>([
["1", { x: 42, y: 84 }],
]);
applyLayoutOutput(graph, { positions, groupBounds: new Map() });
// Same Float64Array instance, mutated in-place
expect(node.pos).toBeInstanceOf(Float64Array);
expect(node.pos[0]).toBe(42);
expect(node.pos[1]).toBe(84);
});
it("calls setDirtyCanvas when available", () => {
let dirtyCalled = false;
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
],
});
(graph as { setDirtyCanvas: (fg: boolean, bg: boolean) => void }).setDirtyCanvas = () => {
dirtyCalled = true;
};
applyLayoutOutput(graph, {
positions: new Map([["1", { x: 0, y: 0 }]]),
groupBounds: new Map(),
});
expect(dirtyCalled).toBe(true);
});
it("skips nodes without matching position data", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] },
],
});
// Empty positions map — node should not be modified
applyLayoutOutput(graph, {
positions: new Map(),
groupBounds: new Map(),
});
expect(graph._nodes[0].pos[0]).toBe(10);
expect(graph._nodes[0].pos[1]).toBe(20);
});
it("applies positions to subgraph boundary nodes", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] },
],
inputNode: { id: -10, pos: [-300, 0], size: [120, 220] },
outputNode: { id: -20, pos: [300, 0], size: [120, 60] },
}) as GraphLike & {
inputNode: { pos: number[] };
outputNode: { pos: number[] };
};
applyLayoutOutput(graph, {
positions: new Map([
["-10", { x: -100, y: 40 }],
["1", { x: 100, y: 200 }],
["-20", { x: 400, y: 60 }],
]),
groupBounds: new Map(),
});
expect(graph.inputNode.pos).toEqual([-100, 40]);
expect(graph._nodes[0].pos).toEqual([100, 200]);
expect(graph.outputNode.pos).toEqual([400, 60]);
});
});
// ---------------------------------------------------------------------------
// extractGroupLayoutInput
// ---------------------------------------------------------------------------
describe("extractGroupLayoutInput", () => {
it("filters to only selected groups and their members", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [520, 20], size: [100, 50] },
{ id: 3, type: "C", title: "C", pos: [1020, 20], size: [100, 50] },
],
groups: [
{ id: 100, title: "Group 1", pos: [0, 0], size: [500, 300] },
{ id: 101, title: "Group 2", pos: [500, 0], size: [500, 300] },
],
links: [
{ id: 1, origin_id: 1, target_id: 2 },
{ id: 2, origin_id: 2, target_id: 3 },
],
});
// Select only group 100
const { nodes, edges, groups } = extractGroupLayoutInput(
graph,
new Set([100]),
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe("group:100");
expect(nodes.some((n) => n.id === "1")).toBe(true);
// Node 2 is in group 101, not selected — should not appear
expect(nodes.some((n) => n.id === "2")).toBe(false);
expect(nodes.some((n) => n.id === "3")).toBe(false);
// Edge from 1->2 crosses group boundaries, target not included
expect(edges).toHaveLength(0);
});
it("includes nested child groups when parent is selected", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
],
groups: [
{ id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] },
{ id: 101, title: "Inner", pos: [100, 100], size: [300, 200] },
],
});
const { groups } = extractGroupLayoutInput(graph, new Set([100]));
// Both outer and inner should be included
expect(groups).toHaveLength(2);
const ids = groups.map((g) => g.id);
expect(ids).toContain("group:100");
expect(ids).toContain("group:101");
});
it("keeps edges between members of selected groups", () => {
const graph = makeGraph({
nodes: [
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
{ id: 2, type: "B", title: "B", pos: [200, 20], size: [100, 50] },
],
groups: [
{ id: 100, title: "Group", pos: [0, 0], size: [500, 300] },
],
links: [{ id: 1, origin_id: 1, target_id: 2 }],
});
const { edges } = extractGroupLayoutInput(graph, new Set([100]));
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({ source: "1", target: "2" });
});
});
// ---------------------------------------------------------------------------
// anchorSelectedGroupLayoutResult
// ---------------------------------------------------------------------------
describe("anchorSelectedGroupLayoutResult", () => {
it("reanchors a selected nested group subtree to its original position", () => {
const graph = makeGraph({
groups: [
{ id: 100, title: "Outer", pos: [100, 100], size: [600, 400] },
{ id: 101, title: "Inner", pos: [250, 220], size: [250, 180] },
],
});
const groups: LayoutGroup[] = [
{
id: "group:101",
title: "Inner",
memberIds: ["1"],
childGroupIds: ["group:102"],
},
{
id: "group:102",
title: "Child",
memberIds: ["2"],
childGroupIds: [],
},
];
const result: FrameworkResult = {
positions: new Map<string, Position>([
["1", { x: 30, y: 60 }],
["2", { x: 80, y: 120 }],
]),
groupBounds: new Map<string, GroupBounds>([
["group:101", { x: 0, y: 0, width: 300, height: 220 }],
["group:102", { x: 40, y: 90, width: 120, height: 100 }],
]),
};
const anchored = anchorSelectedGroupLayoutResult(
graph,
groups,
new Set([101]),
result,
);
expect(anchored.groupBounds.get("group:101")).toEqual({
x: 250,
y: 220,
width: 300,
height: 220,
});
expect(anchored.groupBounds.get("group:102")).toEqual({
x: 290,
y: 310,
width: 120,
height: 100,
});
expect(anchored.positions.get("1")).toEqual({ x: 280, y: 280 });
expect(anchored.positions.get("2")).toEqual({ x: 330, y: 340 });
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
compactVertically,
compactHorizontally,
} from "../../src/layout/compact";
import type { Position, LayoutNode } from "../../src/layout/types";
function node(id: string, width = 100, height = 60): LayoutNode {
return { id, width, height };
}
// ---------------------------------------------------------------------------
// compactVertically
// ---------------------------------------------------------------------------
describe("compactVertically", () => {
it("removes vertical gaps between nodes", () => {
const nodes = [node("A", 100, 50), node("B", 100, 50)];
const positions = new Map<string, Position>([
["A", { x: 0, y: 0 }],
["B", { x: 0, y: 200 }], // 150px gap after A (A ends at 50)
]);
const result = compactVertically(positions, nodes, 10);
expect(result.get("A")!.y).toBe(0);
// B should be shifted up to A.y + A.height + gap = 0 + 50 + 10 = 60
expect(result.get("B")!.y).toBe(60);
});
it("does not change already-compact layout", () => {
const nodes = [node("A", 100, 50), node("B", 100, 50)];
const positions = new Map<string, Position>([
["A", { x: 0, y: 0 }],
["B", { x: 0, y: 60 }], // exactly 10px gap after A
]);
const result = compactVertically(positions, nodes, 10);
expect(result.get("A")!.y).toBe(0);
expect(result.get("B")!.y).toBe(60);
});
it("handles single node", () => {
const nodes = [node("A")];
const positions = new Map<string, Position>([["A", { x: 5, y: 100 }]]);
const result = compactVertically(positions, nodes, 10);
expect(result.get("A")!.y).toBe(100);
expect(result.get("A")!.x).toBe(5); // X unchanged
});
it("returns empty map for empty input", () => {
const result = compactVertically(new Map(), [], 10);
expect(result.size).toBe(0);
});
it("preserves X coordinates", () => {
const nodes = [node("A", 100, 50), node("B", 100, 50)];
const positions = new Map<string, Position>([
["A", { x: 100, y: 0 }],
["B", { x: 200, y: 500 }],
]);
const result = compactVertically(positions, nodes, 10);
expect(result.get("A")!.x).toBe(100);
expect(result.get("B")!.x).toBe(200);
});
});
// ---------------------------------------------------------------------------
// compactHorizontally
// ---------------------------------------------------------------------------
describe("compactHorizontally", () => {
it("removes horizontal gaps between nodes", () => {
const nodes = [node("A", 80, 50), node("B", 80, 50)];
const positions = new Map<string, Position>([
["A", { x: 0, y: 0 }],
["B", { x: 300, y: 0 }], // 220px gap after A (A ends at 80)
]);
const result = compactHorizontally(positions, nodes, 10);
expect(result.get("A")!.x).toBe(0);
// B should be shifted left to A.x + A.width + gap = 0 + 80 + 10 = 90
expect(result.get("B")!.x).toBe(90);
});
it("does not change already-compact layout", () => {
const nodes = [node("A", 80, 50), node("B", 80, 50)];
const positions = new Map<string, Position>([
["A", { x: 0, y: 0 }],
["B", { x: 90, y: 0 }], // exactly 10px gap
]);
const result = compactHorizontally(positions, nodes, 10);
expect(result.get("A")!.x).toBe(0);
expect(result.get("B")!.x).toBe(90);
});
it("preserves Y coordinates", () => {
const nodes = [node("A", 80, 50), node("B", 80, 50)];
const positions = new Map<string, Position>([
["A", { x: 0, y: 100 }],
["B", { x: 500, y: 200 }],
]);
const result = compactHorizontally(positions, nodes, 10);
expect(result.get("A")!.y).toBe(100);
expect(result.get("B")!.y).toBe(200);
});
it("returns empty map for empty input", () => {
const result = compactHorizontally(new Map(), [], 10);
expect(result.size).toBe(0);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { listRepoFixtures, loadFixture } from "../e2e/fixtures";
describe("repo fixture loader", () => {
it("discovers flat checked-in fixtures", () => {
expect(listRepoFixtures()).toContain("simple-dag");
expect(listRepoFixtures()).toContain("token-testing");
});
it("loads a flat checked-in fixture", () => {
const workflow = loadFixture("simple-dag");
expect(workflow).toHaveProperty("nodes");
});
});
+616
View File
@@ -0,0 +1,616 @@
import { describe, it, expect } from "vitest";
import {
layoutWithGroups,
buildGroupHierarchy,
splitDisconnected,
placeDisconnected,
translatePositions,
} from "../../src/layout/framework";
import type {
LayoutNode,
LayoutEdge,
LayoutGroup,
LayoutAlgorithm,
LayoutInput,
LayoutOutput,
Position,
FrameworkConfig,
} from "../../src/layout/types";
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
// ---------------------------------------------------------------------------
// Mock algorithm: places nodes in a horizontal line
// ---------------------------------------------------------------------------
const mockAlgorithm: LayoutAlgorithm = {
name: "mock-horizontal-line",
layout(input: LayoutInput): LayoutOutput {
const positions = new Map<string, Position>();
// Determine max width for spacing
let maxWidth = 0;
for (const n of input.nodes) {
if (n.width > maxWidth) maxWidth = n.width;
}
const gap = 50;
// Sort nodes by ID for determinism
const sorted = [...input.nodes].sort((a, b) =>
a.id.localeCompare(b.id, undefined, { numeric: true }),
);
let x = 0;
for (const n of sorted) {
positions.set(n.id, { x, y: 0 });
x += n.width + gap;
}
return { positions };
},
};
// ---------------------------------------------------------------------------
// Helper to create nodes
// ---------------------------------------------------------------------------
function node(id: string, width = 100, height = 60): LayoutNode {
return { id, width, height };
}
function edge(source: string, target: string): LayoutEdge {
return { source, target };
}
// ---------------------------------------------------------------------------
// Tests: layoutWithGroups
// ---------------------------------------------------------------------------
describe("layoutWithGroups", () => {
it("places a single node", () => {
const nodes = [node("A")];
const result = layoutWithGroups(nodes, [], [], mockAlgorithm);
expect(result.positions.size).toBe(1);
const pos = result.positions.get("A");
expect(pos).toBeDefined();
expect(Number.isFinite(pos!.x)).toBe(true);
expect(Number.isFinite(pos!.y)).toBe(true);
});
it("lays out a simple chain A->B->C", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C")];
const result = layoutWithGroups(nodes, edges, [], mockAlgorithm);
expect(result.positions.size).toBe(3);
// All should have finite coordinates
for (const [, pos] of result.positions) {
expect(Number.isFinite(pos.x)).toBe(true);
expect(Number.isFinite(pos.y)).toBe(true);
}
});
it("places disconnected nodes left of DAG", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [edge("A", "B")]; // C and D are disconnected
const result = layoutWithGroups(nodes, edges, [], mockAlgorithm);
expect(result.positions.size).toBe(4);
const posA = result.positions.get("A")!;
const posC = result.positions.get("C")!;
const posD = result.positions.get("D")!;
// Disconnected nodes should be left of connected ones
expect(posC.x).toBeLessThan(posA.x);
expect(posD.x).toBeLessThan(posA.x);
// Disconnected nodes should be stacked vertically
expect(posD.y).toBeGreaterThan(posC.y);
});
it("lays out a group containing 3 nodes with correct bounds", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B")];
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Group 1",
memberIds: ["A", "B", "C"],
childGroupIds: [],
},
];
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
// All nodes should be positioned
expect(result.positions.size).toBe(3);
// Group bounds should exist and contain all members
const bounds = result.groupBounds.get("g1");
expect(bounds).toBeDefined();
expect(bounds!.width).toBeGreaterThan(0);
expect(bounds!.height).toBeGreaterThan(0);
// All members should be inside group bounds
for (const mId of ["A", "B", "C"]) {
const pos = result.positions.get(mId)!;
expect(pos.x).toBeGreaterThanOrEqual(bounds!.x);
expect(pos.y).toBeGreaterThanOrEqual(bounds!.y);
const n = nodes.find((nd) => nd.id === mId)!;
expect(pos.x + n.width).toBeLessThanOrEqual(bounds!.x + bounds!.width);
expect(pos.y + n.height).toBeLessThanOrEqual(bounds!.y + bounds!.height);
}
});
it("handles nested groups (inner laid out first, then as rectangle in outer)", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [edge("A", "B")];
const groups: LayoutGroup[] = [
{
id: "inner",
title: "Inner",
memberIds: ["A", "B"],
childGroupIds: [],
},
{
id: "outer",
title: "Outer",
memberIds: ["C"],
childGroupIds: ["inner"],
},
];
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
// All 4 nodes positioned (D is ungrouped, at root)
expect(result.positions.size).toBe(4);
// Both groups should have bounds
expect(result.groupBounds.has("inner")).toBe(true);
expect(result.groupBounds.has("outer")).toBe(true);
const innerBounds = result.groupBounds.get("inner")!;
const outerBounds = result.groupBounds.get("outer")!;
// Inner group should be contained within outer group
expect(innerBounds.x).toBeGreaterThanOrEqual(outerBounds.x);
expect(innerBounds.y).toBeGreaterThanOrEqual(outerBounds.y);
expect(innerBounds.x + innerBounds.width).toBeLessThanOrEqual(
outerBounds.x + outerBounds.width,
);
expect(innerBounds.y + innerBounds.height).toBeLessThanOrEqual(
outerBounds.y + outerBounds.height,
);
});
it("handles mixed grouped and ungrouped nodes", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "C")]; // A is grouped, C is ungrouped, edge between them
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Group 1",
memberIds: ["A", "B"],
childGroupIds: [],
},
];
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
// All nodes positioned
expect(result.positions.size).toBe(3);
expect(result.groupBounds.size).toBe(1);
// Ungrouped node C should be positioned
expect(result.positions.has("C")).toBe(true);
});
it("returns empty maps for empty input", () => {
const result = layoutWithGroups([], [], [], mockAlgorithm);
expect(result.positions.size).toBe(0);
expect(result.groupBounds.size).toBe(0);
});
it("produces idempotent results", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [edge("A", "B"), edge("B", "C")];
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Group 1",
memberIds: ["A", "B"],
childGroupIds: [],
},
];
const result1 = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
const result2 = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
// Positions should be identical
expect(result1.positions.size).toBe(result2.positions.size);
for (const [id, pos1] of result1.positions) {
const pos2 = result2.positions.get(id);
expect(pos2).toBeDefined();
expect(pos1.x).toBe(pos2!.x);
expect(pos1.y).toBe(pos2!.y);
}
// Group bounds should be identical
expect(result1.groupBounds.size).toBe(result2.groupBounds.size);
for (const [id, b1] of result1.groupBounds) {
const b2 = result2.groupBounds.get(id);
expect(b2).toBeDefined();
expect(b1.x).toBe(b2!.x);
expect(b1.y).toBe(b2!.y);
expect(b1.width).toBe(b2!.width);
expect(b1.height).toBe(b2!.height);
}
});
it("handles a group with only disconnected nodes", () => {
const nodes = [node("A"), node("B"), node("C")];
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Disconnected Group",
memberIds: ["A", "B", "C"],
childGroupIds: [],
},
];
const result = layoutWithGroups(nodes, [], groups, mockAlgorithm);
expect(result.positions.size).toBe(3);
expect(result.groupBounds.has("g1")).toBe(true);
// All nodes should have finite positions
for (const [, pos] of result.positions) {
expect(Number.isFinite(pos.x)).toBe(true);
expect(Number.isFinite(pos.y)).toBe(true);
}
});
it("handles multiple top-level groups", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [edge("A", "C")]; // Cross-group edge
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Group 1",
memberIds: ["A", "B"],
childGroupIds: [],
},
{
id: "g2",
title: "Group 2",
memberIds: ["C", "D"],
childGroupIds: [],
},
];
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
// All nodes positioned
expect(result.positions.size).toBe(4);
// Both groups have bounds
expect(result.groupBounds.size).toBe(2);
expect(result.groupBounds.has("g1")).toBe(true);
expect(result.groupBounds.has("g2")).toBe(true);
});
it("uses group title tokens to override the default algorithm", () => {
const nodes = [node("A", 100, 60), node("B", 100, 60)];
const groups: LayoutGroup[] = [
{
id: "g1",
title: "Horizontal",
memberIds: ["A", "B"],
childGroupIds: [],
token: { mode: "horizontal" },
},
];
const result = layoutWithGroups(nodes, [], groups, mockAlgorithm);
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
expect(posA.y).toBe(posB.y);
expect(posB.x).toBeGreaterThan(posA.x);
});
it("accepts partial config overrides", () => {
const nodes = [node("A"), node("B")];
const edges = [edge("A", "B")];
const customConfig: Partial<FrameworkConfig> = {
groupPadding: 50,
};
const result = layoutWithGroups(
nodes,
edges,
[],
mockAlgorithm,
customConfig,
);
expect(result.positions.size).toBe(2);
});
it("uses custom horizontalGap when provided", () => {
// With a wider gap, the disconnected node should be farther from the DAG
const nodes = [node("A"), node("B"), node("Disc")];
const edges = [edge("A", "B")]; // Disc is disconnected
const groups: LayoutGroup[] = [];
const defaultResult = layoutWithGroups(
nodes,
edges,
groups,
mockAlgorithm,
);
const customResult = layoutWithGroups(
nodes,
edges,
groups,
mockAlgorithm,
{ disconnectedGap: 500 },
);
const defaultDiscX = defaultResult.positions.get("Disc")!.x;
const customDiscX = customResult.positions.get("Disc")!.x;
// Larger disconnectedGap should push the disconnected node further left
expect(customDiscX).toBeLessThan(defaultDiscX);
});
it("uses custom groupPadding for group bounds", () => {
const nodes = [node("A")];
const groups: LayoutGroup[] = [
{ id: "g1", title: "G1", memberIds: ["A"], childGroupIds: [] },
];
const smallPad = layoutWithGroups(
nodes,
[],
groups,
mockAlgorithm,
{ groupPadding: 10 },
);
const largePad = layoutWithGroups(
nodes,
[],
groups,
mockAlgorithm,
{ groupPadding: 80 },
);
const smallBounds = smallPad.groupBounds.get("g1")!;
const largeBounds = largePad.groupBounds.get("g1")!;
// Larger padding should produce larger group bounds
expect(largeBounds.width).toBeGreaterThan(smallBounds.width);
expect(largeBounds.height).toBeGreaterThan(smallBounds.height);
});
it("uses custom verticalGap for disconnected node spacing", () => {
const nodes = [node("Disc1"), node("Disc2")];
// No edges — both disconnected, stacked vertically
const tightResult = layoutWithGroups(
nodes,
[],
[],
mockAlgorithm,
{ verticalGap: 10 },
);
const looseResult = layoutWithGroups(
nodes,
[],
[],
mockAlgorithm,
{ verticalGap: 100 },
);
const tightGap =
tightResult.positions.get("Disc2")!.y -
tightResult.positions.get("Disc1")!.y;
const looseGap =
looseResult.positions.get("Disc2")!.y -
looseResult.positions.get("Disc1")!.y;
expect(looseGap).toBeGreaterThan(tightGap);
});
});
// ---------------------------------------------------------------------------
// Tests: buildGroupHierarchy
// ---------------------------------------------------------------------------
describe("buildGroupHierarchy", () => {
it("returns empty for no groups", () => {
const { processingOrder, parentMap } = buildGroupHierarchy([]);
expect(processingOrder).toHaveLength(0);
expect(parentMap.size).toBe(0);
});
it("returns leaf groups first, then parents", () => {
const groups: LayoutGroup[] = [
{
id: "parent",
title: "Parent",
memberIds: [],
childGroupIds: ["child"],
},
{
id: "child",
title: "Child",
memberIds: ["A"],
childGroupIds: [],
},
];
const { processingOrder, parentMap } = buildGroupHierarchy(groups);
expect(processingOrder).toHaveLength(2);
expect(processingOrder[0].id).toBe("child");
expect(processingOrder[1].id).toBe("parent");
expect(parentMap.get("child")).toBe("parent");
});
it("handles flat groups (no nesting)", () => {
const groups: LayoutGroup[] = [
{ id: "a", title: "A", memberIds: ["1"], childGroupIds: [] },
{ id: "b", title: "B", memberIds: ["2"], childGroupIds: [] },
];
const { processingOrder } = buildGroupHierarchy(groups);
// Both are leaves, order is deterministic (queue order)
expect(processingOrder).toHaveLength(2);
});
it("handles deep nesting (3 levels)", () => {
const groups: LayoutGroup[] = [
{
id: "root",
title: "Root",
memberIds: [],
childGroupIds: ["mid"],
},
{
id: "mid",
title: "Mid",
memberIds: [],
childGroupIds: ["leaf"],
},
{
id: "leaf",
title: "Leaf",
memberIds: ["A"],
childGroupIds: [],
},
];
const { processingOrder } = buildGroupHierarchy(groups);
expect(processingOrder.map((g) => g.id)).toEqual(["leaf", "mid", "root"]);
});
});
// ---------------------------------------------------------------------------
// Tests: splitDisconnected
// ---------------------------------------------------------------------------
describe("splitDisconnected", () => {
it("splits nodes with and without edges", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B")];
const result = splitDisconnected(nodes, edges);
expect(result.connected.map((n) => n.id)).toEqual(["A", "B"]);
expect(result.disconnected.map((n) => n.id)).toEqual(["C"]);
expect(result.connectedEdges).toHaveLength(1);
});
it("returns all as disconnected when no edges", () => {
const nodes = [node("A"), node("B")];
const result = splitDisconnected(nodes, []);
expect(result.connected).toHaveLength(0);
expect(result.disconnected).toHaveLength(2);
});
it("returns all as connected when all have edges", () => {
const nodes = [node("A"), node("B")];
const edges = [edge("A", "B")];
const result = splitDisconnected(nodes, edges);
expect(result.connected).toHaveLength(2);
expect(result.disconnected).toHaveLength(0);
});
it("ignores edges referencing unknown nodes", () => {
const nodes = [node("A")];
const edges = [edge("A", "Z")]; // Z doesn't exist
const result = splitDisconnected(nodes, edges);
expect(result.connected).toHaveLength(0);
expect(result.disconnected).toHaveLength(1);
});
it("keeps constrained boundary nodes out of disconnected placement", () => {
const nodes: LayoutNode[] = [
{ id: "in", width: 100, height: 60, layerConstraint: "first" },
{ id: "out", width: 100, height: 60, layerConstraint: "last" },
];
const result = splitDisconnected(nodes, []);
expect(result.connected.map((n) => n.id)).toEqual(["in", "out"]);
expect(result.disconnected).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Tests: placeDisconnected
// ---------------------------------------------------------------------------
describe("placeDisconnected", () => {
it("places nodes in vertical stack left of DAG", () => {
const nodes = [node("A", 100, 60), node("B", 80, 40)];
const dagBounds = { minX: 200, minY: 50, maxX: 500, maxY: 300 };
const positions = placeDisconnected(
nodes,
dagBounds,
DEFAULT_FRAMEWORK_CONFIG,
);
expect(positions.size).toBe(2);
const posA = positions.get("A")!;
const posB = positions.get("B")!;
// Should be left of DAG
expect(posA.x + 100).toBeLessThan(dagBounds.minX);
// B should be below A
expect(posB.y).toBeGreaterThan(posA.y);
});
it("places at origin when no DAG", () => {
const nodes = [node("A")];
const positions = placeDisconnected(
nodes,
null,
DEFAULT_FRAMEWORK_CONFIG,
);
expect(positions.size).toBe(1);
expect(Number.isFinite(positions.get("A")!.x)).toBe(true);
});
it("returns empty map for empty input", () => {
const positions = placeDisconnected([], null, DEFAULT_FRAMEWORK_CONFIG);
expect(positions.size).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Tests: translatePositions
// ---------------------------------------------------------------------------
describe("translatePositions", () => {
it("offsets all positions by dx/dy", () => {
const positions = new Map<string, Position>([
["A", { x: 10, y: 20 }],
["B", { x: 30, y: 40 }],
]);
const result = translatePositions(positions, 100, 200);
expect(result.get("A")).toEqual({ x: 110, y: 220 });
expect(result.get("B")).toEqual({ x: 130, y: 240 });
});
it("returns empty map for empty input", () => {
const result = translatePositions(new Map(), 10, 20);
expect(result.size).toBe(0);
});
it("does not mutate original map", () => {
const original = new Map<string, Position>([["A", { x: 5, y: 10 }]]);
translatePositions(original, 100, 200);
expect(original.get("A")).toEqual({ x: 5, y: 10 });
});
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import { createGridAlgorithm } from "../../src/layout/algorithms/grid";
import type { LayoutNode, LayoutInput } from "../../src/layout/types";
function node(id: string, width = 100, height = 60): LayoutNode {
return { id, width, height };
}
function input(nodes: LayoutNode[]): LayoutInput {
return { nodes, edges: [] };
}
// ---------------------------------------------------------------------------
// Grid layout: row-based
// ---------------------------------------------------------------------------
describe("grid layout (row-based)", () => {
it("[2ROW] with 4 nodes produces 2x2 grid", () => {
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
const nodes = [node("A"), node("B"), node("C"), node("D")];
const result = algo.layout(input(nodes));
// cols = ceil(4/2) = 2
// Row 0: A(0,0), B(0,1)
// Row 1: C(1,0), D(1,1)
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
const posD = result.positions.get("D")!;
// cellWidth = 100+40 = 140, cellHeight = 60+40 = 100
expect(posA).toEqual({ x: 0, y: 0 });
expect(posB).toEqual({ x: 140, y: 0 });
expect(posC).toEqual({ x: 0, y: 100 });
expect(posD).toEqual({ x: 140, y: 100 });
});
it("[2ROW] with 1 node places at origin", () => {
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
const result = algo.layout(input([node("A")]));
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
});
it("[3ROW] with 7 nodes distributes correctly", () => {
const algo = createGridAlgorithm({ rows: 3, gap: 20 });
const nodes = Array.from({ length: 7 }, (_, i) => node(`N${i}`, 80, 50));
const result = algo.layout(input(nodes));
// cols = ceil(7/3) = 3
// Row 0: N0, N1, N2
// Row 1: N3, N4, N5
// Row 2: N6
expect(result.positions.size).toBe(7);
const posN0 = result.positions.get("N0")!;
const posN3 = result.positions.get("N3")!;
const posN6 = result.positions.get("N6")!;
// cellWidth = 80+20 = 100, cellHeight = 50+20 = 70
expect(posN0).toEqual({ x: 0, y: 0 });
expect(posN3).toEqual({ x: 0, y: 70 });
expect(posN6).toEqual({ x: 0, y: 140 });
});
});
// ---------------------------------------------------------------------------
// Grid layout: column-based
// ---------------------------------------------------------------------------
describe("grid layout (column-based)", () => {
it("[3COL] with 9 nodes produces 3x3 grid", () => {
const algo = createGridAlgorithm({ columns: 3, gap: 40 });
const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`));
const result = algo.layout(input(nodes));
// rows = ceil(9/3) = 3
// Row 0: N0, N1, N2
// Row 1: N3, N4, N5
// Row 2: N6, N7, N8
expect(result.positions.size).toBe(9);
const posN0 = result.positions.get("N0")!;
const posN4 = result.positions.get("N4")!;
const posN8 = result.positions.get("N8")!;
expect(posN0).toEqual({ x: 0, y: 0 });
expect(posN4).toEqual({ x: 140, y: 100 }); // (row=1, col=1)
expect(posN8).toEqual({ x: 280, y: 200 }); // (row=2, col=2)
});
it("[2COL] with 5 nodes: 3 rows x 2 columns", () => {
const algo = createGridAlgorithm({ columns: 2, gap: 40 });
const nodes = Array.from({ length: 5 }, (_, i) => node(`N${i}`));
const result = algo.layout(input(nodes));
// rows = ceil(5/2) = 3
// Row 0: N0, N1
// Row 1: N2, N3
// Row 2: N4
expect(result.positions.size).toBe(5);
const posN4 = result.positions.get("N4")!;
expect(posN4).toEqual({ x: 0, y: 200 }); // (row=2, col=0)
});
});
// ---------------------------------------------------------------------------
// Grid layout: mixed node sizes
// ---------------------------------------------------------------------------
describe("grid layout with different node sizes", () => {
it("uses max dimensions for uniform cell sizing", () => {
const algo = createGridAlgorithm({ columns: 2, gap: 20 });
const nodes = [
node("small", 60, 30),
node("wide", 150, 30),
node("tall", 60, 100),
node("big", 150, 100),
];
const result = algo.layout(input(nodes));
// maxWidth = 150, maxHeight = 100
// cellWidth = 150+20 = 170, cellHeight = 100+20 = 120
const posSmall = result.positions.get("small")!;
const posWide = result.positions.get("wide")!;
const posTall = result.positions.get("tall")!;
const posBig = result.positions.get("big")!;
// "small" at (0,0): centered in cell → x = (150-60)/2 = 45, y = (100-30)/2 = 35
expect(posSmall).toEqual({ x: 45, y: 35 });
// "wide" at (0,1): x = 170 + (150-150)/2 = 170, y = (100-30)/2 = 35
expect(posWide).toEqual({ x: 170, y: 35 });
// "tall" at (1,0): x = (150-60)/2 = 45, y = 120 + (100-100)/2 = 120
expect(posTall).toEqual({ x: 45, y: 120 });
// "big" at (1,1): x = 170 + 0 = 170, y = 120 + 0 = 120
expect(posBig).toEqual({ x: 170, y: 120 });
});
});
// ---------------------------------------------------------------------------
// Grid layout: determinism
// ---------------------------------------------------------------------------
describe("grid layout determinism", () => {
it("produces identical output for identical input", () => {
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")];
const result1 = algo.layout(input(nodes));
const result2 = algo.layout(input(nodes));
expect(result1.positions.size).toBe(result2.positions.size);
for (const [id, pos1] of result1.positions) {
const pos2 = result2.positions.get(id)!;
expect(pos1.x).toBe(pos2.x);
expect(pos1.y).toBe(pos2.y);
}
});
});
// ---------------------------------------------------------------------------
// Grid layout: default (no rows/columns specified)
// ---------------------------------------------------------------------------
describe("grid layout default (sqrt columns)", () => {
it("uses sqrt(n) columns when neither rows nor columns given", () => {
const algo = createGridAlgorithm({ gap: 40 });
const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`));
const result = algo.layout(input(nodes));
// sqrt(9) = 3 cols, 3 rows
expect(result.positions.size).toBe(9);
// N0 at (0,0), N3 at (1,0), N6 at (2,0)
const posN0 = result.positions.get("N0")!;
const posN3 = result.positions.get("N3")!;
const posN6 = result.positions.get("N6")!;
expect(posN0.y).toBe(0);
expect(posN3.y).toBe(100); // row 1
expect(posN6.y).toBe(200); // row 2
});
});
+370
View File
@@ -0,0 +1,370 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getCurrentGraph, getSelectedGroups } from "../../src/runtime";
import { isGroup } from "../../src/utils";
import {
SETTING_IDS,
SETTINGS_PREFIX,
EXTENSION_NAME,
REPOSITORY_URL,
CURRENT_VERSION,
DEFAULT_ALGORITHM_OPTIONS,
} from "../../src/settings";
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
import type { Position, GroupBounds } from "../../src/layout/types";
import { computeGraphBounds } from "../../src/bounds";
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
describe("isGroup", () => {
it("identifies a plain group without a type", () => {
expect(
isGroup({ id: 1, title: "Group", pos: [0, 0], size: [100, 100] }),
).toBe(true);
});
it("prefers the LGraphGroup constructor tag when present", () => {
expect(
isGroup({
id: 1,
title: "Runtime Group",
pos: [0, 0],
constructor: { name: "LGraphGroup" },
type: "UnexpectedNodeType",
}),
).toBe(true);
});
it("rejects a node with a string type", () => {
expect(
isGroup({
id: 1,
title: "Sampler",
type: "KSampler",
pos: [0, 0],
size: [100, 100],
}),
).toBe(false);
});
it("rejects a reroute node", () => {
expect(
isGroup({
id: 1,
title: "Reroute",
type: "Reroute",
pos: [0, 0],
size: [30, 30],
}),
).toBe(false);
});
it("treats null and undefined types as groups", () => {
expect(
isGroup({ id: 1, title: "Null Type", type: null, pos: [0, 0] }),
).toBe(true);
expect(
isGroup({ id: 2, title: "Undefined Type", type: undefined, pos: [0, 0] }),
).toBe(true);
});
it("rejects items without a string title", () => {
expect(isGroup({ id: 1, pos: [0, 0] })).toBe(false);
expect(
isGroup({ id: 2, title: undefined, pos: [0, 0] }),
).toBe(false);
});
it("rejects an empty-string type because runtime nodes use string types", () => {
expect(
isGroup({ id: 1, title: "Empty Type", type: "", pos: [0, 0] }),
).toBe(false);
});
});
describe("getCurrentGraph", () => {
it("prefers canvas.getCurrentGraph when available", () => {
const visibleGraph = { _nodes: [], _groups: [], links: new Map() };
const fallbackCanvasGraph = { _nodes: [1], _groups: [], links: new Map() };
const fallbackAppGraph = { _nodes: [2], _groups: [], links: new Map() };
expect(
getCurrentGraph({
canvas: {
graph: fallbackCanvasGraph,
getCurrentGraph: () => visibleGraph,
},
graph: fallbackAppGraph,
}),
).toBe(visibleGraph);
});
it("falls back to canvas.graph, then app.graph", () => {
const canvasGraph = { _nodes: [], _groups: [], links: new Map() };
const appGraph = { _nodes: [], _groups: [], links: new Map() };
expect(
getCurrentGraph({
canvas: { graph: canvasGraph },
graph: appGraph,
}),
).toBe(canvasGraph);
expect(
getCurrentGraph({
canvas: { graph: null },
graph: appGraph,
}),
).toBe(appGraph);
});
it("returns null when no graph is available", () => {
expect(getCurrentGraph({ canvas: null })).toBeNull();
expect(getCurrentGraph({})).toBeNull();
});
});
describe("SETTING_IDS", () => {
it("has all expected setting IDs with correct prefix", () => {
const expectedKeys = [
"VERSION",
"KEYBINDINGS",
"DEFAULT_ALGORITHM",
"HORIZONTAL_GAP",
"VERTICAL_GAP",
"GROUP_PADDING",
"DISCONNECTED_GAP",
"FIT_TO_VIEW",
"DEBUG_LOGGING",
] as const;
for (const key of expectedKeys) {
expect(SETTING_IDS[key]).toBeDefined();
expect(SETTING_IDS[key]).toContain(SETTINGS_PREFIX);
}
});
it("has unique IDs for all settings", () => {
const values = Object.values(SETTING_IDS);
expect(new Set(values).size).toBe(values.length);
});
it("spacing IDs map to correct FrameworkConfig defaults", () => {
expect(DEFAULT_FRAMEWORK_CONFIG.horizontalGap).toBe(100);
expect(DEFAULT_FRAMEWORK_CONFIG.verticalGap).toBe(40);
expect(DEFAULT_FRAMEWORK_CONFIG.groupPadding).toBe(30);
expect(DEFAULT_FRAMEWORK_CONFIG.disconnectedGap).toBe(150);
});
});
describe("settings constants", () => {
const packageVersion = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
) as { version: string };
const pyproject = readFileSync(
new URL("../../pyproject.toml", import.meta.url),
"utf-8",
);
const pyprojectVersion =
pyproject.match(/^version = "(.+)"$/m)?.[1] ?? null;
const bumpCurrentVersion =
pyproject.match(/^current_version = "(.+)"$/m)?.[1] ?? null;
it("has a valid extension name", () => {
expect(EXTENSION_NAME).toBe("comfy-node-organizer");
});
it("has a valid repository URL", () => {
expect(REPOSITORY_URL).toContain("github.com");
expect(REPOSITORY_URL).toContain("comfyui-node-organizer");
});
it("has a semver version string", () => {
expect(CURRENT_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
});
it("keeps CURRENT_VERSION in sync with package.json", () => {
expect(CURRENT_VERSION).toBe(packageVersion.version);
});
it("keeps pyproject version metadata in sync with CURRENT_VERSION", () => {
expect(pyprojectVersion).toBe(CURRENT_VERSION);
expect(bumpCurrentVersion).toBe(CURRENT_VERSION);
});
it("includes sugiyama, horizontal, and vertical algorithms", () => {
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("sugiyama");
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("horizontal");
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("vertical");
expect(DEFAULT_ALGORITHM_OPTIONS.length).toBe(3);
});
});
describe("createConfiguredDefaultAlgorithm", () => {
it("passes spacing config into default algorithm factories", async () => {
const globalScope = globalThis as typeof globalThis & {
app?: {
registerExtension: (extension: unknown) => void;
extensionManager?: { setting?: { get: <T>(_id: string) => T | undefined } };
canvas?: null;
};
};
const originalApp = globalScope.app;
globalScope.app = {
registerExtension: () => undefined,
extensionManager: {
setting: {
get: () => undefined,
},
},
canvas: null,
};
try {
const { createConfiguredDefaultAlgorithm } = await import("../../src/index");
const twoNodes: LayoutNode[] = [
{ id: "a", width: 10, height: 10 },
{ id: "b", width: 10, height: 10 },
];
const verticalPair: LayoutInput = {
nodes: twoNodes,
edges: [],
};
const sugiyamaInput: LayoutInput = {
nodes: [
{ id: "left-a", width: 10, height: 10 },
{ id: "left-b", width: 10, height: 10 },
{ id: "right", width: 10, height: 10 },
],
edges: [
{ source: "left-a", target: "right" },
{ source: "left-b", target: "right" },
],
};
const horizontal = createConfiguredDefaultAlgorithm("horizontal", {
horizontalGap: 123,
});
expect(horizontal.layout(verticalPair).positions.get("b")?.x).toBe(133);
const vertical = createConfiguredDefaultAlgorithm("vertical", {
verticalGap: 77,
});
expect(vertical.layout(verticalPair).positions.get("b")?.y).toBe(87);
const sugiyama = createConfiguredDefaultAlgorithm("sugiyama", {
horizontalGap: 123,
verticalGap: 77,
});
const sugiyamaPositions = sugiyama.layout(sugiyamaInput).positions;
expect(sugiyamaPositions.get("right")?.x).toBe(133);
expect(sugiyamaPositions.get("left-b")?.y).toBe(87);
} finally {
if (originalApp === undefined) {
delete globalScope.app;
} else {
globalScope.app = originalApp;
}
}
});
});
describe("getSelectedGroups", () => {
it("returns only selected groups", () => {
const selectedItems = [
{ id: 100, title: "Group A", pos: [0, 0] as [number, number] },
{ id: 1, title: "Sampler", type: "KSampler", pos: [10, 10] as [number, number] },
{ id: 101, title: "Group B", pos: [20, 20] as [number, number] },
];
expect(getSelectedGroups({ graph: null, selectedItems })).toEqual([
{ id: 100, title: "Group A" },
{ id: 101, title: "Group B" },
]);
});
it("returns an empty array when nothing group-like is selected", () => {
expect(getSelectedGroups(undefined)).toEqual([]);
expect(getSelectedGroups({ graph: null })).toEqual([]);
expect(
getSelectedGroups({
graph: null,
selectedItems: [{ id: 1, title: "Node", type: "KSampler", pos: [0, 0] }],
}),
).toEqual([]);
});
});
describe("computeGraphBounds", () => {
const makeNode = (id: number, x: number, y: number, w: number, h: number) =>
({ id, pos: [x, y], size: [w, h] });
it("computes bounds from regular nodes", () => {
const nodes = [makeNode(1, 0, 0, 100, 50), makeNode(2, 200, 100, 100, 50)];
const positions = new Map<string, Position>([
["1", { x: 0, y: 0 }],
["2", { x: 200, y: 100 }],
]);
const result = computeGraphBounds(nodes, undefined, undefined, positions, new Map());
expect(result).toEqual([0, 0, 300, 150]);
});
it("returns null when no items have positions", () => {
const result = computeGraphBounds([], undefined, undefined, new Map(), new Map());
expect(result).toBeNull();
});
it("includes inputNode in bounds", () => {
const nodes = [makeNode(1, 100, 50, 100, 50)];
const inputNode = makeNode(-10, -200, 0, 50, 30);
const positions = new Map<string, Position>([
["1", { x: 100, y: 50 }],
["-10", { x: -200, y: 0 }],
]);
const result = computeGraphBounds(nodes, inputNode, undefined, positions, new Map());
// Without inputNode: bounds would be [100, 50, 100, 50]
// With inputNode: bounds should extend left to -200
expect(result).toEqual([-200, 0, 400, 100]);
});
it("includes outputNode in bounds", () => {
const nodes = [makeNode(1, 0, 0, 100, 50)];
const outputNode = makeNode(-20, 500, 0, 50, 30);
const positions = new Map<string, Position>([
["1", { x: 0, y: 0 }],
["-20", { x: 500, y: 0 }],
]);
const result = computeGraphBounds(nodes, undefined, outputNode, positions, new Map());
// Without outputNode: bounds would be [0, 0, 100, 50]
// With outputNode: bounds should extend right to 550
expect(result).toEqual([0, 0, 550, 50]);
});
it("includes both boundary nodes in subgraph scenario", () => {
const nodes = [makeNode(1, 100, 50, 100, 50)];
const inputNode = makeNode(-10, -200, 0, 50, 30);
const outputNode = makeNode(-20, 500, 0, 50, 30);
const positions = new Map<string, Position>([
["1", { x: 100, y: 50 }],
["-10", { x: -200, y: 0 }],
["-20", { x: 500, y: 0 }],
]);
const result = computeGraphBounds(nodes, inputNode, outputNode, positions, new Map());
expect(result).toEqual([-200, 0, 750, 100]);
});
it("includes group bounds", () => {
const nodes = [makeNode(1, 50, 50, 100, 50)];
const positions = new Map<string, Position>([["1", { x: 50, y: 50 }]]);
const groupBounds = new Map<string, GroupBounds>([
["group:1", { x: 0, y: 0, width: 300, height: 200 }],
]);
const result = computeGraphBounds(nodes, undefined, undefined, positions, groupBounds);
expect(result).toEqual([0, 0, 300, 200]);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
isWorkflowData,
parseInstalledTemplateManifest,
} from "../e2e/installed-templates";
describe("installed template parsing", () => {
it("parses raw ids and json paths", () => {
const raw = JSON.stringify([
{
id: "api_google_gemini_image",
path: "workflow.json",
},
]);
expect(parseInstalledTemplateManifest(raw)).toEqual([
{
id: "api_google_gemini_image",
path: "workflow.json",
},
]);
});
it("rejects an empty template manifest", () => {
expect(() => parseInstalledTemplateManifest("[]")).toThrow(/zero templates/i);
});
it("recognizes workflow graph data", () => {
expect(
isWorkflowData({
nodes: [],
}),
).toBe(true);
});
it("rejects non-workflow template metadata", () => {
expect(
isWorkflowData({
$schema: "https://example.com",
title: "Schema",
}),
).toBe(false);
});
});
+400
View File
@@ -0,0 +1,400 @@
import { describe, expect, it } from "vitest";
import fc from "fast-check";
import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama";
import { layoutWithGroups } from "../../src/layout/framework";
import type {
LayoutEdge,
LayoutGroup,
LayoutNode,
LayoutToken,
Position,
GroupBounds,
} from "../../src/layout/types";
const algorithm = createSugiyamaAlgorithm();
function makeNodes(nodeCount: number): fc.Arbitrary<LayoutNode[]> {
return fc.tuple(
...Array.from({ length: nodeCount }, (_, index) =>
fc.record({
id: fc.constant(String(index)),
width: fc.integer({ min: 80, max: 320 }),
height: fc.integer({ min: 40, max: 220 }),
}),
),
);
}
function makeDagEdges(nodeIds: string[], maxEdges: number): fc.Arbitrary<LayoutEdge[]> {
const candidates = nodeIds.flatMap((source, sourceIndex) =>
nodeIds
.slice(sourceIndex + 1)
.map((target) => ({ source, target })),
);
return fc.subarray(candidates, {
minLength: 0,
maxLength: Math.min(maxEdges, candidates.length),
});
}
function dagArbitrary(opts: {
readonly minNodes: number;
readonly maxNodes: number;
readonly maxEdges: number;
}): fc.Arbitrary<{ nodes: LayoutNode[]; edges: LayoutEdge[] }> {
return fc
.integer({ min: opts.minNodes, max: opts.maxNodes })
.chain((nodeCount) =>
makeNodes(nodeCount).chain((nodes) =>
makeDagEdges(
nodes.map((node) => node.id),
opts.maxEdges,
).map((edges) => ({ nodes, edges })),
),
);
}
function groupedDagArbitrary(): fc.Arbitrary<{
nodes: LayoutNode[];
edges: LayoutEdge[];
groups: LayoutGroup[];
}> {
return fc
.integer({ min: 4, max: 8 })
.chain((nodeCount) =>
makeNodes(nodeCount).chain((nodes) => {
const nodeIds = nodes.map((node) => node.id);
return fc
.tuple(
makeDagEdges(nodeIds, nodeCount * 2),
fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }),
)
.chain(([edges, innerMemberIds]) => {
const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id));
return fc
.subarray(remaining, {
minLength: 1,
maxLength: remaining.length,
})
.map((outerDirectMemberIds) => ({
nodes,
edges,
groups: [
{
id: "group:inner",
title: "Inner",
memberIds: innerMemberIds,
childGroupIds: [],
},
{
id: "group:outer",
title: "Outer",
memberIds: outerDirectMemberIds,
childGroupIds: ["group:inner"],
},
],
}));
});
}),
);
}
function tokenArbitrary(): fc.Arbitrary<LayoutToken> {
return fc.oneof(
fc.constant<LayoutToken>({ mode: "horizontal" }),
fc.constant<LayoutToken>({ mode: "vertical" }),
fc.integer({ min: 2, max: 9 }).map<LayoutToken>((count) => ({
mode: "grid",
count,
dimension: "row",
})),
fc.integer({ min: 2, max: 9 }).map<LayoutToken>((count) => ({
mode: "grid",
count,
dimension: "col",
})),
);
}
function tokenizedGroupedDagArbitrary(): fc.Arbitrary<{
nodes: LayoutNode[];
edges: LayoutEdge[];
groups: LayoutGroup[];
}> {
return fc
.integer({ min: 4, max: 8 })
.chain((nodeCount) =>
makeNodes(nodeCount).chain((nodes) => {
const nodeIds = nodes.map((node) => node.id);
return fc
.tuple(
makeDagEdges(nodeIds, nodeCount * 2),
fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }),
)
.chain(([edges, innerMemberIds]) => {
const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id));
return fc
.tuple(
fc.subarray(remaining, {
minLength: 1,
maxLength: remaining.length,
}),
tokenArbitrary(),
tokenArbitrary(),
)
.map(([outerDirectMemberIds, innerToken, outerToken]) => ({
nodes,
edges,
groups: [
{
id: "group:inner",
title: "Inner",
memberIds: innerMemberIds,
childGroupIds: [],
token: innerToken,
},
{
id: "group:outer",
title: "Outer",
memberIds: outerDirectMemberIds,
childGroupIds: ["group:inner"],
token: outerToken,
},
],
}));
});
}),
);
}
function assertFinitePositions(
positions: ReadonlyMap<string, Position>,
): void {
for (const [id, pos] of positions) {
expect(Number.isFinite(pos.x), `Node ${id} has non-finite x`).toBe(true);
expect(Number.isFinite(pos.y), `Node ${id} has non-finite y`).toBe(true);
}
}
function assertAllNodesPositioned(
nodes: ReadonlyArray<LayoutNode>,
positions: ReadonlyMap<string, Position>,
): void {
for (const node of nodes) {
expect(positions.has(node.id), `Missing position for node ${node.id}`).toBe(
true,
);
}
}
function assertNoOverlaps(
nodes: ReadonlyArray<LayoutNode>,
positions: ReadonlyMap<string, Position>,
): void {
for (let index = 0; index < nodes.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) {
const left = nodes[index];
const right = nodes[otherIndex];
const leftPos = positions.get(left.id);
const rightPos = positions.get(right.id);
expect(leftPos, `Missing position for node ${left.id}`).toBeDefined();
expect(rightPos, `Missing position for node ${right.id}`).toBeDefined();
const overlap =
leftPos!.x < rightPos!.x + right.width &&
leftPos!.x + left.width > rightPos!.x &&
leftPos!.y < rightPos!.y + right.height &&
leftPos!.y + left.height > rightPos!.y;
expect(
overlap,
`Nodes ${left.id} and ${right.id} overlap: ` +
`[${leftPos!.x}, ${leftPos!.y}, ${left.width}, ${left.height}] vs ` +
`[${rightPos!.x}, ${rightPos!.y}, ${right.width}, ${right.height}]`,
).toBe(false);
}
}
}
function assertPositiveBounds(
groupId: string,
bounds: GroupBounds | undefined,
): asserts bounds is GroupBounds {
if (!bounds) {
throw new Error(`Missing bounds for group ${groupId}`);
}
expect(bounds.width, `Group ${groupId} width must be positive`).toBeGreaterThan(
0,
);
expect(
bounds.height,
`Group ${groupId} height must be positive`,
).toBeGreaterThan(0);
}
function assertGroupContainsMembers(
groups: ReadonlyArray<LayoutGroup>,
nodes: ReadonlyArray<LayoutNode>,
positions: ReadonlyMap<string, Position>,
boundsByGroup: ReadonlyMap<string, GroupBounds>,
): void {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
for (const group of groups) {
const bounds = boundsByGroup.get(group.id);
assertPositiveBounds(group.id, bounds);
for (const memberId of group.memberIds) {
const node = nodeById.get(memberId);
const pos = positions.get(memberId);
expect(node, `Missing node ${memberId}`).toBeDefined();
expect(pos, `Missing position for node ${memberId}`).toBeDefined();
expect(pos!.x).toBeGreaterThanOrEqual(bounds.x);
expect(pos!.y).toBeGreaterThanOrEqual(bounds.y);
expect(pos!.x + node!.width).toBeLessThanOrEqual(bounds.x + bounds.width);
expect(pos!.y + node!.height).toBeLessThanOrEqual(
bounds.y + bounds.height,
);
}
}
}
function assertIdempotent(
nodes: ReadonlyArray<LayoutNode>,
edges: ReadonlyArray<LayoutEdge>,
groups: ReadonlyArray<LayoutGroup>,
): void {
const first = layoutWithGroups(nodes, edges, groups, algorithm);
const second = layoutWithGroups(nodes, edges, groups, algorithm);
expect(second.positions.size).toBe(first.positions.size);
expect(second.groupBounds.size).toBe(first.groupBounds.size);
for (const [id, firstPos] of first.positions) {
expect(second.positions.get(id)).toEqual(firstPos);
}
for (const [id, firstBounds] of first.groupBounds) {
expect(second.groupBounds.get(id)).toEqual(firstBounds);
}
}
describe("property-based layout invariants", () => {
it("produces finite positions and places every node for random DAGs", () => {
fc.assert(
fc.property(
dagArbitrary({ minNodes: 1, maxNodes: 20, maxEdges: 30 }),
({ nodes, edges }) => {
const result = layoutWithGroups(nodes, edges, [], algorithm);
assertFinitePositions(result.positions);
assertAllNodesPositioned(nodes, result.positions);
},
),
{ numRuns: 150 },
);
});
it("avoids node overlaps for random DAGs without groups", () => {
fc.assert(
fc.property(
dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }),
({ nodes, edges }) => {
const result = layoutWithGroups(nodes, edges, [], algorithm);
assertNoOverlaps(nodes, result.positions);
},
),
{ numRuns: 100 },
);
});
it("is idempotent for random DAGs", () => {
fc.assert(
fc.property(
dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }),
({ nodes, edges }) => {
assertIdempotent(nodes, edges, []);
},
),
{ numRuns: 100 },
);
});
it("keeps grouped members inside positive group bounds", () => {
fc.assert(
fc.property(groupedDagArbitrary(), ({ nodes, edges, groups }) => {
const result = layoutWithGroups(nodes, edges, groups, algorithm);
assertFinitePositions(result.positions);
assertAllNodesPositioned(nodes, result.positions);
assertGroupContainsMembers(
groups,
nodes,
result.positions,
result.groupBounds,
);
assertIdempotent(nodes, edges, groups);
}),
{ numRuns: 75 },
);
});
});
describe("property-based token layout invariants", () => {
it("produces finite positions and places every node with tokenized groups", () => {
fc.assert(
fc.property(
tokenizedGroupedDagArbitrary(),
({ nodes, edges, groups }) => {
const result = layoutWithGroups(nodes, edges, groups, algorithm);
assertFinitePositions(result.positions);
assertAllNodesPositioned(nodes, result.positions);
},
),
{ numRuns: 75 },
);
});
it("avoids node overlaps with tokenized groups", () => {
fc.assert(
fc.property(
tokenizedGroupedDagArbitrary(),
({ nodes, edges, groups }) => {
const result = layoutWithGroups(nodes, edges, groups, algorithm);
assertNoOverlaps(nodes, result.positions);
},
),
{ numRuns: 75 },
);
});
it("keeps tokenized group members inside positive group bounds", () => {
fc.assert(
fc.property(
tokenizedGroupedDagArbitrary(),
({ nodes, edges, groups }) => {
const result = layoutWithGroups(nodes, edges, groups, algorithm);
assertGroupContainsMembers(
groups,
nodes,
result.positions,
result.groupBounds,
);
},
),
{ numRuns: 75 },
);
});
it("is idempotent with tokenized groups", () => {
fc.assert(
fc.property(
tokenizedGroupedDagArbitrary(),
({ nodes, edges, groups }) => {
assertIdempotent(nodes, edges, groups);
},
),
{ numRuns: 75 },
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { extractPinnedWorkflowTemplatesRequirement } from "../../scripts/setup-test-comfy-helpers";
describe("workflow template requirement parsing", () => {
it("extracts the exact pinned requirement", () => {
const requirements = [
"torchsde",
"comfyui-workflow-templates==0.9.26",
"kornia>=0.7.1",
].join("\n");
expect(extractPinnedWorkflowTemplatesRequirement(requirements)).toBe(
"comfyui-workflow-templates==0.9.26",
);
});
it("fails when the requirement is missing", () => {
expect(() =>
extractPinnedWorkflowTemplatesRequirement("torchsde==0.2.6"),
).toThrow(/comfyui-workflow-templates/i);
});
});
+658
View File
@@ -0,0 +1,658 @@
import { describe, it, expect } from "vitest";
import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama";
import { breakCycles } from "../../src/layout/algorithms/sugiyama/cycle-break";
import { assignLayers } from "../../src/layout/algorithms/sugiyama/layer-assign";
import { minimizeCrossings } from "../../src/layout/algorithms/sugiyama/crossing-minimize";
import { assignCoordinates } from "../../src/layout/algorithms/sugiyama/coordinate-assign";
import type { LayoutNode, LayoutEdge, Position } from "../../src/layout/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function node(id: string, width = 200, height = 100): LayoutNode {
return { id, width, height };
}
function edge(source: string, target: string): LayoutEdge {
return { source, target };
}
function nodeMap(nodes: LayoutNode[]): ReadonlyMap<string, LayoutNode> {
const m = new Map<string, LayoutNode>();
for (const n of nodes) m.set(n.id, n);
return m;
}
/** Check that no two positioned nodes overlap (AABB collision). */
function assertNoOverlaps(
positions: ReadonlyMap<string, Position>,
nodes: LayoutNode[],
): void {
const nMap = nodeMap(nodes);
const entries = [...positions.entries()];
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [idA, posA] = entries[i];
const [idB, posB] = entries[j];
const nA = nMap.get(idA)!;
const nB = nMap.get(idB)!;
const overlapX =
posA.x < posB.x + nB.width && posA.x + nA.width > posB.x;
const overlapY =
posA.y < posB.y + nB.height && posA.y + nA.height > posB.y;
expect(
overlapX && overlapY,
`Nodes ${idA} and ${idB} overlap`,
).toBe(false);
}
}
}
/** Check all coordinates are finite (no NaN/Infinity). */
function assertFiniteCoordinates(
positions: ReadonlyMap<string, Position>,
): void {
for (const [id, pos] of positions) {
expect(Number.isFinite(pos.x), `${id}.x is not finite: ${pos.x}`).toBe(
true,
);
expect(Number.isFinite(pos.y), `${id}.y is not finite: ${pos.y}`).toBe(
true,
);
}
}
// ---------------------------------------------------------------------------
// breakCycles
// ---------------------------------------------------------------------------
describe("breakCycles", () => {
it("returns edges unchanged when no cycles exist", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C")];
const result = breakCycles(nodes, edges);
expect(result.reversedEdges.size).toBe(0);
expect(result.edges).toHaveLength(2);
});
it("breaks a simple 2-node cycle", () => {
const nodes = [node("A"), node("B")];
const edges = [edge("A", "B"), edge("B", "A")];
const result = breakCycles(nodes, edges);
// One edge should be reversed
expect(result.reversedEdges.size).toBe(1);
expect(result.edges).toHaveLength(2);
// Result should be acyclic: verify by running layer assignment (which requires DAG)
const layers = assignLayers(nodes, result.edges);
expect(layers.size).toBe(2);
});
it("breaks a 3-node cycle A->B->C->A", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")];
const result = breakCycles(nodes, edges);
// At least one edge reversed
expect(result.reversedEdges.size).toBeGreaterThanOrEqual(1);
// Result must be acyclic: all nodes should get valid layers
const layers = assignLayers(nodes, result.edges);
expect(layers.size).toBe(3);
// Verify topological ordering: for every edge, source layer < target layer
for (const e of result.edges) {
expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!);
}
});
it("handles empty input", () => {
const result = breakCycles([], []);
expect(result.edges).toHaveLength(0);
expect(result.reversedEdges.size).toBe(0);
});
it("handles nodes with no edges", () => {
const nodes = [node("A"), node("B")];
const result = breakCycles(nodes, []);
expect(result.edges).toHaveLength(0);
expect(result.reversedEdges.size).toBe(0);
});
it("handles self-loop (edge to same node)", () => {
const nodes = [node("A"), node("B")];
// Self-loop on A plus a normal edge
const edges = [edge("A", "A"), edge("A", "B")];
const result = breakCycles(nodes, edges);
// Self-loop might be reversed or kept — algorithm should handle it
// Just ensure no crash and result is valid
expect(result.edges.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// assignLayers
// ---------------------------------------------------------------------------
describe("assignLayers", () => {
it("assigns single node to layer 0", () => {
const nodes = [node("A")];
const layers = assignLayers(nodes, []);
expect(layers.get("A")).toBe(0);
});
it("assigns linear chain A->B->C to consecutive layers", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C")];
const layers = assignLayers(nodes, edges);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
expect(layers.get("C")).toBe(2);
});
it("assigns diamond A->B, A->C, B->D, C->D correctly", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("A", "C"),
edge("B", "D"),
edge("C", "D"),
];
const layers = assignLayers(nodes, edges);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
expect(layers.get("C")).toBe(1);
expect(layers.get("D")).toBe(2);
});
it("uses longest path (not shortest)", () => {
// A->B->C->D and A->D
// D should be at layer 3 (longest path), not layer 1 (shortest)
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("B", "C"),
edge("C", "D"),
edge("A", "D"),
];
const layers = assignLayers(nodes, edges);
expect(layers.get("A")).toBe(0);
expect(layers.get("D")).toBe(3); // longest path: A->B->C->D
});
it("handles multiple sources", () => {
// A->C, B->C
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "C"), edge("B", "C")];
const layers = assignLayers(nodes, edges);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(0);
expect(layers.get("C")).toBe(1);
});
it("handles empty input", () => {
const layers = assignLayers([], []);
expect(layers.size).toBe(0);
});
it("handles disconnected nodes", () => {
const nodes = [node("A"), node("B")];
const layers = assignLayers(nodes, []);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(0);
});
it("preserves monotonic increase along edges", () => {
const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")];
const edges = [
edge("A", "B"),
edge("A", "C"),
edge("B", "D"),
edge("C", "D"),
edge("D", "E"),
];
const layers = assignLayers(nodes, edges);
for (const e of edges) {
expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!);
}
});
it("forces first and last constrained nodes to graph edges", () => {
const nodes: LayoutNode[] = [
{ id: "in", width: 120, height: 40, layerConstraint: "first" },
node("mid"),
{ id: "out", width: 120, height: 40, layerConstraint: "last" },
];
const edges = [edge("in", "mid"), edge("mid", "out")];
const layers = assignLayers(nodes, edges);
expect(layers.get("in")).toBe(0);
expect(layers.get("out")).toBeGreaterThan(layers.get("mid")!);
});
it("separates disconnected constrained boundary nodes into distinct layers", () => {
const nodes: LayoutNode[] = [
{ id: "in", width: 120, height: 40, layerConstraint: "first" },
{ id: "out", width: 120, height: 40, layerConstraint: "last" },
];
const layers = assignLayers(nodes, []);
expect(layers.get("in")).toBe(0);
expect(layers.get("out")).toBe(1);
});
});
// ---------------------------------------------------------------------------
// minimizeCrossings
// ---------------------------------------------------------------------------
describe("minimizeCrossings", () => {
it("returns single layer unchanged", () => {
const layers = [["A", "B", "C"]];
const result = minimizeCrossings(layers, [], new Map());
expect(result).toEqual([["A", "B", "C"]]);
});
it("returns empty layers unchanged", () => {
const result = minimizeCrossings([], [], new Map());
expect(result).toEqual([]);
});
it("reduces crossings in a simple case", () => {
// Layer 0: [A, B], Layer 1: [C, D]
// Edges: A->D, B->C (these cross if A is before B and C is before D)
const nodes = [node("A"), node("B"), node("C"), node("D")];
const layers = [["A", "B"], ["C", "D"]];
const edges = [edge("A", "D"), edge("B", "C")];
const result = minimizeCrossings(layers, edges, nodeMap(nodes));
// After minimization, should reorder to eliminate crossing
// Either layer 0 becomes [B, A] or layer 1 becomes [D, C]
// Verify no crossings remain
const l0Pos = new Map<string, number>();
const l1Pos = new Map<string, number>();
for (let i = 0; i < result[0].length; i++) l0Pos.set(result[0][i], i);
for (let i = 0; i < result[1].length; i++) l1Pos.set(result[1][i], i);
// Edge A->D and B->C should not cross
const aPosTop = l0Pos.get("A")!;
const bPosTop = l0Pos.get("B")!;
const cPosBot = l1Pos.get("C")!;
const dPosBot = l1Pos.get("D")!;
// Crossing means (A before B but D after C) or (A after B but D before C)
const crosses =
(aPosTop < bPosTop && dPosBot > cPosBot) ||
(aPosTop > bPosTop && dPosBot < cPosBot);
expect(crosses).toBe(false);
});
it("preserves order when no crossings exist", () => {
// A->C, B->D (no crossing since A<B and C<D)
const nodes = [node("A"), node("B"), node("C"), node("D")];
const layers = [["A", "B"], ["C", "D"]];
const edges = [edge("A", "C"), edge("B", "D")];
const result = minimizeCrossings(layers, edges, nodeMap(nodes));
// Order should remain [A, B] and [C, D]
expect(result[0]).toEqual(["A", "B"]);
expect(result[1]).toEqual(["C", "D"]);
});
});
// ---------------------------------------------------------------------------
// assignCoordinates
// ---------------------------------------------------------------------------
describe("assignCoordinates", () => {
it("assigns single node at origin", () => {
const nodes = [node("A", 200, 100)];
const nMap = nodeMap(nodes);
const result = assignCoordinates([["A"]], nMap, {
horizontalGap: 100,
verticalGap: 40,
});
expect(result.get("A")).toEqual({ x: 0, y: 0 });
});
it("stacks nodes vertically within a layer", () => {
const nodes = [node("A", 200, 100), node("B", 200, 80)];
const nMap = nodeMap(nodes);
const result = assignCoordinates([["A", "B"]], nMap, {
horizontalGap: 100,
verticalGap: 40,
});
const posA = result.get("A")!;
const posB = result.get("B")!;
// Same X
expect(posA.x).toBe(posB.x);
// B below A with gap
expect(posB.y).toBe(posA.y + 100 + 40); // A.height + gap
});
it("places layers left to right with horizontal gap", () => {
const nodes = [node("A", 200, 100), node("B", 150, 80)];
const nMap = nodeMap(nodes);
const result = assignCoordinates([["A"], ["B"]], nMap, {
horizontalGap: 100,
verticalGap: 40,
});
const posA = result.get("A")!;
const posB = result.get("B")!;
expect(posA.x).toBe(0);
expect(posB.x).toBe(200 + 100); // A.width + gap
});
it("returns empty map for empty layers", () => {
const result = assignCoordinates([], new Map(), {
horizontalGap: 100,
verticalGap: 40,
});
expect(result.size).toBe(0);
});
it("centers shorter layers vertically", () => {
// Layer 0: two nodes (taller), Layer 1: one node (shorter)
const nodes = [
node("A", 200, 100),
node("B", 200, 100),
node("C", 200, 100),
];
const nMap = nodeMap(nodes);
const result = assignCoordinates([["A", "B"], ["C"]], nMap, {
horizontalGap: 100,
verticalGap: 40,
});
const posA = result.get("A")!;
const posC = result.get("C")!;
// Layer 0 total height: 100 + 40 + 100 = 240
// Layer 1 total height: 100
// Layer 1 y offset: (240 - 100) / 2 = 70
expect(posA.y).toBe(0);
expect(posC.y).toBe(70);
});
});
// ---------------------------------------------------------------------------
// Full Sugiyama algorithm (integration)
// ---------------------------------------------------------------------------
describe("createSugiyamaAlgorithm", () => {
const sugiyama = createSugiyamaAlgorithm();
it("handles single node", () => {
const result = sugiyama.layout({
nodes: [node("A")],
edges: [],
});
expect(result.positions.size).toBe(1);
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
});
it("lays out linear chain A->B->C left to right", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C")];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(3);
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
// X should increase left to right
expect(posA.x).toBeLessThan(posB.x);
expect(posB.x).toBeLessThan(posC.x);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
});
it("lays out diamond graph correctly", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("A", "C"),
edge("B", "D"),
edge("C", "D"),
];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(4);
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
const posD = result.positions.get("D")!;
// A at layer 0, B and C at layer 1, D at layer 2
expect(posA.x).toBeLessThan(posB.x);
expect(posA.x).toBeLessThan(posC.x);
expect(posB.x).toBe(posC.x); // same layer
expect(posB.x).toBeLessThan(posD.x);
// B and C should be at different Y positions
expect(posB.y).not.toBe(posC.y);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
});
it("handles cycle A->B->C->A", () => {
const nodes = [node("A"), node("B"), node("C")];
const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")];
const result = sugiyama.layout({ nodes, edges });
// All nodes should be positioned
expect(result.positions.size).toBe(3);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
});
it("handles wide graph (many nodes in one layer)", () => {
const nodes = [
node("root"),
node("A"),
node("B"),
node("C"),
node("D"),
node("E"),
];
const edges = [
edge("root", "A"),
edge("root", "B"),
edge("root", "C"),
edge("root", "D"),
edge("root", "E"),
];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(6);
// All child nodes should have same X (same layer)
const childPositions = ["A", "B", "C", "D", "E"].map(
(id) => result.positions.get(id)!,
);
const childX = childPositions[0].x;
for (const pos of childPositions) {
expect(pos.x).toBe(childX);
}
// Root should be at a smaller X
expect(result.positions.get("root")!.x).toBeLessThan(childX);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
});
it("handles empty input", () => {
const result = sugiyama.layout({ nodes: [], edges: [] });
expect(result.positions.size).toBe(0);
});
it("is idempotent (same input produces same output)", () => {
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("A", "C"),
edge("B", "D"),
edge("C", "D"),
];
const result1 = sugiyama.layout({ nodes, edges });
const result2 = sugiyama.layout({ nodes, edges });
for (const n of nodes) {
const pos1 = result1.positions.get(n.id)!;
const pos2 = result2.positions.get(n.id)!;
expect(pos1.x).toBe(pos2.x);
expect(pos1.y).toBe(pos2.y);
}
});
it("respects custom gap configuration", () => {
const custom = createSugiyamaAlgorithm({
horizontalGap: 200,
verticalGap: 80,
});
const nodes = [node("A", 100, 50), node("B", 100, 50)];
const edges = [edge("A", "B")];
const result = custom.layout({ nodes, edges });
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
// Horizontal gap should be respected: B.x = A.x + A.width + gap
expect(posB.x - posA.x).toBe(100 + 200);
});
it("handles complex multi-layer graph", () => {
// S -> A, S -> B
// A -> C, A -> D
// B -> D, B -> E
// C -> F, D -> F, E -> F
const nodes = [
node("S"),
node("A"),
node("B"),
node("C"),
node("D"),
node("E"),
node("F"),
];
const edges = [
edge("S", "A"),
edge("S", "B"),
edge("A", "C"),
edge("A", "D"),
edge("B", "D"),
edge("B", "E"),
edge("C", "F"),
edge("D", "F"),
edge("E", "F"),
];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(7);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
// S should be leftmost, F should be rightmost
const posS = result.positions.get("S")!;
const posF = result.positions.get("F")!;
expect(posS.x).toBeLessThan(posF.x);
// Layer ordering should hold for all edges
for (const e of edges) {
const srcX = result.positions.get(e.source)!.x;
const tgtX = result.positions.get(e.target)!.x;
expect(srcX).toBeLessThan(tgtX);
}
});
it("produces positions for all nodes even in complex cycles", () => {
// A->B->C->D->A plus A->C (multiple cycles)
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("B", "C"),
edge("C", "D"),
edge("D", "A"),
edge("A", "C"),
];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(4);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
});
it("has name 'sugiyama'", () => {
expect(sugiyama.name).toBe("sugiyama");
});
it("handles nodes with varying sizes", () => {
const nodes = [
node("tiny", 50, 30),
node("medium", 200, 100),
node("large", 400, 200),
];
const edges = [edge("tiny", "medium"), edge("medium", "large")];
const result = sugiyama.layout({ nodes, edges });
expect(result.positions.size).toBe(3);
assertFiniteCoordinates(result.positions);
assertNoOverlaps(result.positions, nodes);
// X should increase
const positions = nodes.map((n) => result.positions.get(n.id)!);
expect(positions[0].x).toBeLessThan(positions[1].x);
expect(positions[1].x).toBeLessThan(positions[2].x);
});
it("handles parallel paths of different lengths", () => {
// A -> B -> C -> D (long path)
// A -> D (short path)
const nodes = [node("A"), node("B"), node("C"), node("D")];
const edges = [
edge("A", "B"),
edge("B", "C"),
edge("C", "D"),
edge("A", "D"),
];
const result = sugiyama.layout({ nodes, edges });
// D should be at layer 3 (longest path), not layer 1
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
const posD = result.positions.get("D")!;
expect(posA.x).toBeLessThan(posB.x);
expect(posB.x).toBeLessThan(posC.x);
expect(posC.x).toBeLessThan(posD.x);
assertNoOverlaps(result.positions, nodes);
});
});
+223
View File
@@ -0,0 +1,223 @@
import { describe, it, expect } from "vitest";
import { parseLayoutToken, tokenToAlgorithm } from "../../src/layout/tokens";
import { createHorizontalAlgorithm } from "../../src/layout/algorithms/horizontal";
import { createVerticalAlgorithm } from "../../src/layout/algorithms/vertical";
import type { LayoutNode, LayoutInput } from "../../src/layout/types";
function node(id: string, width = 100, height = 60): LayoutNode {
return { id, width, height };
}
function input(nodes: LayoutNode[]): LayoutInput {
return { nodes, edges: [] };
}
// ---------------------------------------------------------------------------
// parseLayoutToken
// ---------------------------------------------------------------------------
describe("parseLayoutToken", () => {
it("parses [HORIZONTAL] from group title", () => {
const result = parseLayoutToken("My Group [HORIZONTAL]");
expect(result).toEqual({ mode: "horizontal" });
});
it("parses [vertical] case-insensitively", () => {
const result = parseLayoutToken("stuff [vertical] more");
expect(result).toEqual({ mode: "vertical" });
});
it("parses [2ROW] as grid with 2 rows", () => {
const result = parseLayoutToken("[2ROW]");
expect(result).toEqual({ mode: "grid", count: 2, dimension: "row" });
});
it("parses [3col] case-insensitively as grid with 3 columns", () => {
const result = parseLayoutToken("Title [3col]");
expect(result).toEqual({ mode: "grid", count: 3, dimension: "col" });
});
it("treats [1ROW] as horizontal alias", () => {
const result = parseLayoutToken("[1ROW]");
expect(result).toEqual({ mode: "horizontal" });
});
it("treats [1COL] as vertical alias", () => {
const result = parseLayoutToken("[1COL]");
expect(result).toEqual({ mode: "vertical" });
});
it("returns null when no token present", () => {
const result = parseLayoutToken("No token here");
expect(result).toBeNull();
});
it("returns null for [10ROW] (only 1-9 supported)", () => {
const result = parseLayoutToken("[10ROW]");
expect(result).toBeNull();
});
it("returns null for [0ROW] (0 not valid)", () => {
const result = parseLayoutToken("[0ROW]");
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Algorithm behavior: horizontal
// ---------------------------------------------------------------------------
describe("horizontal algorithm", () => {
it("places 3 nodes left-to-right with Y centered", () => {
const nodes = [node("A", 100, 40), node("B", 80, 60), node("C", 120, 20)];
const algo = createHorizontalAlgorithm(40);
const result = algo.layout(input(nodes));
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
// Max height is 60 (node B)
// A: y = 60/2 - 40/2 = 10
// B: y = 60/2 - 60/2 = 0
// C: y = 60/2 - 20/2 = 20
expect(posA).toEqual({ x: 0, y: 10 });
expect(posB).toEqual({ x: 140, y: 0 }); // 0 + 100 + 40
expect(posC).toEqual({ x: 260, y: 20 }); // 140 + 80 + 40
});
it("returns empty positions for empty input", () => {
const algo = createHorizontalAlgorithm();
const result = algo.layout(input([]));
expect(result.positions.size).toBe(0);
});
it("places single node at origin", () => {
const algo = createHorizontalAlgorithm();
const result = algo.layout(input([node("A", 100, 60)]));
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
});
});
// ---------------------------------------------------------------------------
// Algorithm behavior: vertical
// ---------------------------------------------------------------------------
describe("vertical algorithm", () => {
it("places 3 nodes top-to-bottom with X centered", () => {
const nodes = [node("A", 80, 40), node("B", 120, 60), node("C", 60, 30)];
const algo = createVerticalAlgorithm(40);
const result = algo.layout(input(nodes));
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posC = result.positions.get("C")!;
// Max width is 120 (node B)
// A: x = 120/2 - 80/2 = 20
// B: x = 120/2 - 120/2 = 0
// C: x = 120/2 - 60/2 = 30
expect(posA).toEqual({ x: 20, y: 0 });
expect(posB).toEqual({ x: 0, y: 80 }); // 0 + 40 + 40
expect(posC).toEqual({ x: 30, y: 180 }); // 80 + 60 + 40
});
it("returns empty positions for empty input", () => {
const algo = createVerticalAlgorithm();
const result = algo.layout(input([]));
expect(result.positions.size).toBe(0);
});
it("places single node at origin", () => {
const algo = createVerticalAlgorithm();
const result = algo.layout(input([node("A", 100, 60)]));
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
});
});
// ---------------------------------------------------------------------------
// Algorithm behavior: grid
// ---------------------------------------------------------------------------
describe("grid algorithm via tokenToAlgorithm", () => {
it("[2ROW] with 6 nodes: 2 rows x 3 columns", () => {
const token = parseLayoutToken("[2ROW]")!;
const algo = tokenToAlgorithm(token, 40);
const nodes = [
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
node("D", 100, 60), node("E", 100, 60), node("F", 100, 60),
];
const result = algo.layout(input(nodes));
expect(result.positions.size).toBe(6);
// Row 0: A, B, C
// Row 1: D, E, F
const posA = result.positions.get("A")!;
const posB = result.positions.get("B")!;
const posD = result.positions.get("D")!;
// Same-size nodes, gap=40: cellWidth=140, cellHeight=100
expect(posA).toEqual({ x: 0, y: 0 });
expect(posB).toEqual({ x: 140, y: 0 });
expect(posD).toEqual({ x: 0, y: 100 });
});
it("[3COL] with 6 nodes: 2 rows x 3 columns", () => {
const token = parseLayoutToken("[3COL]")!;
const algo = tokenToAlgorithm(token, 40);
const nodes = [
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
node("D", 100, 60), node("E", 100, 60), node("F", 100, 60),
];
const result = algo.layout(input(nodes));
expect(result.positions.size).toBe(6);
// Row 0: A, B, C
// Row 1: D, E, F
const posA = result.positions.get("A")!;
const posD = result.positions.get("D")!;
expect(posA).toEqual({ x: 0, y: 0 });
expect(posD).toEqual({ x: 0, y: 100 });
});
it("[2ROW] with 5 nodes: row 0 has 3, row 1 has 2", () => {
const token = parseLayoutToken("[2ROW]")!;
const algo = tokenToAlgorithm(token, 40);
const nodes = [
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
node("D", 100, 60), node("E", 100, 60),
];
const result = algo.layout(input(nodes));
expect(result.positions.size).toBe(5);
// cols = ceil(5/2) = 3
// Row 0: A(0,0), B(0,1), C(0,2)
// Row 1: D(1,0), E(1,1)
const posC = result.positions.get("C")!;
const posD = result.positions.get("D")!;
const posE = result.positions.get("E")!;
expect(posC.y).toBe(0); // row 0
expect(posD.y).toBe(100); // row 1
expect(posE.y).toBe(100); // row 1
expect(posE.x).toBe(140); // col 1
});
it("empty input returns empty positions", () => {
const token = parseLayoutToken("[2ROW]")!;
const algo = tokenToAlgorithm(token, 40);
const result = algo.layout(input([]));
expect(result.positions.size).toBe(0);
});
it("single node placed at origin", () => {
const token = parseLayoutToken("[3COL]")!;
const algo = tokenToAlgorithm(token, 40);
const result = algo.layout(input([node("A", 100, 60)]));
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
});
});
+1 -1
View File
@@ -21,5 +21,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "src", "tests"]
"include": ["vite.config.ts", "src", "tests", "scripts"]
}
Generated
+367
View File
@@ -0,0 +1,367 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
]
[[package]]
name = "bump-my-version"
version = "1.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "questionary" },
{ name = "rich" },
{ name = "rich-click" },
{ name = "tomlkit" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/11/0f73c652396f86197ea6d509c78e8c44c3483d9a86437ca53ce55edca8e8/bump_my_version-1.2.7.tar.gz", hash = "sha256:d915a10b41e0c9db5a2fa39bde9f45f92e1e4194242d819c9ceb9eca8831cd21", size = 1198071, upload-time = "2026-02-14T13:44:59.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl", hash = "sha256:16f89360f979c0a8eb3249ebe3e13ae4f0cb5481d7bb58e12a9f66996922acfd", size = 60013, upload-time = "2026-02-14T13:44:58.318Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "comfyui-node-organizer"
version = "1.3.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "bump-my-version" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "bump-my-version", specifier = ">=1.2.2" }]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-click"
version = "1.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
]
[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "wcmatch"
version = "10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bracex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
[[package]]
name = "wcwidth"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
]
+2 -2
View File
@@ -3,13 +3,13 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["tests/**/*.test.ts"],
include: ["tests/unit/**/*.test.ts"],
exclude: ["tests/e2e/**"],
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: [
"src/index.ts", // Extension entry point not testable without ComfyUI
"src/layout/reroute-collapse.ts", // No fixtures with reroute nodes yet
],
thresholds: {
lines: 70,