mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
Compare commits
18 Commits
drjkl/subg
...
ext-api/i-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5f3cf550 | ||
|
|
d4b04bac6c | ||
|
|
c5865d3717 | ||
|
|
2ec4fec8c2 | ||
|
|
c614243e36 | ||
|
|
e010c47110 | ||
|
|
192c102c7a | ||
|
|
58d6d2a157 | ||
|
|
e7f642765f | ||
|
|
96addd0e94 | ||
|
|
7200eb0dc4 | ||
|
|
e616a9386a | ||
|
|
fe6d4399c3 | ||
|
|
6dd361bbca | ||
|
|
8f68be5699 | ||
|
|
653ef1a4f0 | ||
|
|
c16052e2e3 | ||
|
|
3e94459340 |
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
|
||||
#
|
||||
# Runs on any PR touching extension-api declaration files, extension-api-v2
|
||||
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
|
||||
#
|
||||
# Two jobs:
|
||||
# test — vitest run against src/extension-api-v2/__tests__/
|
||||
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
|
||||
# blast_radius ≥ 2.0 category is missing a stub triple)
|
||||
#
|
||||
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
|
||||
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
|
||||
name: 'CI: Tests Extension API'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Extension API tests (vitest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run extension-api test suite
|
||||
run: pnpm test:extension-api
|
||||
|
||||
- name: Run with coverage (push only)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm test:extension-api:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name == 'push'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: extension-api
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
compat-floor:
|
||||
name: Compat-floor gate (blast_radius ≥ 2.0)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Check compat floor
|
||||
run: python3 scripts/check-compat-floor.py
|
||||
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
|
||||
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.
|
||||
97
.github/workflows/extension-api-publish.yml
vendored
Normal file
97
.github/workflows/extension-api-publish.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
|
||||
#
|
||||
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
|
||||
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
|
||||
#
|
||||
# Prerequisites (one-time human setup):
|
||||
# - NPM_TOKEN secret must be set in the repo/org settings with publish
|
||||
# access to the @comfyorg scope on npmjs.com.
|
||||
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
|
||||
#
|
||||
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Publish'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'extension-api-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run — build and verify without publishing'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create GitHub Release
|
||||
id-token: write # needed for npm provenance via OIDC
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # full history for release notes
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Setup npm registry
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Verify package version matches tag
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
|
||||
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
|
||||
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
|
||||
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version check passed: $PKG_VERSION"
|
||||
|
||||
- name: Publish to npm (with provenance)
|
||||
if: github.event_name == 'push' || inputs.dry_run == 'false'
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Dry-run report
|
||||
if: inputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "=== DRY RUN — would publish ==="
|
||||
cd packages/extension-api
|
||||
npm pack --dry-run
|
||||
echo "=== End dry run ==="
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = context.ref.replace('refs/tags/', '')
|
||||
const { data: release } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: tag,
|
||||
name: tag,
|
||||
generate_release_notes: true,
|
||||
draft: false,
|
||||
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
|
||||
})
|
||||
console.log(`Release created: ${release.html_url}`)
|
||||
65
.github/workflows/extension-api-typecheck.yml
vendored
Normal file
65
.github/workflows/extension-api-typecheck.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Description: Typecheck and build the @comfyorg/extension-api package.
|
||||
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
|
||||
# implementations, or the package scaffold — so regressions in the published
|
||||
# contract are caught before merge.
|
||||
#
|
||||
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
|
||||
name: 'Extension API: Typecheck'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extensions/core/*.v2.ts'
|
||||
- 'src/services/extension-api-service.ts'
|
||||
- 'packages/extension-api/**'
|
||||
- '.github/workflows/extension-api-*.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Build + typecheck @comfyorg/extension-api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build package (emit declarations)
|
||||
run: pnpm --filter @comfyorg/extension-api build
|
||||
|
||||
- name: Typecheck package
|
||||
run: pnpm --filter @comfyorg/extension-api typecheck
|
||||
|
||||
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
|
||||
# Verifies the published types are consumable from an external module
|
||||
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
|
||||
# checked in to packages/extension-api/test/smoke/.
|
||||
run: |
|
||||
cd packages/extension-api
|
||||
if [ -d test/smoke ]; then
|
||||
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
|
||||
else
|
||||
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
|
||||
fi
|
||||
@@ -172,7 +172,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
|
||||
18. Try to minimize the surface area (exported values) of each module and composable
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`. **Exception**: `src/extension-api/index.ts` is the published npm package entry point (`@comfyorg/extension-api`) and is explicitly exempt from this rule.
|
||||
20. Keep functions short and functional
|
||||
21. Minimize [nesting](https://wiki.c2.com/?ArrowAntiPattern), e.g. `if () { ... }` or `for () { ... }`
|
||||
22. Avoid mutable state, prefer immutability and assignment at point of declaration
|
||||
|
||||
@@ -393,62 +393,31 @@ export class SubgraphHelper {
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const widgetEntries = (node.widgets ?? []).flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [
|
||||
[widget.sourceNodeId, widget.sourceWidgetName] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(node.id)
|
||||
)
|
||||
const previewExposures = Array.isArray(
|
||||
serializedNode?.properties?.previewExposures
|
||||
)
|
||||
? serializedNode.properties.previewExposures
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const previewEntries = previewExposures.flatMap((entry) => {
|
||||
if (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'sourceNodeId' in entry &&
|
||||
typeof entry.sourceNodeId === 'string' &&
|
||||
'sourcePreviewName' in entry &&
|
||||
typeof entry.sourcePreviewName === 'string'
|
||||
) {
|
||||
return [
|
||||
[entry.sourceNodeId, entry.sourcePreviewName] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets: [...widgetEntries, ...previewEntries]
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function getPromotedWidgets(
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
const widgetEntries = widgets.flatMap((widget) => {
|
||||
return widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
@@ -40,29 +40,6 @@ export async function getPromotedWidgets(
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
const previewExposures = serializedNode?.properties?.previewExposures
|
||||
const previewEntries = Array.isArray(previewExposures)
|
||||
? previewExposures.flatMap((exposure) => {
|
||||
if (
|
||||
typeof exposure === 'object' &&
|
||||
exposure !== null &&
|
||||
'sourceNodeId' in exposure &&
|
||||
typeof exposure.sourceNodeId === 'string' &&
|
||||
'sourcePreviewName' in exposure &&
|
||||
typeof exposure.sourcePreviewName === 'string'
|
||||
) {
|
||||
return [[exposure.sourceNodeId, exposure.sourcePreviewName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
: []
|
||||
|
||||
return [...widgetEntries, ...previewEntries]
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
@@ -101,6 +78,12 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,149 +1,20 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
interface MutableWorkflowNode {
|
||||
id: number
|
||||
pos?: [number, number]
|
||||
widgets_values?: unknown[]
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type MutableWorkflow = ComfyWorkflowJSON & {
|
||||
last_node_id: number
|
||||
nodes: MutableWorkflowNode[]
|
||||
}
|
||||
|
||||
interface HostWidgetSnapshot {
|
||||
name: string
|
||||
sourceNodeId: string | null
|
||||
sourceWidgetName: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PrimitiveFanoutSnapshot {
|
||||
hostWidgetNames: string[]
|
||||
hostWidgetValues: HostWidgetSnapshot[]
|
||||
interiorWidgetValues: unknown[]
|
||||
primitiveOutputLinks: unknown
|
||||
primitiveOriginLinkCount: number
|
||||
serializedProperties: Record<string, unknown>
|
||||
}
|
||||
|
||||
function loadPrimitiveFanoutWorkflow(): MutableWorkflow {
|
||||
return JSON.parse(
|
||||
readFileSync(
|
||||
assetPath('subgraphs/subgraph-with-link-and-proxied-primitive.json'),
|
||||
'utf-8'
|
||||
)
|
||||
) as MutableWorkflow
|
||||
}
|
||||
|
||||
function createPrimitiveFanoutMultiHostWorkflow(): ComfyWorkflowJSON {
|
||||
const workflow = loadPrimitiveFanoutWorkflow()
|
||||
const original = workflow.nodes.find((node) => node.id === 2)
|
||||
if (!original) throw new Error('Primitive fanout fixture is missing host 2')
|
||||
|
||||
original.widgets_values = ['first-host', 11]
|
||||
const clone = structuredClone(original)
|
||||
clone.id = 12
|
||||
clone.pos = [900, 409]
|
||||
clone.widgets_values = ['second-host', 22]
|
||||
workflow.nodes.push(clone)
|
||||
workflow.last_node_id = Math.max(workflow.last_node_id, clone.id)
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
function createUnresolvableProxyWorkflow(): ComfyWorkflowJSON {
|
||||
const workflow = loadPrimitiveFanoutWorkflow()
|
||||
const host = workflow.nodes.find((node) => node.id === 2)
|
||||
if (!host) throw new Error('Primitive fanout fixture is missing host 2')
|
||||
|
||||
host.properties = {
|
||||
...host.properties,
|
||||
proxyWidgets: [['9999', 'missing_widget']]
|
||||
}
|
||||
host.widgets_values = ['quarantined-host-value']
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
|
||||
const primitiveNode = hostNode.subgraph.getNodeById(4)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === 4).length
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
|
||||
return {
|
||||
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
|
||||
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
sourceNodeId:
|
||||
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
|
||||
? widget.sourceNodeId
|
||||
: null,
|
||||
sourceWidgetName:
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
? widget.sourceWidgetName
|
||||
: null,
|
||||
value: widget.value
|
||||
})),
|
||||
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
|
||||
(node.widgets ?? []).map((widget) => widget.value)
|
||||
),
|
||||
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const node = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
return node?.properties ?? {}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
@@ -170,160 +41,6 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(beforeReload.hostWidgetNames).toContain('value')
|
||||
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgetErrorQuarantine'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(afterReload.interiorWidgetValues).toEqual(
|
||||
beforeReload.interiorWidgetValues
|
||||
)
|
||||
expect(
|
||||
afterReload.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe(
|
||||
beforeReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
)
|
||||
expect(afterReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(afterReload.serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
})
|
||||
|
||||
test('Multiple SubgraphNode hosts keep independent migrated widget values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
createPrimitiveFanoutMultiHostWorkflow()
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '12'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const firstHost = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
const secondHost = await getPrimitiveFanoutSnapshot(comfyPage, '12')
|
||||
|
||||
expect(
|
||||
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('second-host')
|
||||
expect(
|
||||
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('second-host')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const firstAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
const secondAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '12')
|
||||
expect(
|
||||
firstAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
firstAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
secondAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('second-host')
|
||||
expect(
|
||||
secondAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('second-host')
|
||||
})
|
||||
|
||||
test('Nested preview exposures render through serialized chain resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'8'
|
||||
)
|
||||
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(nestedHostProperties.previewExposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '6',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
|
||||
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
await expect(nestedSubgraphNode).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
// A host whose only promoted content is a preview exposure has no
|
||||
// node.widgets entries and renders no `.lg-node-widgets` container; the
|
||||
// pseudo-widget surfaces via usePromotedPreviews instead.
|
||||
})
|
||||
|
||||
test('Legacy unresolvable proxy entry is omitted and quarantined on save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadGraphData(createUnresolvableProxyWorkflow())
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
|
||||
.not.toContain('missing_widget')
|
||||
|
||||
const serializedProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'2'
|
||||
)
|
||||
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'missing_widget'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'quarantined-host-value'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -770,15 +487,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The legacy `proxyWidgets` entry references an interior nodeId that
|
||||
// doesn't match the existing linked input's PromotedWidgetView source,
|
||||
// so migration creates a second SubgraphInput rather than deduping.
|
||||
// The intent of this test is that no legacy "<id>: <id>:" prefix
|
||||
// leaks into the rendered widget rows.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
await expect(widgetRows.first()).not.toContainText('6: 3:')
|
||||
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -93,11 +93,12 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
// Hosts whose only promoted content is preview exposures have empty
|
||||
// node.widgets, so the `.lg-node-widgets` container is not rendered at
|
||||
// all (gated by `<NodeWidgets v-if="nodeData.widgets?.length">`). The
|
||||
// assertion above (count by name returns the right number) already
|
||||
// proves previews don't render as regular widget rows.
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -285,11 +285,11 @@ quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` has been removed. Canonical value-widget exposure is
|
||||
represented by linked `SubgraphInput`s. Canonical preview exposure is
|
||||
represented by host-scoped `properties.previewExposures` /
|
||||
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
|
||||
and must not be reintroduced as runtime authority.
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
@@ -325,5 +325,4 @@ for existing workflow consumers that still assume array order.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` is removed; callers query linked inputs or preview exposures
|
||||
directly.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
|
||||
187
docs/architecture/extension-api-v2/names-appendix.md
Normal file
187
docs/architecture/extension-api-v2/names-appendix.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Names That Must Agree Across Layers
|
||||
|
||||
**Task:** DOC1.E6
|
||||
**Date:** 2026-05-08
|
||||
**Patterns cross-walked:** S2.N16 (widget array access), S13.SC1 (ComfyNodeDef inspection), S15.OS1 (dynamic output mutation)
|
||||
|
||||
This appendix enumerates the terms that span at least two of the four layers — Python backend, v1 frontend (`ComfyExtension`/LiteGraph), v2 extension API (`NodeHandle`/`WidgetHandle`), and ECS World components — and calls out real inconsistencies where the same concept is named differently or the semantics diverge. Future contributors who rename or refactor any of these terms must propagate the change across all layers listed.
|
||||
|
||||
---
|
||||
|
||||
## Layers
|
||||
|
||||
| Layer | Owner | Primary source |
|
||||
|-------|-------|---------------|
|
||||
| **Python backend** | ComfyUI server | `NODE_CLASS_MAPPINGS`, `INPUT_TYPES`, `RETURN_TYPES` |
|
||||
| **v1 frontend** | LiteGraph / ComfyExtension | `src/types/comfy.ts`, `src/schemas/nodeDefSchema.ts`, `LGraphNode.ts` |
|
||||
| **v2 extension API** | This project | `src/extension-api/node.ts`, `src/extension-api/widget.ts` |
|
||||
| **ECS World** | Alex's branch (PR #11939) | `src/services/extension-api-service.ts` (stubs), `@/world/entityIds` |
|
||||
|
||||
---
|
||||
|
||||
## Term 1 — Node class identifier (`class_type` / `type` / `comfyClass`)
|
||||
|
||||
**What it is:** The string that identifies which Python class backs a node. Used to look up `object_info`, serialize the prompt, and match `INPUT_TYPES` definitions.
|
||||
|
||||
| Layer | Name | Value example | Notes |
|
||||
|-------|------|--------------|-------|
|
||||
| Python backend | Python class name | `'KSampler'` | The class registered in `NODE_CLASS_MAPPINGS` |
|
||||
| Execution prompt JSON (API format) | `class_type` | `"class_type": "KSampler"` | Key in the flat prompt dict |
|
||||
| UWF backend spec | `class_type` | `"class_type": "KSampler"` | Unchanged from API format (per `uwf-backend-data-model.md`) |
|
||||
| `ComfyNodeDef` (v1 schema) | `name` | `nodeData.name === 'KSampler'` | The `name` field in the server's `/object_info` response |
|
||||
| `LGraphNode` (v1) | `node.type` | `node.type === 'KSampler'` | LiteGraph's class string; set at registration |
|
||||
| v2 `NodeHandle` | `handle.type` | `handle.type === 'KSampler'` | `readonly type: string` in `src/extension-api/node.ts:260` |
|
||||
| v2 `NodeHandle` | `handle.comfyClass` | `handle.comfyClass === 'KSampler'` | `readonly comfyClass: string` in `src/extension-api/node.ts:269` |
|
||||
| ECS `NodeTypeData` component | `type` + `comfyClass` | both fields | Stub in `extension-api-service.ts:61–63` |
|
||||
|
||||
**⚠ Inconsistency — `type` vs `comfyClass`:** v2 `NodeHandle` exposes **both** `type` and `comfyClass` because for most nodes they are equal, but for virtual/reroute nodes `type` is the LiteGraph registration string while `comfyClass` is the actual Python class backing the node. Extensions that compare against ComfyUI node names should always use `comfyClass`. Extensions that filter by LiteGraph registration (for `nodeTypes:` filter in `defineNodeExtension`) should use `type`. The distinction must be preserved — collapsing them back to one field would break reroute/virtual-node detection.
|
||||
|
||||
**Rule:** `class_type` (wire format, Python, UWF) = `comfyClass` (v2 API) = `name` in `ComfyNodeDef` = `node.type` in LiteGraph for non-virtual nodes.
|
||||
|
||||
---
|
||||
|
||||
## Term 2 — Node display name (`display_name` / `title`)
|
||||
|
||||
**What it is:** The human-readable label shown in the node header and search results. Distinct from the class identifier.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `NODE_DISPLAY_NAME_MAPPINGS[class]` (optional) | If absent, falls back to the class name |
|
||||
| `ComfyNodeDef` (v1 schema) | `display_name` | `nodeData.display_name` — always a string per zod schema (`src/schemas/nodeDefSchema.ts:279`) |
|
||||
| `LGraphNode` (v1) | `node.title` | Set during `nodeCreated`; extensions mutate `node.title` directly to rename nodes |
|
||||
| v2 `NodeHandle` | `handle.title` (getter) + `handle.setTitle(s)` | `src/extension-api/node.ts:304–315`; accessor pair per D3.3/D6 hybrid rule |
|
||||
| ECS `NodeVisualData` component | `title` | Field name consistent with LiteGraph |
|
||||
|
||||
**No inconsistency.** `display_name` lives only in the schema/backend layer; `title` is the runtime/frontend term. The two layers don't overlap. Extension authors should use `handle.setTitle()` in v2; `node.title =` is the v1 equivalent. Both are stable.
|
||||
|
||||
---
|
||||
|
||||
## Term 3 — Widget name (`name` / `input_name` / `widget.name`)
|
||||
|
||||
**What it is:** The stable per-node-instance identifier for a widget slot. Used as the key in `widgets_values_named`, `promoted_inputs`, and for `WidgetHandle` lookup.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend `INPUT_TYPES` | Dict key (e.g. `'seed'`, `'steps'`) | The name as declared in the Python class |
|
||||
| UWF `promoted_inputs` | `input_name` | `{ node_id, input_name, display_name }` — snake_case to match Python origin |
|
||||
| UWF `spec.nodes.{id}.inputs` | Named key in the inputs object | e.g. `"seed": { "value": 123, "link": null }` |
|
||||
| v1 `LGraphNode.widgets` | `widget.name` | `widget.name === 'seed'` — used in `node.widgets.find(w => w.name === ...)` (S2.N16 pattern) |
|
||||
| v1 `node.widgets` iteration | position index (implicit) | `widgets_values` positional array — the root cause of widget shift bugs (S17.WV1) |
|
||||
| v2 `NodeHandle.widget(name)` | `name` argument | Lookup by name, not position (`src/extension-api/node.ts:383`) |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `name` parameter | `src/extension-api/node.ts:396–403`; stable key for the widget's lifetime |
|
||||
| v2 `WidgetHandle.name` | `readonly name: string` | `src/extension-api/widget.ts:277` — "stable within the node's lifetime" |
|
||||
| ECS `WidgetComponentSchema` | `name` field (inferred) | Widget schema component expected to carry `name` per `widgetComponents` import |
|
||||
|
||||
**⚠ Inconsistency — positional array vs. named:** v1 serialization stores widget values as a positional array (`widgets_values: [123, 20, 7.5]`). v2 API and UWF both use `name` as the stable key. The bridge is Austin's PR #10392 (`widgets_values_named`). Until that merges, any code that reads `node.widgets[i]` by index is fragile; code that reads `widget.name` is UWF-safe. The v2 `WidgetHandle` **must** be looked up by name, never by index — this is enforced by the API shape.
|
||||
|
||||
**Rule:** Always use the Python-declared input name as the canonical widget identifier. Never use position. `widget.name` (v1) = `name` parameter (v2 addWidget) = `input_name` (UWF wire format).
|
||||
|
||||
---
|
||||
|
||||
## Term 4 — Widget type string (`type` / `widgetType` / widget constructor key)
|
||||
|
||||
**What it is:** The string describing what kind of widget a slot is (e.g. `'INT'`, `'STRING'`, `'COMBO'`, `'IMAGE'`). Controls which widget constructor is used and which validation rules apply.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | Return value of `INPUT_TYPES()` tuple: first element | e.g. `("INT", {"default": 42})` — the type string |
|
||||
| `ComfyNodeDef` / `InputSpec` | Type string as zod-inferred from the schema | `INPUT_TYPES['required']['steps'] = ['INT', {...}]` |
|
||||
| `zBaseInputOptions` | `widgetType` field (optional override) | `src/schemas/nodeDefSchema.ts:33` — overrides the slot type for widget selection; rare |
|
||||
| v1 `getCustomWidgets` return | Record key | `{ MY_WIDGET: constructor }` — extension-registered type strings |
|
||||
| v1 `node.addWidget(type, ...)` | `type` first arg | The LiteGraph widget constructor key |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `type` first arg | `src/extension-api/node.ts:395, 403` |
|
||||
| v2 `WidgetHandle.type` | `readonly type: string` | `src/extension-api/widget.ts` line ~280 |
|
||||
| ECS `WidgetComponentSchema` | `type` field | Expected to match the Python-declared type string |
|
||||
|
||||
**⚠ Inconsistency — `widgetType` override:** The `widgetType` field in `zBaseInputOptions` is an override that makes the frontend render a different widget than the Python type implies. Extensions that inspect `nodeData.input.required[name][1].widgetType` to determine rendering (S13.SC1 pattern) must check this field **before** using the slot's primary type. The v2 `ComfyNodeDef`-inspection helper (`ctx.inspectNodeDef`) must resolve this correctly — it cannot just return `InputSpec[0]` (the Python type) as `widgetType`.
|
||||
|
||||
**Rule:** Python type string = v1 `widget.type` = v2 `WidgetHandle.type` = `widgetType` override if present (takes precedence).
|
||||
|
||||
---
|
||||
|
||||
## Term 5 — Slot type string (connection type / `'IMAGE'`, `'LATENT'`, etc.)
|
||||
|
||||
**What it is:** The type label on a node's input/output slot that governs which connections are valid. Distinct from widget type (a slot may be a pure connection point with no widget).
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_TYPES` tuple element | e.g. `RETURN_TYPES = ('IMAGE', 'MASK')` |
|
||||
| Python backend `INPUT_TYPES` | First element of required/optional tuple | `'IMAGE'` means "must receive an IMAGE connection" |
|
||||
| UWF `spec.nodes.{id}.outputs` | `{ "name": "IMAGE" }` per output | Output type declarations (new in UWF — not in old API format) |
|
||||
| v1 `LGraphNode` slot | `slot.type` | String on the slot object; extensions read/mutate via S15.OS1 |
|
||||
| v1 `node.addInput(name, type)` | `type` second arg | e.g. `node.addInput('mask', 'MASK')` |
|
||||
| v1 `node.addOutput(name, type)` | `type` second arg | S15.OS1 pattern |
|
||||
| v2 `SlotInfo` | `readonly type: string` | `src/extension-api/node.ts:82–83` |
|
||||
| v2 slot events | `event.slot.type` | Available in `onSlotConnected`, `onSlotDisconnected` |
|
||||
|
||||
**⚠ Inconsistency — dynamic mutation (S15.OS1):** v1 allows `slot.type = 'IMAGE'` and `node.outputs[i].type = newType` at runtime. v2 restricts this: output types must be declared in `INPUT_TYPES` / schema; runtime mutation is only via `node.declareOutputs(spec)` (proposed, not yet implemented). This is an intentional breaking change. The UWF spec formalizes this by requiring `spec.nodes.{id}.outputs` to be declared at save time, not derived from runtime state.
|
||||
|
||||
**Rule:** Slot type strings are uppercase by convention (matching Python `RETURN_TYPES`). v2 enforces schema-declaration; mutation-at-runtime is deprecated.
|
||||
|
||||
---
|
||||
|
||||
## Term 6 — Node output name (`output_name` / `RETURN_NAMES`)
|
||||
|
||||
**What it is:** Optional human-readable names for a node's output slots. Not the type — the label shown on the output connector.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_NAMES` class attribute | e.g. `RETURN_NAMES = ('upscaled_image', 'mask')` — optional |
|
||||
| `ComfyNodeDef` schema | `output_name` | `z.array(z.string()).optional()` in `src/schemas/nodeDefSchema.ts:275` |
|
||||
| v1 `LGraphNode` | `output.name` | String on the slot object; `node.outputs[i].name` |
|
||||
| v2 `SlotInfo` | `readonly name: string` | `src/extension-api/node.ts:80–81` — same field name |
|
||||
|
||||
**No inconsistency.** `RETURN_NAMES` → `output_name` in the schema → `slot.name` at runtime. All three refer to the same string. Field name shifts from snake_case (`output_name`) in the schema to camelCase-neutral (`name`) on the slot object — consistent with the rest of the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Term 7 — Extension name (`ComfyExtension.name` / `ExtensionOptions.name`)
|
||||
|
||||
**What it is:** The unique identifier for an extension used for hook ordering (D10b), scope registry keys, deprecation telemetry, and conflict detection.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| v1 `ComfyExtension` | `name: string` | Required field; `src/types/comfy.ts:108`; typically a dotted namespace like `'Comfy.Sidebar'` |
|
||||
| v2 `ExtensionOptions` | `name: string` | Required field; `src/extension-api/lifecycle.ts`; same semantic |
|
||||
| ECS scope registry | `extensionName` | Key component of `NodeInstanceScope`; used in `${extensionName}:${nodeEntityId}` scope key |
|
||||
| D6 telemetry | `apiVersion` | Separate field added by I-EXT.3 to `ExtensionOptions` for version tracking — not a replacement for `name` |
|
||||
|
||||
**No inconsistency.** Same field name and semantics across v1 and v2. The scope registry key format is `${extensionName}:${nodeEntityId}` — both components must be stable.
|
||||
|
||||
**Rule:** Extension names should follow the dotted-namespace convention (e.g. `'MyPublisher.MyExtension'`) to avoid collisions. This is currently advisory, not enforced.
|
||||
|
||||
---
|
||||
|
||||
## Term 8 — Node input display name (`display_name` / widget label)
|
||||
|
||||
**What it is:** The label shown next to the widget in the UI. Distinct from the internal `name` (key) used for serialization.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `display_name` in input options dict | `INPUT_TYPES()['required']['steps'] = ['INT', {'display_name': 'Steps'}]` |
|
||||
| `zBaseInputOptions` schema | `display_name: z.string().optional()` | `src/schemas/nodeDefSchema.ts:27` |
|
||||
| UWF `promoted_inputs` | `display_name` field | `{ node_id, input_name, display_name }` — the UI label for app-mode promoted inputs |
|
||||
| v1 `widget` | `widget.label` | Optional; falls back to `widget.name` if absent. Inspected in S2.N16 patterns |
|
||||
| v2 `WidgetHandle` | `label` getter | `src/extension-api/widget.ts:355` — "Defaults to the widget name" |
|
||||
|
||||
**No inconsistency** in naming — all layers call it `display_name` (schema/wire) or `label` (runtime). The two forms are consistent: `display_name` is the static schema-declared label; `label` is the runtime-settable display string. v2 exposes `label` as a settable accessor; the Python-declared `display_name` becomes its initial value.
|
||||
|
||||
---
|
||||
|
||||
## Summary: real inconsistencies to track
|
||||
|
||||
| # | Inconsistency | Risk | Resolution |
|
||||
|---|--------------|------|-----------|
|
||||
| 1 | `type` vs `comfyClass` on `NodeHandle` — two fields, must not collapse | Medium | Document: use `comfyClass` for Python identity, `type` for LiteGraph registration. Enforced by distinct fields. |
|
||||
| 2 | Widget identity: positional index (v1 `widgets_values`) vs name key (v2 / UWF) | **HIGH** | Bridge: Austin's PR #10392 (`widgets_values_named`). v2 `WidgetHandle` is name-only. Never look up by position in v2 code. |
|
||||
| 3 | `widgetType` override in `InputSpec` takes precedence over slot type for rendering | Medium | `ctx.inspectNodeDef` must resolve `widgetType` before returning slot type. Do not skip this field. |
|
||||
| 4 | Slot type mutation (S15.OS1): `slot.type = X` is valid v1, banned in v2/UWF | Medium | v2 must not expose a `setType()` mutator on `SlotInfo`. Schema-declare outputs; UWF enforces at save time. |
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **S2.N16** — widget array iteration/mutation (`node.widgets[i]`, `node.widgets.find(w => w.name === ...)`): the `name` field is the stable key; position is not. v2 forces name-based lookup.
|
||||
- **S13.SC1** — `ComfyNodeDef` inspection: callers must resolve `widgetType` override (Term 4) and understand `display_name` vs runtime `label` (Term 8). The `ctx.inspectNodeDef` typed helper (D4 G1 BLOCKER) wraps this correctly.
|
||||
- **S15.OS1** — dynamic output mutation: slot `type` strings are the agreed layer (Term 5), but v1 allows mutation that v2/UWF forbids. Track in I-PG.B2 as `strangler-bridge` until UWF Phase 3 covers output schema declaration.
|
||||
- **UWF backend data model** — `class_type`, `input_name`, `display_name` snake_case keys mirror Python origin. v2 API uses camelCase (`comfyClass`, widget `name`, widget `label`) per JS convention. No semantic difference; only case convention changes at the API boundary.
|
||||
@@ -6,17 +6,16 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
Six stores extract entity state out of class instances into centralized, queryable registries:
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -100,39 +99,62 @@ graph LR
|
||||
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
|
||||
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
|
||||
|
||||
## 3. Linked promoted widgets and preview exposures
|
||||
## 3. PromotionStore
|
||||
|
||||
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
|
||||
by linked `SubgraphInput`s, and display-only previews are represented by
|
||||
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
|
||||
Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
**File:** `src/stores/promotionStore.ts`
|
||||
|
||||
### Runtime shape
|
||||
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
|
||||
|
||||
```diagram
|
||||
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
|
||||
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
|
||||
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
|
||||
### State Shape
|
||||
|
||||
╭────────────────╮ ╭──────────────────────╮
|
||||
│ Subgraph host │────▶│ PreviewExposureStore │
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
|
||||
│ │ │
|
||||
graphId subgraphNodeId ordered promotion entries
|
||||
|
||||
graphRefCounts: Map<UUID, Map<string, number>>
|
||||
│ │ │
|
||||
graphId entryKey count of nodes promoting this widget
|
||||
```
|
||||
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
### Ref-Counting for O(1) Queries
|
||||
|
||||
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
|
||||
|
||||
```ts
|
||||
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
|
||||
// O(1) lookup: refCounts.get(key) > 0
|
||||
```
|
||||
|
||||
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
|
||||
|
||||
### View Reconciliation Layer
|
||||
|
||||
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
PS["PromotionStore
|
||||
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
|
||||
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
|
||||
(proxy widget)"]
|
||||
PV -->|"resolveDeepest()"| CW["Concrete Widget
|
||||
(leaf node)"]
|
||||
PV -->|"reads value"| WVS["WidgetValueStore"]
|
||||
```
|
||||
|
||||
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
|
||||
| Ref-counted queries | Yes | Efficient global state queries without scanning |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
|
||||
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
|
||||
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
|
||||
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -186,8 +208,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
|
||||
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
|
||||
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
@@ -200,7 +222,7 @@ graph TD
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, preview exposures)"]
|
||||
(getWidget, isPromotedByAny)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
(LayoutStore)"]
|
||||
@@ -227,12 +249,13 @@ graph TD
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
@@ -266,6 +289,7 @@ graph TD
|
||||
- value → WidgetValueStore
|
||||
- label → WidgetValueStore
|
||||
- disabled → WidgetValueStore
|
||||
- promotion status → PromotionStore
|
||||
- DOM pos/vis → DomWidgetStore"]
|
||||
W_rem["Remains on class:
|
||||
- _node back-ref
|
||||
@@ -309,8 +333,7 @@ graph TD
|
||||
|
||||
subgraph Subgraph["Subgraph (node component)"]
|
||||
S_ext["Extracted:
|
||||
- value exposure → linked inputs
|
||||
- preview exposure → PreviewExposureStore"]
|
||||
- promotions → PromotionStore"]
|
||||
S_rem["Remains on class:
|
||||
- name, description
|
||||
- inputs[], outputs[]
|
||||
@@ -337,15 +360,15 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.1",
|
||||
"version": "1.45.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -47,6 +47,9 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
|
||||
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
3
packages/extension-api/.gitignore
vendored
Normal file
3
packages/extension-api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
docs-build/
|
||||
build/
|
||||
node_modules/
|
||||
9
packages/extension-api/.npmignore
Normal file
9
packages/extension-api/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
src/
|
||||
scripts/
|
||||
tsconfig*.json
|
||||
typedoc.json
|
||||
docs-build/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
__tests__/
|
||||
node_modules/
|
||||
50
packages/extension-api/README.md
Normal file
50
packages/extension-api/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @comfyorg/extension-api
|
||||
|
||||
> **Status**: scaffolded. Package implementation pending PKG3 — see
|
||||
> `../../../plans/P2-extension-api-package.md` and
|
||||
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
|
||||
|
||||
The official TypeScript declaration package for ComfyUI extensions. This
|
||||
package replaces the practice of vendoring `comfy.d.ts` files in custom
|
||||
node repos.
|
||||
|
||||
## Install (post-publish)
|
||||
|
||||
```bash
|
||||
pnpm add -D @comfyorg/extension-api
|
||||
```
|
||||
|
||||
```ts
|
||||
import { defineExtension } from '@comfyorg/extension-api'
|
||||
|
||||
export default defineExtension({
|
||||
name: 'MyExtension',
|
||||
setup(ctx) {
|
||||
ctx.onNodeMounted((node) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
This package is built from the source-of-truth folder
|
||||
`../../src/extension-api/`. Do not edit the package's `build/` output
|
||||
directly.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
|
||||
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
|
||||
surface has stabilized.
|
||||
- Breaking changes follow semver strictly from `1.0.0` onward.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
|
||||
- `plans/P2-extension-api-package.md` — package structure plan
|
||||
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
|
||||
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
|
||||
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
|
||||
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration
|
||||
28
packages/extension-api/package.json
Normal file
28
packages/extension-api/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@comfyorg/extension-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Official TypeScript extension API for ComfyUI custom nodes",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./build/index.js"
|
||||
},
|
||||
"types": "./build/index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --emitDeclarationOnly --outDir build",
|
||||
"docs:build": "tsx scripts/build-docs.ts",
|
||||
"docs:watch": "tsx scripts/build-docs.ts --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"typedoc": "0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.6.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:api"
|
||||
]
|
||||
}
|
||||
}
|
||||
461
packages/extension-api/scripts/build-docs.ts
Normal file
461
packages/extension-api/scripts/build-docs.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
|
||||
*
|
||||
* Steps:
|
||||
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
|
||||
* 2. Post-process each markdown file:
|
||||
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
|
||||
* - Convert ``` fences without lang tag → ```ts
|
||||
* - Replace raw [TypeName] cross-refs with MDX relative links
|
||||
* - Wrap @example blocks in proper code fences
|
||||
* 3. Write final .mdx files to docs-build/mintlify/
|
||||
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
|
||||
*
|
||||
* Run: pnpm --filter @comfyorg/extension-api docs:build
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgRoot = path.resolve(__dirname, '..')
|
||||
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
|
||||
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
|
||||
const watchMode = process.argv.includes('--watch')
|
||||
|
||||
// ── Page metadata ────────────────────────────────────────────────────────────
|
||||
// Controls frontmatter for each generated page. Key = TypeDoc output filename
|
||||
// stem (lowercased). Unrecognised files get generic metadata.
|
||||
|
||||
interface PageMeta {
|
||||
title: string
|
||||
sidebarTitle?: string
|
||||
description: string
|
||||
icon?: string
|
||||
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
|
||||
order: number
|
||||
}
|
||||
|
||||
const PAGE_META: Record<string, PageMeta> = {
|
||||
// Top-level overview
|
||||
index: {
|
||||
title: 'Extension API Overview',
|
||||
description: 'TypeScript API reference for ComfyUI custom node extensions.',
|
||||
icon: 'puzzle-piece',
|
||||
group: 'root',
|
||||
order: 0
|
||||
},
|
||||
// Lifecycle / registration
|
||||
defineextension: {
|
||||
title: 'defineExtension',
|
||||
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 1
|
||||
},
|
||||
definenodeextension: {
|
||||
title: 'defineNodeExtension',
|
||||
description: 'Register a node-scoped extension reacting to node lifecycle events.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 2
|
||||
},
|
||||
definewidgetextension: {
|
||||
title: 'defineWidgetExtension',
|
||||
description: 'Register a custom widget type with its own DOM rendering.',
|
||||
icon: 'code',
|
||||
group: 'core',
|
||||
order: 3
|
||||
},
|
||||
extensionoptions: {
|
||||
title: 'ExtensionOptions',
|
||||
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
|
||||
group: 'core',
|
||||
order: 4
|
||||
},
|
||||
nodeextensionoptions: {
|
||||
title: 'NodeExtensionOptions',
|
||||
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
|
||||
group: 'core',
|
||||
order: 5
|
||||
},
|
||||
widgetextensionoptions: {
|
||||
title: 'WidgetExtensionOptions',
|
||||
description: 'Options object for defineWidgetExtension — custom widget rendering.',
|
||||
group: 'core',
|
||||
order: 6
|
||||
},
|
||||
onnoderemoved: {
|
||||
title: 'onNodeRemoved',
|
||||
sidebarTitle: 'onNodeRemoved',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
|
||||
group: 'core',
|
||||
order: 7
|
||||
},
|
||||
onnodemounted: {
|
||||
title: 'onNodeMounted',
|
||||
sidebarTitle: 'onNodeMounted',
|
||||
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
|
||||
group: 'core',
|
||||
order: 8
|
||||
},
|
||||
// Handles
|
||||
nodehandle: {
|
||||
title: 'NodeHandle',
|
||||
description: 'Controlled access to node state, mutations, slots, and events.',
|
||||
icon: 'circle-nodes',
|
||||
group: 'handles',
|
||||
order: 10
|
||||
},
|
||||
widgethandle: {
|
||||
title: 'WidgetHandle',
|
||||
description: 'Controlled access to widget state, mutations, and events.',
|
||||
icon: 'sliders',
|
||||
group: 'handles',
|
||||
order: 11
|
||||
},
|
||||
slotinfo: {
|
||||
title: 'SlotInfo',
|
||||
description: 'Read-only snapshot of a node slot (input or output).',
|
||||
group: 'handles',
|
||||
order: 12
|
||||
},
|
||||
// Events
|
||||
nodeexecutedevent: {
|
||||
title: 'NodeExecutedEvent',
|
||||
description: 'Payload fired when a node finishes execution.',
|
||||
group: 'events',
|
||||
order: 20
|
||||
},
|
||||
nodeconnectedevent: {
|
||||
title: 'NodeConnectedEvent',
|
||||
description: 'Payload fired when a slot connection is made.',
|
||||
group: 'events',
|
||||
order: 21
|
||||
},
|
||||
nodedisconnectedevent: {
|
||||
title: 'NodeDisconnectedEvent',
|
||||
description: 'Payload fired when a slot connection is removed.',
|
||||
group: 'events',
|
||||
order: 22
|
||||
},
|
||||
nodepositionchangedevent: {
|
||||
title: 'NodePositionChangedEvent',
|
||||
description: 'Payload fired when a node is moved on the canvas.',
|
||||
group: 'events',
|
||||
order: 23
|
||||
},
|
||||
nodesizechangedevent: {
|
||||
title: 'NodeSizeChangedEvent',
|
||||
description: 'Payload fired when a node is resized.',
|
||||
group: 'events',
|
||||
order: 24
|
||||
},
|
||||
nodemodechangedevent: {
|
||||
title: 'NodeModeChangedEvent',
|
||||
description: 'Payload fired when a node execution mode changes.',
|
||||
group: 'events',
|
||||
order: 25
|
||||
},
|
||||
nodebeforeserializeevent: {
|
||||
title: 'NodeBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip node data.',
|
||||
group: 'events',
|
||||
order: 26
|
||||
},
|
||||
widgetvaluechangeevent: {
|
||||
title: 'WidgetValueChangeEvent',
|
||||
description: 'Payload fired when a widget value changes.',
|
||||
group: 'events',
|
||||
order: 27
|
||||
},
|
||||
widgetbeforeserializeevent: {
|
||||
title: 'WidgetBeforeSerializeEvent',
|
||||
description: 'Pre-serialization hook payload — override or skip widget value.',
|
||||
group: 'events',
|
||||
order: 28
|
||||
},
|
||||
widgetbeforequeueevent: {
|
||||
title: 'WidgetBeforeQueueEvent',
|
||||
description: 'Pre-queue validation payload — call reject() to cancel queue.',
|
||||
group: 'events',
|
||||
order: 29
|
||||
},
|
||||
// Shell UI
|
||||
sidebartabextension: {
|
||||
title: 'SidebarTabExtension',
|
||||
description: 'Register a custom sidebar tab.',
|
||||
group: 'shell',
|
||||
order: 40
|
||||
},
|
||||
bottompanelextension: {
|
||||
title: 'BottomPanelExtension',
|
||||
description: 'Register a custom bottom panel tab.',
|
||||
group: 'shell',
|
||||
order: 41
|
||||
},
|
||||
toastmanager: {
|
||||
title: 'ToastManager',
|
||||
description: 'Show toast notifications to the user.',
|
||||
group: 'shell',
|
||||
order: 42
|
||||
},
|
||||
commandmanager: {
|
||||
title: 'CommandManager',
|
||||
description: 'Register keyboard shortcuts and command palette entries.',
|
||||
group: 'shell',
|
||||
order: 43
|
||||
},
|
||||
extensionmanager: {
|
||||
title: 'ExtensionManager',
|
||||
description: 'Access shell UI registration APIs.',
|
||||
group: 'shell',
|
||||
order: 44
|
||||
},
|
||||
// Identity
|
||||
nodelocatorid: {
|
||||
title: 'NodeLocatorId',
|
||||
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
|
||||
group: 'identity',
|
||||
order: 50
|
||||
},
|
||||
nodeexecutionid: {
|
||||
title: 'NodeExecutionId',
|
||||
description: 'Branded string ID for a specific node execution run.',
|
||||
group: 'identity',
|
||||
order: 51
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<PageMeta['group'], string> = {
|
||||
root: 'Extensions API',
|
||||
core: 'Registration',
|
||||
handles: 'Handles',
|
||||
events: 'Events',
|
||||
shell: 'Shell UI',
|
||||
identity: 'Identity'
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function slug(stem: string): string {
|
||||
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function metaFor(stem: string): PageMeta {
|
||||
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
|
||||
return (
|
||||
PAGE_META[key] ?? {
|
||||
title: stem,
|
||||
description: `API reference for ${stem}.`,
|
||||
group: 'core',
|
||||
order: 99
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
|
||||
function toMintlifyMdx(raw: string, stem: string): string {
|
||||
const meta = metaFor(stem)
|
||||
|
||||
// Build frontmatter
|
||||
const fm: string[] = [
|
||||
`---`,
|
||||
`title: "${meta.title}"`,
|
||||
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
|
||||
`description: "${meta.description}"`,
|
||||
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
|
||||
`---`
|
||||
]
|
||||
|
||||
let body = raw
|
||||
|
||||
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
|
||||
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
|
||||
|
||||
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
|
||||
body = body.replace(/^# .+\n+/, '')
|
||||
|
||||
// Ensure opening code fences that have no lang tag get `ts`
|
||||
// Only match a ``` that is immediately followed by a newline (opening fence),
|
||||
// not a closing fence (which also has just ``` + newline but we can detect
|
||||
// by context: opening fences follow non-fence lines; closing fences follow content).
|
||||
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
|
||||
// We track state via a flag pass instead of a single regex.
|
||||
let inBlock = false
|
||||
body = body
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (inBlock) {
|
||||
if (line.trim() === '```') { inBlock = false; return line }
|
||||
return line
|
||||
}
|
||||
if (line.startsWith('```')) {
|
||||
if (line.trim() === '```') {
|
||||
// bare opening fence → add ts
|
||||
inBlock = true
|
||||
return '```ts'
|
||||
}
|
||||
// has a lang tag already
|
||||
inBlock = true
|
||||
return line
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// TypeDoc emits `typescript` lang tag; normalize to `ts`
|
||||
body = body.replace(/^```typescript\b/gm, '```ts')
|
||||
|
||||
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
|
||||
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
|
||||
(_match, label, _category, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
// Same-dir links
|
||||
body = body.replace(
|
||||
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
|
||||
(_match, label, file) => `[${label}](./${slug(file)})`
|
||||
)
|
||||
|
||||
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
|
||||
// code examples to be directly under prose without a sub-heading.
|
||||
// Flatten "## Example\n\n```ts" → "```ts"
|
||||
body = body.replace(/^## Example\s*\n+/gm, '')
|
||||
|
||||
// Stability tags: render as a <Tip> callout
|
||||
body = body.replace(
|
||||
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
|
||||
(_match, level) => {
|
||||
const label =
|
||||
level === 'stable'
|
||||
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
|
||||
: level === 'experimental'
|
||||
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
|
||||
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
|
||||
return label
|
||||
}
|
||||
)
|
||||
|
||||
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
|
||||
body = body.replace(
|
||||
/^Stability: (stable|experimental|deprecated)\s*$/gm,
|
||||
(_match, level) => {
|
||||
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
|
||||
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
|
||||
return '<Warning>**Stability:** Deprecated</Warning>'
|
||||
}
|
||||
)
|
||||
|
||||
return [...fm, '', body.trim(), ''].join('\n')
|
||||
}
|
||||
|
||||
// ── Nav snippet builder ───────────────────────────────────────────────────────
|
||||
|
||||
interface NavPage {
|
||||
group?: string
|
||||
pages: (string | NavPage)[]
|
||||
}
|
||||
|
||||
function buildNavSnippet(stems: string[]): NavPage {
|
||||
// Sort stems by order then group by category
|
||||
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
|
||||
const sortedByGroup: Record<string, string[]> = {}
|
||||
for (const stem of sortedStems) {
|
||||
const group = metaFor(stem).group
|
||||
if (!sortedByGroup[group]) sortedByGroup[group] = []
|
||||
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
|
||||
}
|
||||
|
||||
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
|
||||
|
||||
const pages: (string | NavPage)[] = []
|
||||
|
||||
// Overview at top level
|
||||
if (sortedByGroup['root']) {
|
||||
for (const p of sortedByGroup['root']) pages.push(p)
|
||||
}
|
||||
|
||||
for (const grp of groupOrder) {
|
||||
if (grp === 'root') continue
|
||||
const grpPages = sortedByGroup[grp]
|
||||
if (!grpPages?.length) continue
|
||||
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
|
||||
}
|
||||
|
||||
return { group: 'Extensions API', pages }
|
||||
}
|
||||
|
||||
// ── Main pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTypedoc(): void {
|
||||
console.log('▶ Running TypeDoc...')
|
||||
execSync(
|
||||
`pnpm exec typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
|
||||
{ cwd: pkgRoot, stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
function processFiles(): void {
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(mintlifyDir, { recursive: true })
|
||||
|
||||
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
|
||||
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
|
||||
|
||||
const stems: string[] = []
|
||||
|
||||
for (const relPath of mdFiles) {
|
||||
const src = path.join(rawDir, relPath)
|
||||
const stem = path.basename(relPath, '.md')
|
||||
const raw = fs.readFileSync(src, 'utf8')
|
||||
const mdx = toMintlifyMdx(raw, stem)
|
||||
|
||||
const destName = slug(stem) + '.mdx'
|
||||
const dest = path.join(mintlifyDir, destName)
|
||||
fs.writeFileSync(dest, mdx)
|
||||
console.log(` ✔ ${relPath} → mintlify/${destName}`)
|
||||
stems.push(stem)
|
||||
}
|
||||
|
||||
// Write nav snippet
|
||||
const nav = buildNavSnippet(stems)
|
||||
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
|
||||
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
|
||||
console.log(` ✔ nav-snippet.json`)
|
||||
|
||||
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
|
||||
console.log(` ${stems.length} pages + nav-snippet.json`)
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
runTypedoc()
|
||||
processFiles()
|
||||
}
|
||||
|
||||
if (watchMode) {
|
||||
// Simple watch: re-run on change to source files
|
||||
console.log('👁 Watch mode — watching src/extension-api/**')
|
||||
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
run()
|
||||
|
||||
fs.watch(srcDir, { recursive: true }, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
console.log('\n🔄 Source changed — rebuilding...')
|
||||
try { run() } catch (e) { console.error(e) }
|
||||
}, 500)
|
||||
})
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
78
packages/extension-api/src/index.ts
Normal file
78
packages/extension-api/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @comfyorg/extension-api — Public Extension API for ComfyUI
|
||||
*
|
||||
* This is the package entry point compiled to `build/index.js` + `build/index.d.ts`.
|
||||
* It re-exports the stable public contract from `src/extension-api/` in the main app.
|
||||
*
|
||||
* All types and functions exported here are part of the semver-stable surface.
|
||||
* Do not add internal implementation details to this barrel.
|
||||
*/
|
||||
|
||||
// Re-export everything from the canonical source in the main app tree.
|
||||
// The tsconfig.json paths alias @/* → ../../src/* so these resolve correctly.
|
||||
export type {
|
||||
ExtensionOptions,
|
||||
NodeExtensionOptions,
|
||||
WidgetExtensionOptions
|
||||
} from '@/extension-api/lifecycle'
|
||||
|
||||
export {
|
||||
defineExtension,
|
||||
defineNodeExtension,
|
||||
defineWidgetExtension,
|
||||
onNodeMounted,
|
||||
onNodeRemoved
|
||||
} from '@/extension-api/lifecycle'
|
||||
|
||||
export type {
|
||||
NodeHandle,
|
||||
NodeEntityId,
|
||||
SlotEntityId,
|
||||
SlotInfo,
|
||||
SlotDirection,
|
||||
NodeMode,
|
||||
Point,
|
||||
Size,
|
||||
NodeExecutedEvent,
|
||||
NodeConnectedEvent,
|
||||
NodeDisconnectedEvent,
|
||||
NodePositionChangedEvent,
|
||||
NodeSizeChangedEvent,
|
||||
NodeModeChangedEvent,
|
||||
NodeBeforeSerializeEvent
|
||||
} from '@/extension-api/node'
|
||||
|
||||
export type {
|
||||
WidgetHandle,
|
||||
WidgetEntityId,
|
||||
WidgetValue,
|
||||
WidgetOptions,
|
||||
WidgetValueChangeEvent,
|
||||
WidgetOptionChangeEvent,
|
||||
WidgetPropertyChangeEvent,
|
||||
WidgetBeforeSerializeEvent,
|
||||
WidgetBeforeQueueEvent
|
||||
} from '@/extension-api/widget'
|
||||
|
||||
export type { Handler, AsyncHandler, Unsubscribe } from '@/extension-api/events'
|
||||
|
||||
export type {
|
||||
SidebarTabExtension,
|
||||
BottomPanelExtension,
|
||||
VueExtension,
|
||||
CustomExtension,
|
||||
ToastMessageOptions,
|
||||
ToastManager,
|
||||
ExtensionManager,
|
||||
CommandManager
|
||||
} from '@/extension-api/shell'
|
||||
|
||||
export type { NodeLocatorId, NodeExecutionId } from '@/extension-api/identifiers'
|
||||
export {
|
||||
isNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
parseNodeLocatorId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
createNodeExecutionId
|
||||
} from '@/extension-api/identifiers'
|
||||
4
packages/extension-api/tsconfig.build.json
Normal file
4
packages/extension-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**", "scripts/**"]
|
||||
}
|
||||
21
packages/extension-api/tsconfig.docs.json
Normal file
21
packages/extension-api/tsconfig.docs.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../src/extension-api/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../../src/**/*.test.ts",
|
||||
"../../src/**/*.spec.ts",
|
||||
"../../src/**/*.vue"
|
||||
]
|
||||
}
|
||||
19
packages/extension-api/tsconfig.json
Normal file
19
packages/extension-api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"paths": {
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts", "scripts/**"]
|
||||
}
|
||||
37
packages/extension-api/typedoc.json
Normal file
37
packages/extension-api/typedoc.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"entryPoints": ["../../src/extension-api/index.ts"],
|
||||
"tsconfig": "./tsconfig.docs.json",
|
||||
"out": "./docs-build/raw",
|
||||
"plugin": ["typedoc-plugin-markdown"],
|
||||
"excludeInternal": true,
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"readme": "none",
|
||||
"skipErrorChecking": true,
|
||||
"githubPages": false,
|
||||
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
|
||||
"hideGenerator": true,
|
||||
"useCodeBlocks": true,
|
||||
"flattenOutputFiles": false,
|
||||
"entryFileName": "index",
|
||||
"fileExtension": ".md",
|
||||
"outputFileStrategy": "members",
|
||||
"hidePageHeader": false,
|
||||
"hideBreadcrumbs": false,
|
||||
"useHTMLAnchors": false,
|
||||
"sanitizeComments": true,
|
||||
"expandObjects": false,
|
||||
"parametersFormat": "table",
|
||||
"propertiesFormat": "table",
|
||||
"typeDeclarationFormat": "table",
|
||||
"indexFormat": "table",
|
||||
"tableColumnSettings": {
|
||||
"hideDefaults": false,
|
||||
"hideInherited": false,
|
||||
"hideModifiers": false,
|
||||
"hideOverrides": false,
|
||||
"hideSources": true,
|
||||
"hideValues": false,
|
||||
"leftAlignHeaders": false
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
328
pnpm-lock.yaml
generated
328
pnpm-lock.yaml
generated
@@ -650,7 +650,7 @@ importers:
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
'@nx/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
|
||||
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)
|
||||
'@pinia/testing':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))
|
||||
@@ -662,7 +662,7 @@ importers:
|
||||
version: 4.6.0
|
||||
'@storybook/addon-docs':
|
||||
specifier: 'catalog:'
|
||||
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
@@ -671,10 +671,10 @@ importers:
|
||||
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@storybook/vue3-vite':
|
||||
specifier: 'catalog:'
|
||||
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@testing-library/jest-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 6.9.1
|
||||
@@ -704,7 +704,7 @@ importers:
|
||||
version: 0.170.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(vitest@4.0.16)
|
||||
@@ -842,19 +842,19 @@ importers:
|
||||
version: 11.1.0
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-dts:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-html:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.6
|
||||
@@ -912,10 +912,10 @@ importers:
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
dotenv:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.1
|
||||
@@ -927,13 +927,13 @@ importers:
|
||||
version: 30.0.0(@babel/parser@7.29.0)(vue@3.5.13(typescript@5.9.3))
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-html:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -982,16 +982,16 @@ importers:
|
||||
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
|
||||
'@astrojs/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
|
||||
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.4)
|
||||
'@playwright/test':
|
||||
specifier: 'catalog:'
|
||||
version: 1.58.1
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
astro:
|
||||
specifier: 'catalog:'
|
||||
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
|
||||
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4)
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.0
|
||||
@@ -1003,7 +1003,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
@@ -1030,6 +1030,21 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/extension-api:
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.4
|
||||
typedoc:
|
||||
specifier: 0.28.19
|
||||
version: 0.28.19(typescript@5.9.3)
|
||||
typedoc-plugin-markdown:
|
||||
specifier: ^4.6.3
|
||||
version: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/ingest-types:
|
||||
dependencies:
|
||||
zod:
|
||||
@@ -2431,6 +2446,9 @@ packages:
|
||||
'@formkit/auto-animate@0.9.0':
|
||||
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
@@ -5453,6 +5471,10 @@ packages:
|
||||
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7624,6 +7646,9 @@ packages:
|
||||
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
||||
engines: {node: '>=16.14'}
|
||||
|
||||
lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -7864,6 +7889,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -9343,6 +9372,19 @@ packages:
|
||||
typed-binary@4.3.2:
|
||||
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
|
||||
|
||||
typedoc-plugin-markdown@4.11.0:
|
||||
resolution: {integrity: sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
typedoc: 0.28.x
|
||||
|
||||
typedoc@0.28.19:
|
||||
resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==}
|
||||
engines: {node: '>= 18', pnpm: '>= 10'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x
|
||||
|
||||
typegpu@0.8.2:
|
||||
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -10186,6 +10228,11 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -10467,14 +10514,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
|
||||
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.4)':
|
||||
dependencies:
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/compiler-sfc': 3.5.28
|
||||
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
@@ -11863,6 +11910,14 @@ snapshots:
|
||||
|
||||
'@formkit/auto-animate@0.9.0': {}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
@@ -12495,11 +12550,11 @@ snapshots:
|
||||
- typescript
|
||||
- verdaccio
|
||||
|
||||
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
|
||||
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)':
|
||||
dependencies:
|
||||
'@nx/devkit': 22.6.1(nx@22.6.1)
|
||||
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
|
||||
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
|
||||
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)
|
||||
'@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3)
|
||||
ajv: 8.18.0
|
||||
enquirer: 2.3.6
|
||||
@@ -12507,8 +12562,8 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
tsconfig-paths: 4.2.0
|
||||
tslib: 2.8.1
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -12519,7 +12574,7 @@ snapshots:
|
||||
- typescript
|
||||
- verdaccio
|
||||
|
||||
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
|
||||
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vitest@4.0.16)':
|
||||
dependencies:
|
||||
'@nx/devkit': 22.6.1(nx@22.6.1)
|
||||
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
|
||||
@@ -12527,8 +12582,8 @@ snapshots:
|
||||
semver: 7.7.4
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/traverse'
|
||||
- '@swc-node/register'
|
||||
@@ -13317,10 +13372,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4)
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
react: 19.2.4
|
||||
@@ -13346,25 +13401,25 @@ snapshots:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
ts-dedent: 2.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- esbuild
|
||||
- rollup
|
||||
- webpack
|
||||
|
||||
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
esbuild: 0.27.3
|
||||
rollup: 4.53.5
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
|
||||
@@ -13389,14 +13444,14 @@ snapshots:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
|
||||
magic-string: 0.30.21
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
typescript: 5.9.3
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue-component-meta: 2.2.12(typescript@5.9.3)
|
||||
vue-docgen-api: 4.79.2(vue@3.5.13(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
@@ -13478,19 +13533,19 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.0
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.0
|
||||
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.0
|
||||
'@tailwindcss/oxide': 4.2.0
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.0
|
||||
'@tailwindcss/oxide': 4.2.0
|
||||
tailwindcss: 4.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
@@ -14083,32 +14138,32 @@ snapshots:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
|
||||
|
||||
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@rolldown/pluginutils': 1.0.0-rc.9
|
||||
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
'@vitest/coverage-v8@4.0.16(vitest@4.0.16)':
|
||||
@@ -14124,7 +14179,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14145,21 +14200,21 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -14195,7 +14250,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -14370,38 +14425,38 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
|
||||
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
'@vue/devtools-shared': 7.7.9
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
@@ -14809,7 +14864,7 @@ snapshots:
|
||||
|
||||
astral-regex@2.0.0: {}
|
||||
|
||||
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
|
||||
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
@@ -14866,8 +14921,8 @@ snapshots:
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.4
|
||||
vfile: 6.0.3
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
@@ -15060,6 +15115,10 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
@@ -17463,6 +17522,8 @@ snapshots:
|
||||
|
||||
lru-cache@8.0.5: {}
|
||||
|
||||
lunr@2.3.9: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
lz-utils@2.1.0: {}
|
||||
@@ -17898,6 +17959,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -19827,6 +19892,19 @@ snapshots:
|
||||
|
||||
typed-binary@4.3.2: {}
|
||||
|
||||
typedoc-plugin-markdown@4.11.0(typedoc@0.28.19(typescript@5.9.3)):
|
||||
dependencies:
|
||||
typedoc: 0.28.19(typescript@5.9.3)
|
||||
|
||||
typedoc@0.28.19(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@gerrit0/mini-shiki': 3.23.0
|
||||
lunr: 2.3.9
|
||||
markdown-it: 14.1.1
|
||||
minimatch: 10.2.5
|
||||
typescript: 5.9.3
|
||||
yaml: 2.8.4
|
||||
|
||||
typegpu@0.8.2:
|
||||
dependencies:
|
||||
tinyest: 0.1.2
|
||||
@@ -20111,27 +20189,27 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
birpc: 2.9.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
birpc: 2.9.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@microsoft/api-extractor': 7.57.2(@types/node@24.10.4)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
|
||||
@@ -20144,13 +20222,13 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
typescript: 5.9.3
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
colorette: 2.0.20
|
||||
@@ -20164,9 +20242,9 @@ snapshots:
|
||||
html-minifier-terser: 6.1.0
|
||||
node-html-parser: 5.4.2
|
||||
pathe: 0.2.0
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
colorette: 2.0.20
|
||||
@@ -20180,9 +20258,9 @@ snapshots:
|
||||
html-minifier-terser: 6.1.0
|
||||
node-html-parser: 5.4.2
|
||||
pathe: 0.2.0
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@antfu/utils': 0.7.10
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
|
||||
@@ -20193,12 +20271,12 @@ snapshots:
|
||||
perfect-debounce: 1.0.0
|
||||
picocolors: 1.1.1
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
debug: 4.4.3
|
||||
@@ -20208,12 +20286,12 @@ snapshots:
|
||||
perfect-debounce: 2.0.0
|
||||
sirv: 3.0.2
|
||||
unplugin-utils: 0.3.1
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
ansis: 4.2.0
|
||||
debug: 4.4.3
|
||||
@@ -20223,56 +20301,56 @@ snapshots:
|
||||
perfect-debounce: 2.0.0
|
||||
sirv: 3.0.2
|
||||
unplugin-utils: 0.3.1
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
'@vue/devtools-shared': 7.7.9
|
||||
execa: 9.6.1
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- rollup
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))(vue@3.5.13(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -20283,11 +20361,11 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.28
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -20298,11 +20376,11 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.28
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@@ -20317,9 +20395,9 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
terser: 5.39.2
|
||||
tsx: 4.19.4
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.4
|
||||
|
||||
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@@ -20334,16 +20412,16 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
terser: 5.39.2
|
||||
tsx: 4.19.4
|
||||
yaml: 2.8.2
|
||||
yaml: 2.8.4
|
||||
|
||||
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
|
||||
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20360,7 +20438,7 @@ snapshots:
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -20382,10 +20460,10 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
|
||||
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -20402,7 +20480,7 @@ snapshots:
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -20834,6 +20912,8 @@ snapshots:
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
|
||||
28
scripts/generate-docs.sh
Executable file
28
scripts/generate-docs.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# PKG5.D6 — Generate TypeDoc → Mintlify MDX for @comfyorg/extension-api
|
||||
#
|
||||
# Output: packages/extension-api/docs-build/mintlify/*.mdx
|
||||
# packages/extension-api/docs-build/mintlify/nav-snippet.json
|
||||
#
|
||||
# Prerequisites: pnpm install must have been run (typedoc, tsx)
|
||||
# Usage: ./scripts/generate-docs.sh [--watch]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PKG_DIR="$REPO_ROOT/packages/extension-api"
|
||||
|
||||
if [ ! -f "$PKG_DIR/package.json" ]; then
|
||||
echo "ERROR: $PKG_DIR/package.json not found — run from repo root or ensure packages/extension-api exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--watch" ]; then
|
||||
echo "Starting docs watch mode..."
|
||||
pnpm --filter @comfyorg/extension-api docs:watch
|
||||
else
|
||||
echo "Generating extension API docs..."
|
||||
pnpm --filter @comfyorg/extension-api docs:build
|
||||
echo ""
|
||||
echo "Done. MDX files written to: $PKG_DIR/docs-build/mintlify/"
|
||||
echo "Copy to Comfy-Org/docs: cp -r $PKG_DIR/docs-build/mintlify/* <docs-repo>/extensions/api/"
|
||||
fi
|
||||
@@ -29,7 +29,6 @@ import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -158,12 +157,10 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const isPromoted = isPromotedWidgetView(widget)
|
||||
const storeId =
|
||||
isPromoted && app.rootGraph?.id
|
||||
? createNodeLocatorId(app.rootGraph.id, node.id)
|
||||
: node.id
|
||||
const storeName = widget.name
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, onBeforeUnmount, provide, shallowRef, triggerRef } from 'vue'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -63,17 +63,6 @@ useEventListener(
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
// `LGraph.trigger()` invokes `onTrigger` synchronously but does not dispatch
|
||||
// on `events`, so chain through `onTrigger` to react to slot-label renames.
|
||||
const previousOnTrigger = app.rootGraph.onTrigger
|
||||
app.rootGraph.onTrigger = (event) => {
|
||||
previousOnTrigger?.(event)
|
||||
if (event.type === 'node:slot-label:changed') triggerRef(graphNodes)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
if (app.rootGraph.onTrigger === undefined) return
|
||||
app.rootGraph.onTrigger = previousOnTrigger
|
||||
})
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
|
||||
@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -70,6 +70,8 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
@@ -81,12 +83,13 @@ function isWidgetShownOnParents(
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
|
||||
@@ -14,17 +14,13 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputAtIndex
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
@@ -37,6 +33,7 @@ const { node } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -58,31 +55,9 @@ const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
const promotionEntries = computed(() =>
|
||||
promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
)
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
@@ -106,7 +81,37 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
const entries = promotionEntries.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
const result: NodeWidgetsList = []
|
||||
for (const {
|
||||
sourceNodeId: entryNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
} of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
if (
|
||||
String(w.sourceNodeId) !== entryNodeId ||
|
||||
w.sourceWidgetName !== sourceWidgetName
|
||||
)
|
||||
return false
|
||||
|
||||
if (!disambiguatingSourceNodeId) return true
|
||||
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
return w.name === sourceWidgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
@@ -121,9 +126,12 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!isWidgetPromotedOnSubgraphNode(node, {
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -182,7 +190,12 @@ function setDraggableState() {
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
reorderSubgraphInputAtIndex(node, oldPosition, newPosition)
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
@@ -199,4 +201,64 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(onResetToDefault).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
})
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
value: 'value',
|
||||
label: 'Text',
|
||||
options: {},
|
||||
y: 0,
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
} as IBaseWidget
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Text',
|
||||
parents: [parentSubgraphNode],
|
||||
isShownOnParents: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Hide input/ }))
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -79,19 +82,16 @@ function handleHideInput() {
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
import {
|
||||
getSourceNodeId,
|
||||
promoteValueWidgetViaSubgraphInput
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subgraphStore: {
|
||||
shown: 'Shown',
|
||||
hidden: 'Hidden',
|
||||
hideAll: 'Hide all',
|
||||
showAll: 'Show all',
|
||||
addRecommended: 'Add recommended'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noneSearchDesc: 'No results'
|
||||
},
|
||||
g: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Search'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubgraphEditor', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders preview exposures after promoted inputs without drag handles', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.add(previewNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
usePreviewExposureStore().addExposure(
|
||||
subgraph.rootGraph.id,
|
||||
String(host.id),
|
||||
{
|
||||
sourceNodeId: String(previewNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second', '$$canvas-image-preview'])
|
||||
expect(
|
||||
within(screen.getByTestId('draggable-list'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
expect(
|
||||
within(shown).getAllByTestId('subgraph-widget-drag-handle')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reorders node widgets from dragged promoted input order when widget names repeat', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('seed', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'seed', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('seed', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'seed', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
it('rerenders promoted widgets in dragged input order', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('subgraph-editor-shown-section'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('subgraph-editor-shown-section'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('serializes promoted widget values in dragged input order', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('sends dragged text input values to the matching prompt targets', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
const graph = host.graph!
|
||||
graph.add(host)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
firstNode.comfyClass = 'FirstNode'
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
secondNode.comfyClass = 'SecondNode'
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(output[`${host.id}:${firstNode.id}`].inputs.text).toBe('first value')
|
||||
expect(output[`${host.id}:${secondNode.id}`].inputs.text).toBe(
|
||||
'second value'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -13,8 +14,7 @@ import {
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
pruneDisconnected
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -22,17 +22,23 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const inputOrderVersion = ref(0)
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
})
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
@@ -45,70 +51,55 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
|
||||
return [...getActivePromotedWidgets(node), ...getActivePreviewWidgets(node)]
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
updateActiveWidgets(value, activeWidgets.value)
|
||||
}
|
||||
})
|
||||
|
||||
const activePromotedWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedWidgets(node) : []
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
updateActiveWidgets(value, activePromotedWidgets.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedWidgets(node: SubgraphNode): WidgetItem[] {
|
||||
void inputOrderVersion.value
|
||||
return node.widgets.flatMap((widget): WidgetItem[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [[sourceNode, widget]]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewWidgets(node: SubgraphNode): WidgetItem[] {
|
||||
const hostLocator = String(node.id)
|
||||
return previewExposureStore
|
||||
.getExposures(node.rootGraph.id, hostLocator)
|
||||
.flatMap((exposure): WidgetItem[] => {
|
||||
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
const widget = getPromotableWidgets(sourceNode).find(
|
||||
(candidate) => candidate.name === exposure.sourcePreviewName
|
||||
)
|
||||
return widget ? [[sourceNode, widget]] : []
|
||||
})
|
||||
}
|
||||
|
||||
function updateActiveWidgets(value: WidgetItem[], currentItems: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
const currentKeys = new Set(currentItems.map(toKey))
|
||||
const nextKeys = new Set(value.map(toKey))
|
||||
for (const item of value) {
|
||||
if (!currentKeys.has(toKey(item))) promote(item)
|
||||
}
|
||||
for (const item of currentItems) {
|
||||
if (!nextKeys.has(toKey(item))) demote(item)
|
||||
}
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map(([, widget]) => widget)
|
||||
return promotionEntries.value.flatMap(
|
||||
({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}): WidgetItem[] => {
|
||||
if (sourceNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
|
||||
if (!widget) return []
|
||||
return [
|
||||
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
|
||||
]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
|
||||
if (!wNode) return []
|
||||
const widget = getPromotableWidgets(wNode).find((w) => {
|
||||
if (w.name !== sourceWidgetName) return false
|
||||
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
return true
|
||||
})
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
)
|
||||
inputOrderVersion.value += 1
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => ({
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
})
|
||||
|
||||
const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
@@ -128,8 +119,14 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return interiorWidgets.value.filter(
|
||||
(item: WidgetItem) =>
|
||||
!activeWidgets.value.some((active) => toKey(active) === toKey(item))
|
||||
([n, w]: WidgetItem) =>
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -158,14 +155,6 @@ const filteredActive = computed<WidgetItem[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const filteredActivePromoted = computed<WidgetItem[]>(() =>
|
||||
filteredActive.value.filter(([, widget]) => isPromotedWidgetView(widget))
|
||||
)
|
||||
|
||||
const filteredActivePreviews = computed<WidgetItem[]>(() =>
|
||||
filteredActive.value.filter(([, widget]) => !isPromotedWidgetView(widget))
|
||||
)
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
@@ -270,9 +259,9 @@ onMounted(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activePromotedWidgets">
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActivePromoted"
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
@@ -282,18 +271,6 @@ onMounted(() => {
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</DraggableList>
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActivePreviews"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:is-draggable="false"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -61,7 +61,6 @@ const icon = computed(() =>
|
||||
</Button>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
data-testid="subgraph-widget-drag-handle"
|
||||
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
verifyMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
// Cloud media scans return pending for asset verification. OSS scans only
|
||||
// return pending for generated output/temp media.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
@@ -206,6 +207,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
@@ -401,6 +403,37 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = vueNode?.widgets?.find(
|
||||
(widget) => widget.name === '$$canvas-image-preview'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.options?.canvasOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested promoted widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -443,6 +476,118 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
||||
const graph = outerSubgraphNode.graph as LGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'text'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -228,15 +227,18 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
function resolvePromotedSourceByInputName(inputName: string): {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
} | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,9 +256,10 @@ function safeWidgetMapper(
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
@@ -303,7 +306,8 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -316,7 +320,11 @@ function safeWidgetMapper(
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
@@ -374,11 +382,6 @@ function buildSlotMetadata(
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: string | undefined
|
||||
let originOutputName: string | undefined
|
||||
// Promotion via SubgraphInput materialises a real link from the
|
||||
// SUBGRAPH_INPUT sentinel into the interior widget's input slot.
|
||||
// That link is internal plumbing — not an external connection — so
|
||||
// exclude it from `linked` (which downstream renders as disabled).
|
||||
let isPromotionLink = false
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
@@ -386,13 +389,12 @@ function buildSlotMetadata(
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
isPromotionLink = link.origin_id === SUBGRAPH_INPUT_ID
|
||||
}
|
||||
}
|
||||
|
||||
const slotInfo: WidgetSlotMetadata = {
|
||||
index,
|
||||
linked: input.link != null && !isPromotionLink,
|
||||
linked: input.link != null,
|
||||
originNodeId,
|
||||
originOutputName,
|
||||
type: String(input.type)
|
||||
|
||||
@@ -9,27 +9,19 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
| 'nodeOutputs'
|
||||
| 'nodeOutputsByExecutionId'
|
||||
| 'nodePreviewImages'
|
||||
| 'nodePreviewImagesByExecutionId'
|
||||
| 'getNodeImageUrls'
|
||||
| 'getNodeImageUrlsByExecutionId'
|
||||
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
||||
)
|
||||
const getNodeImageUrlsByExecutionId = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrlsByExecutionId']>()
|
||||
)
|
||||
const useNodeOutputStoreMock = vi.hoisted(() =>
|
||||
vi.fn<() => MockNodeOutputStore>()
|
||||
)
|
||||
@@ -43,15 +35,8 @@ vi.mock('@/stores/nodeOutputStore', () => {
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
nodeOutputsByExecutionId: reactive<
|
||||
MockNodeOutputStore['nodeOutputsByExecutionId']
|
||||
>({}),
|
||||
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
||||
nodePreviewImagesByExecutionId: reactive<
|
||||
MockNodeOutputStore['nodePreviewImagesByExecutionId']
|
||||
>({}),
|
||||
getNodeImageUrls,
|
||||
getNodeImageUrlsByExecutionId
|
||||
getNodeImageUrls
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,18 +83,6 @@ function seedPreviewImages(
|
||||
}
|
||||
}
|
||||
|
||||
function exposePreview(
|
||||
setup: ReturnType<typeof createSetup>,
|
||||
sourceNodeId: string,
|
||||
sourcePreviewName = '$$canvas-image-preview'
|
||||
) {
|
||||
usePreviewExposureStore().addExposure(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
createNodeLocatorId(setup.subgraphNode.rootGraph.id, setup.subgraphNode.id),
|
||||
{ sourceNodeId, sourcePreviewName }
|
||||
)
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
|
||||
@@ -117,7 +90,6 @@ describe(usePromotedPreviews, () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
getNodeImageUrls.mockReset()
|
||||
getNodeImageUrlsByExecutionId.mockReset()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
@@ -137,6 +109,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
@@ -145,7 +122,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns image preview for promoted $$ widget with outputs', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -165,7 +146,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns video type when interior node has video previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
|
||||
@@ -177,7 +162,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns audio type when interior node has audio previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
|
||||
@@ -196,8 +185,16 @@ describe(usePromotedPreviews, () => {
|
||||
id: 20,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
exposePreview(setup, '10')
|
||||
exposePreview(setup, '20')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
|
||||
@@ -215,7 +212,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
@@ -235,7 +236,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('recomputes when preview images are populated after first evaluation', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
@@ -257,7 +262,11 @@ describe(usePromotedPreviews, () => {
|
||||
it('skips interior nodes with no image output', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
@@ -265,16 +274,29 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
it('skips missing interior nodes', () => {
|
||||
const setup = createSetup()
|
||||
exposePreview(setup, '99')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('uses preview exposures by source preview name', () => {
|
||||
it('ignores non-$$ promoted widgets', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
exposePreview(setup, '10')
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -284,116 +306,4 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
||||
})
|
||||
|
||||
it('renders leaf media exposed through a nested subgraph host', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(innerHost.id),
|
||||
{
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
createNodeLocatorId(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
outerSetup.subgraphNode.id
|
||||
),
|
||||
{
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=leaf.png']
|
||||
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
|
||||
node === leafNode ? mockUrls : []
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(
|
||||
() => outerSetup.subgraphNode
|
||||
)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
|
||||
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(firstHost.rootGraph.id, '11', {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, '12', {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, '11:20', {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, '12:20', {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId['11:20:10'] = ['blob:first']
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId['12:20:10'] = ['blob:second']
|
||||
getNodeImageUrlsByExecutionId.mockImplementation((executionId) => {
|
||||
if (executionId === '11:20:10') return ['blob:first']
|
||||
if (executionId === '12:20:10') return ['blob:second']
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
|
||||
[
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: ['blob:first']
|
||||
}
|
||||
]
|
||||
)
|
||||
expect(
|
||||
usePromotedPreviews(() => secondHost).promotedPreviews.value
|
||||
).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: ['blob:second']
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,128 +4,41 @@ import { computed, toValue } from 'vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface PromotedPreview {
|
||||
/** Source node id resolved on the host's interior subgraph. */
|
||||
sourceNodeId: string
|
||||
/** Canonical preview name on the source widget (typically `$$`-prefixed). */
|
||||
sourceWidgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns reactive preview media exposed by a host SubgraphNode.
|
||||
*
|
||||
* Reads from the host-scoped {@link usePreviewExposureStore}, the canonical
|
||||
* post-ADR-0009 source for display-only preview promotion.
|
||||
* Returns reactive preview media from promoted `$$` pseudo-widgets
|
||||
* on a SubgraphNode. Each promoted preview interior node produces
|
||||
* a separate entry so they render independently.
|
||||
*/
|
||||
export function usePromotedPreviews(
|
||||
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
const legacyHostLocator = createNodeLocatorId(rootGraphId, node.id)
|
||||
|
||||
const instanceExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
hostLocator
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
e.sourceWidgetName.startsWith('$$')
|
||||
)
|
||||
let exposures = instanceExposures
|
||||
if (!exposures.length) {
|
||||
const legacyExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
legacyHostLocator
|
||||
)
|
||||
if (legacyExposures.length) {
|
||||
previewExposureStore.setExposures(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
legacyExposures
|
||||
)
|
||||
exposures = legacyExposures
|
||||
}
|
||||
}
|
||||
|
||||
const exposurePairs = exposures.map((exposure) => ({
|
||||
exposureName: exposure.name,
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourceWidgetName: exposure.sourcePreviewName
|
||||
}))
|
||||
|
||||
if (!exposurePairs.length) return []
|
||||
if (!pseudoEntries.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
const hostNodesByLocator = new Map<string, SubgraphNode>([
|
||||
[hostLocator, node]
|
||||
])
|
||||
|
||||
const resolveNestedHost = (
|
||||
rootGraphId: UUID,
|
||||
currentHostLocator: string,
|
||||
sourceNodeId: string
|
||||
) => {
|
||||
const currentHost = hostNodesByLocator.get(currentHostLocator)
|
||||
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
|
||||
if (!(sourceNode instanceof SubgraphNode)) return undefined
|
||||
|
||||
const nestedHostLocator = `${currentHostLocator}:${sourceNode.id}`
|
||||
const legacyNestedHostLocator = createNodeLocatorId(
|
||||
rootGraphId,
|
||||
sourceNode.id
|
||||
)
|
||||
const nestedExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
nestedHostLocator
|
||||
)
|
||||
if (!nestedExposures.length) {
|
||||
const definitionExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
String(sourceNode.id)
|
||||
)
|
||||
const legacyExposures = definitionExposures.length
|
||||
? definitionExposures
|
||||
: previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
legacyNestedHostLocator
|
||||
)
|
||||
if (legacyExposures.length) {
|
||||
previewExposureStore.setExposures(
|
||||
rootGraphId,
|
||||
nestedHostLocator,
|
||||
legacyExposures
|
||||
)
|
||||
}
|
||||
}
|
||||
hostNodesByLocator.set(nestedHostLocator, sourceNode)
|
||||
return { rootGraphId, hostNodeLocator: nestedHostLocator }
|
||||
}
|
||||
|
||||
for (const pair of exposurePairs) {
|
||||
const resolved = previewExposureStore.resolveChain(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
pair.exposureName,
|
||||
resolveNestedHost
|
||||
)
|
||||
const leaf = resolved?.leaf ?? {
|
||||
sourceNodeId: pair.sourceNodeId,
|
||||
sourcePreviewName: pair.sourceWidgetName
|
||||
}
|
||||
const leafHostLocator =
|
||||
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
|
||||
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
|
||||
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
@@ -133,29 +46,15 @@ export function usePromotedPreviews(
|
||||
// app.nodeOutputs / app.nodePreviewImages, so without this
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
leafHost.subgraph.id,
|
||||
leaf.sourceNodeId
|
||||
node.subgraph.id,
|
||||
entry.sourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
const leafExecutionId = `${leafHostLocator}:${leaf.sourceNodeId}`
|
||||
const reactiveExecutionOutputs =
|
||||
nodeOutputStore.nodeOutputsByExecutionId?.[leafExecutionId]
|
||||
const reactiveExecutionPreviews =
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId?.[leafExecutionId]
|
||||
if (
|
||||
!reactiveOutputs?.images?.length &&
|
||||
!reactivePreviews?.length &&
|
||||
!reactiveExecutionOutputs?.images?.length &&
|
||||
!reactiveExecutionPreviews?.length
|
||||
)
|
||||
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
|
||||
continue
|
||||
|
||||
const urls =
|
||||
nodeOutputStore.getNodeImageUrlsByExecutionId?.(
|
||||
leafExecutionId,
|
||||
interiorNode
|
||||
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
const type =
|
||||
@@ -166,8 +65,8 @@ export function usePromotedPreviews(
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
sourceNodeId: leaf.sourceNodeId,
|
||||
sourceWidgetName: leaf.sourcePreviewName,
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
|
||||
@@ -105,11 +105,7 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
// ADR 0009: each SubgraphNode is opaque, so legacy nested
|
||||
// disambiguator-based lookup no longer reaches deep widgets. The
|
||||
// prefix is preserved as `disambiguatingSourceNodeId` lookup metadata
|
||||
// for migration tooling.
|
||||
it('returns original entry when prefix cannot be resolved', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
@@ -120,8 +116,7 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'nonexistent_widget',
|
||||
disambiguatingSourceNodeId: '999'
|
||||
sourceWidgetName: '999: nonexistent_widget'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,54 +1,89 @@
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
function canResolve(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
|
||||
'resolved'
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).status === 'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
interface StrippedPrefix {
|
||||
sourceWidgetName: string
|
||||
/** Deepest legacy `n: ` prefix removed from the original widget name. */
|
||||
deepestPrefixId?: string
|
||||
}
|
||||
function tryResolveCandidate(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
|
||||
return undefined
|
||||
|
||||
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
|
||||
let remaining = sourceWidgetName
|
||||
let deepestPrefixId: string | undefined
|
||||
while (true) {
|
||||
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
|
||||
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
|
||||
deepestPrefixId = match[1]
|
||||
remaining = match[2]
|
||||
return {
|
||||
sourceWidgetName: widgetName,
|
||||
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyPrefixedEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
let remaining = sourceWidgetName
|
||||
|
||||
while (true) {
|
||||
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
|
||||
if (!match) return undefined
|
||||
|
||||
const [, legacySourceNodeId, unprefixed] = match
|
||||
remaining = unprefixed
|
||||
|
||||
const disambiguators = [
|
||||
legacySourceNodeId,
|
||||
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
|
||||
undefined
|
||||
]
|
||||
|
||||
for (const disambiguator of disambiguators) {
|
||||
const resolved = tryResolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
remaining,
|
||||
disambiguator
|
||||
)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a legacy `proxyWidgets` entry.
|
||||
*
|
||||
* Under ADR 0009 each `SubgraphNode` is opaque, so the canonical state never
|
||||
* resolves through deep nested identities. This helper still recognizes the
|
||||
* legacy `"<id>: <name>"` prefix encoding and surfaces the deepest prefix as
|
||||
* `disambiguatingSourceNodeId` so migration tooling can preserve it as
|
||||
* lookup metadata. The bare entry is returned unchanged when it already
|
||||
* resolves at the immediate level.
|
||||
*/
|
||||
export function normalizeLegacyProxyWidgetEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): LegacyProxyEntrySource {
|
||||
if (canResolve(hostNode, sourceNodeId, sourceWidgetName)) {
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
@@ -56,13 +91,19 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
}
|
||||
}
|
||||
|
||||
const stripped = stripLegacyPrefixes(sourceWidgetName)
|
||||
const patch = resolveLegacyPrefixedEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: stripped.sourceWidgetName,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
})
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
|
||||
import type {
|
||||
LegacyProxyEntrySource,
|
||||
PromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function makeSource(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): LegacyProxyEntrySource {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
describe(classifyProxyEntry, () => {
|
||||
describe('alreadyLinked branch', () => {
|
||||
it('returns alreadyLinked when an input already represents the entry', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('value')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: 'seed_link'
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines as ambiguous when canonical inputs share the same identity, even if the legacy entry has a disambiguator', () => {
|
||||
// ADR 0009: canonical PromotedWidgetView no longer carries a
|
||||
// `disambiguatingSourceNodeId`, so two inputs sharing the same
|
||||
// (sourceNodeId, sourceWidgetName) cannot be told apart by the
|
||||
// classifier. The legacy entry's disambiguator is metadata only and
|
||||
// does not break the tie.
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
for (const inputName of ['first_seed', 'second_seed']) {
|
||||
const input = host.addInput(inputName, '*')
|
||||
input._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
}
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed', 'second')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines ambiguous already-linked inputs without a disambiguator', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
for (const inputName of ['first_seed', 'second_seed']) {
|
||||
const input = host.addInput(inputName, '*')
|
||||
input._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
}
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('quarantine branches', () => {
|
||||
it('quarantines when source node is missing', () => {
|
||||
const host = buildHost()
|
||||
const normalized = makeSource('999', 'seed')
|
||||
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines when source widget is missing on the source node', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'nonexistent')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', '*')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const normalized = makeSource(String(primitive.id), 'value')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview branch', () => {
|
||||
it('classifies $$-prefixed names as preview exposure', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(
|
||||
String(innerNode.id),
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('preview')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies type:preview serialize:false widgets as preview exposure', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
const widget = innerNode.addWidget('text', 'videopreview', '', () => {})
|
||||
widget.type = 'preview'
|
||||
widget.serialize = false
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'videopreview')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('preview')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: 'videopreview'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('value-widget branch', () => {
|
||||
it('plans a createSubgraphInput when the widget exists and is not linked', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 42, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'value',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive fanout branch', () => {
|
||||
it('emits primitiveBypass with target list when cohort points at the same primitive', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const targetA = new LGraphNode('TargetA')
|
||||
targetA.addInput('value', 'INT')
|
||||
targetA.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetA)
|
||||
|
||||
const targetB = new LGraphNode('TargetB')
|
||||
targetB.addInput('value', 'INT')
|
||||
targetB.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetB)
|
||||
|
||||
primitive.connect(0, targetA, 0)
|
||||
primitive.connect(0, targetB, 0)
|
||||
|
||||
const sourceA = makeSource(String(primitive.id), 'seed')
|
||||
// Cohort has 2 entries pointing at the primitive (one per target).
|
||||
const cohort = [sourceA, sourceA]
|
||||
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized: sourceA,
|
||||
cohort
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('primitiveFanout')
|
||||
expect(result.plan.kind).toBe('primitiveBypass')
|
||||
if (result.plan.kind !== 'primitiveBypass') return
|
||||
expect(result.plan.primitiveNodeId).toBe(primitive.id)
|
||||
expect(result.plan.sourceWidgetName).toBe('seed')
|
||||
expect(result.plan.targets).toHaveLength(2)
|
||||
expect(result.plan.targets.map((t) => t.targetNodeId)).toEqual(
|
||||
expect.arrayContaining([targetA.id, targetB.id])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import type {
|
||||
MigrationPlan,
|
||||
PrimitiveBypassTargetRef,
|
||||
ProxyEntryClassification
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
interface ClassificationResult {
|
||||
classification: ProxyEntryClassification
|
||||
plan: MigrationPlan
|
||||
}
|
||||
|
||||
interface ClassifyProxyEntryArgs {
|
||||
hostNode: SubgraphNode
|
||||
normalized: LegacyProxyEntrySource
|
||||
/** All proxy entries this planner pass is considering — needed to detect primitive fan-out. */
|
||||
cohort: readonly LegacyProxyEntrySource[]
|
||||
}
|
||||
|
||||
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
|
||||
|
||||
type LinkedInputMatch =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'one'; inputName: string }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
function findLinkedSubgraphInputMatch(
|
||||
hostNode: SubgraphNode,
|
||||
normalized: LegacyProxyEntrySource
|
||||
): LinkedInputMatch {
|
||||
const matches: string[] = []
|
||||
for (const input of hostNode.inputs) {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) continue
|
||||
if (
|
||||
widget.sourceNodeId === normalized.sourceNodeId &&
|
||||
widget.sourceWidgetName === normalized.sourceWidgetName
|
||||
) {
|
||||
matches.push(input.name)
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return { kind: 'none' }
|
||||
if (matches.length === 1) return { kind: 'one', inputName: matches[0] }
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
function collectPrimitiveTargets(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const targets: PrimitiveBypassTargetRef[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) continue
|
||||
targets.push({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function cohortReferencesPrimitive(
|
||||
cohort: readonly LegacyProxyEntrySource[],
|
||||
primitiveNodeId: string
|
||||
): boolean {
|
||||
let count = 0
|
||||
for (const entry of cohort) {
|
||||
if (entry.sourceNodeId === primitiveNodeId) {
|
||||
count += 1
|
||||
if (count >= 2) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function classifyProxyEntry(
|
||||
args: ClassifyProxyEntryArgs
|
||||
): ClassificationResult {
|
||||
const { hostNode, normalized, cohort } = args
|
||||
|
||||
const linkedInput = findLinkedSubgraphInputMatch(hostNode, normalized)
|
||||
if (linkedInput.kind === 'one') {
|
||||
return {
|
||||
classification: 'value',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: linkedInput.inputName }
|
||||
}
|
||||
}
|
||||
if (linkedInput.kind === 'ambiguous') {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
|
||||
if (!sourceNode) {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
|
||||
const targets = collectPrimitiveTargets(hostNode, sourceNode)
|
||||
const cohortDuplicated = cohortReferencesPrimitive(
|
||||
cohort,
|
||||
normalized.sourceNodeId
|
||||
)
|
||||
if (targets.length >= 1 || cohortDuplicated) {
|
||||
return {
|
||||
classification: 'primitiveFanout',
|
||||
plan: {
|
||||
kind: 'primitiveBypass',
|
||||
primitiveNodeId: sourceNode.id,
|
||||
sourceWidgetName: normalized.sourceWidgetName,
|
||||
targets
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
const promotableWidgets = getPromotableWidgets(sourceNode)
|
||||
const sourceWidget = promotableWidgets.find(
|
||||
(w) => w.name === normalized.sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.sourceWidgetName.startsWith('$$') ||
|
||||
isPreviewPseudoWidget(sourceWidget)
|
||||
) {
|
||||
return {
|
||||
classification: 'preview',
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classification: 'value',
|
||||
plan: {
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
|
||||
import type { ResolveNestedHostFn } from '@/stores/previewExposureStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.graph!.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function buildEntry(args: {
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}): PendingMigrationEntry {
|
||||
return {
|
||||
normalized: {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourcePreviewName
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: HOST_VALUE_HOLE,
|
||||
classification: 'preview',
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: args.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe(migratePreviewExposure, () => {
|
||||
it('adds an exposure for a $$-prefixed preview source', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
previewName: '$$canvas-image-preview'
|
||||
})
|
||||
const locator = String(host.id)
|
||||
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('produces a unique name on collision via nextUniqueName', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
const otherInner = new LGraphNode('OtherInner')
|
||||
host.subgraph.add(otherInner)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const locator = String(host.id)
|
||||
store.addExposure(host.rootGraph.id, locator, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(otherInner.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.previewName).toBe('$$canvas-image-preview_1')
|
||||
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reuses an existing exposure for the same source preview', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const locator = String(host.id)
|
||||
store.addExposure(host.rootGraph.id, locator, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
previewName: '$$canvas-image-preview'
|
||||
})
|
||||
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns missingSourceNode when the source node is absent', () => {
|
||||
const host = buildHost()
|
||||
const store = usePreviewExposureStore()
|
||||
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: '999',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
|
||||
})
|
||||
|
||||
it('round-trips through resolveChain across an outer host into an inner host', () => {
|
||||
// Set up an inner host with a leaf preview exposure, and a separate outer
|
||||
// host whose interior contains a placeholder for the inner host. The
|
||||
// chain walker is graph-agnostic, so we wire the nested-host edge via
|
||||
// the resolver callback.
|
||||
const innerSubgraph = createTestSubgraph({ name: 'Inner' })
|
||||
const innerHost = createTestSubgraphNode(innerSubgraph)
|
||||
innerHost.graph!.add(innerHost)
|
||||
const innerLeaf = new LGraphNode('Leaf')
|
||||
innerSubgraph.add(innerLeaf)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ name: 'Outer' })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerHost.graph!.add(outerHost)
|
||||
|
||||
const placeholder = new LGraphNode('PlaceholderInnerHost')
|
||||
outerSubgraph.add(placeholder)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const innerLocator = String(innerHost.id)
|
||||
const outerLocator = String(outerHost.id)
|
||||
|
||||
// Inner host: the leaf exposure (canonical $$ name) the outer chain
|
||||
// ultimately resolves to.
|
||||
store.addExposure(innerHost.rootGraph.id, innerLocator, {
|
||||
sourceNodeId: String(innerLeaf.id),
|
||||
sourcePreviewName: '$$inner-preview'
|
||||
})
|
||||
|
||||
// Outer host: migrate an entry whose source points at the placeholder
|
||||
// (representing the inner host inside outer's interior).
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: outerHost,
|
||||
entry: {
|
||||
normalized: {
|
||||
sourceNodeId: String(placeholder.id),
|
||||
sourceWidgetName: '$$inner-preview'
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: HOST_VALUE_HOLE,
|
||||
classification: 'preview',
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$inner-preview'
|
||||
}
|
||||
},
|
||||
store
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const resolveNestedHost: ResolveNestedHostFn = (
|
||||
_rootGraphId,
|
||||
_hostLocator,
|
||||
sourceNodeId
|
||||
) =>
|
||||
sourceNodeId === String(placeholder.id)
|
||||
? { rootGraphId: innerHost.rootGraph.id, hostNodeLocator: innerLocator }
|
||||
: undefined
|
||||
|
||||
const chain = store.resolveChain(
|
||||
outerHost.rootGraph.id,
|
||||
outerLocator,
|
||||
'$$inner-preview',
|
||||
resolveNestedHost
|
||||
)
|
||||
|
||||
expect(chain).toBeDefined()
|
||||
expect(chain?.steps).toHaveLength(2)
|
||||
expect(chain?.leaf.sourceNodeId).toBe(String(innerLeaf.id))
|
||||
expect(chain?.leaf.sourcePreviewName).toBe('$$inner-preview')
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
type MigratePreviewExposureResult =
|
||||
| { ok: true; previewName: string }
|
||||
| { ok: false; reason: 'missingSourceNode' | 'missingSourceWidget' }
|
||||
|
||||
interface MigratePreviewExposureArgs {
|
||||
hostNode: SubgraphNode
|
||||
entry: PendingMigrationEntry
|
||||
/** Pinia store action — pass `usePreviewExposureStore()` from the caller. */
|
||||
store: ReturnType<typeof usePreviewExposureStore>
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a single legacy preview-shaped proxy entry into the host-scoped
|
||||
* {@link usePreviewExposureStore}.
|
||||
*
|
||||
* For canonical `$$`-prefixed preview names the source widget may be lazily
|
||||
* created at first execution; we treat the exposure as metadata-only and do
|
||||
* not require the concrete widget to be present yet. For non-`$$` previews
|
||||
* (e.g. `videopreview`) the widget must already exist on the source node.
|
||||
*/
|
||||
export function migratePreviewExposure(
|
||||
args: MigratePreviewExposureArgs
|
||||
): MigratePreviewExposureResult {
|
||||
const { hostNode, entry, store } = args
|
||||
const { plan } = entry
|
||||
|
||||
if (plan.kind !== 'previewExposure') {
|
||||
throw new Error(`migratePreviewExposure: invalid plan kind ${plan.kind}`)
|
||||
}
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(
|
||||
entry.normalized.sourceNodeId
|
||||
)
|
||||
if (!sourceNode) {
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$')
|
||||
if (!isCanonicalPseudo) {
|
||||
const widget = sourceNode.widgets?.find(
|
||||
(w) => w.name === plan.sourcePreviewName
|
||||
)
|
||||
if (!widget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
const hostNodeLocator = String(hostNode.id)
|
||||
const existing = store
|
||||
.getExposures(hostNode.rootGraph.id, hostNodeLocator)
|
||||
.find(
|
||||
(exposure) =>
|
||||
exposure.sourceNodeId === entry.normalized.sourceNodeId &&
|
||||
exposure.sourcePreviewName === plan.sourcePreviewName
|
||||
)
|
||||
if (existing) return { ok: true, previewName: existing.name }
|
||||
|
||||
const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, {
|
||||
sourceNodeId: entry.normalized.sourceNodeId,
|
||||
sourcePreviewName: plan.sourcePreviewName
|
||||
})
|
||||
|
||||
return { ok: true, previewName: added.name }
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
|
||||
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
describe(flushProxyWidgetMigration, () => {
|
||||
it('returns an empty result when no proxyWidgets are present', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result).toEqual({
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates a preview-shaped entry into the PreviewExposureStore', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result.previewMigrated).toBe(1)
|
||||
expect(result.quarantined).toBe(0)
|
||||
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
expect(exposures).toHaveLength(1)
|
||||
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
|
||||
})
|
||||
|
||||
it('quarantines entries whose source node has disappeared', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result.quarantined).toBe(1)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'seed'],
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('counts already-linked entries as repaired and applies the host value', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
let widgetValue: TWidgetValue = 0
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
|
||||
const result = flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(result.repaired).toBe(1)
|
||||
expect(result.quarantined).toBe(0)
|
||||
expect(widgetValue).toBe(99)
|
||||
})
|
||||
|
||||
it('clears properties.proxyWidgets after a successful flush', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('re-running flush over a fully migrated host produces no further mutations', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const first = flushProxyWidgetMigration({ hostNode: host })
|
||||
expect(first.previewMigrated).toBe(1)
|
||||
|
||||
const exposuresAfterFirst = usePreviewExposureStore()
|
||||
.getExposures(host.rootGraph.id, String(host.id))
|
||||
.map((e) => ({ ...e }))
|
||||
|
||||
const second = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(second).toEqual({
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
})
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
).toEqual(exposuresAfterFirst)
|
||||
})
|
||||
|
||||
it('re-running flush over a quarantined host does not duplicate quarantine entries', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
const firstQuarantine = readHostQuarantine(host)
|
||||
expect(firstQuarantine).toHaveLength(1)
|
||||
|
||||
// Reseed proxyWidgets to simulate a stale legacy reload of the same
|
||||
// unresolved entry; flush must still produce no duplicates.
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
|
||||
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
|
||||
import {
|
||||
appendHostQuarantine,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
|
||||
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import type { ProxyWidgetErrorQuarantineEntry } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
interface FlushProxyWidgetMigrationArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** widgets_values from the host node at parse time. May be sparse. */
|
||||
hostWidgetValues?: readonly unknown[]
|
||||
}
|
||||
|
||||
interface FlushProxyWidgetMigrationResult {
|
||||
repaired: number
|
||||
primitiveRepaired: number
|
||||
previewMigrated: number
|
||||
quarantined: number
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: FlushProxyWidgetMigrationResult = {
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
}
|
||||
|
||||
function toLegacyTuple(
|
||||
source: LegacyProxyEntrySource
|
||||
): SerializedProxyWidgetTuple {
|
||||
return source.disambiguatingSourceNodeId
|
||||
? [
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName,
|
||||
source.disambiguatingSourceNodeId
|
||||
]
|
||||
: [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function unwrapHostValue(
|
||||
hostValue: PendingMigrationEntry['hostValue']
|
||||
): TWidgetValue | undefined {
|
||||
return hostValue === HOST_VALUE_HOLE ? undefined : (hostValue as TWidgetValue)
|
||||
}
|
||||
|
||||
function quarantineFor(
|
||||
entry: PendingMigrationEntry,
|
||||
reason: ProxyWidgetErrorQuarantineEntry['reason']
|
||||
): ProxyWidgetErrorQuarantineEntry {
|
||||
return makeQuarantineEntry({
|
||||
originalEntry: toLegacyTuple(entry.normalized),
|
||||
reason,
|
||||
hostValue: unwrapHostValue(entry.hostValue)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-ratchet a host SubgraphNode's legacy `properties.proxyWidgets` into
|
||||
* canonical representations:
|
||||
*
|
||||
* - value-widget entries → linked SubgraphInput via {@link repairValueWidget};
|
||||
* - primitive-fanout cohorts → one SubgraphInput per primitive via
|
||||
* {@link repairPrimitiveFanout};
|
||||
* - preview entries → host-scoped exposure via {@link migratePreviewExposure};
|
||||
* - unrepairable / quarantine plans → appended to
|
||||
* `properties.proxyWidgetErrorQuarantine`.
|
||||
*
|
||||
* Idempotent: re-running flush over an already-migrated host produces no
|
||||
* mutations and no duplicates because (a) the planner classifies migrated
|
||||
* entries as `alreadyLinked` (a no-op apply), (b) preview/quarantine helpers
|
||||
* dedup, and (c) the legacy `properties.proxyWidgets` is removed once flush
|
||||
* succeeds so subsequent calls return early.
|
||||
*/
|
||||
export function flushProxyWidgetMigration(
|
||||
args: FlushProxyWidgetMigrationArgs
|
||||
): FlushProxyWidgetMigrationResult {
|
||||
const { hostNode, hostWidgetValues } = args
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode, hostWidgetValues })
|
||||
if (plan.entries.length === 0) return EMPTY_RESULT
|
||||
|
||||
const previewStore = usePreviewExposureStore()
|
||||
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
|
||||
const result: FlushProxyWidgetMigrationResult = { ...EMPTY_RESULT }
|
||||
|
||||
// Group primitive-bypass entries per primitive node. Cohort flushed
|
||||
// all-or-nothing through repairPrimitiveFanout.
|
||||
const primitiveCohorts = new Map<NodeId, PendingMigrationEntry[]>()
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
const { plan: planEntry } = entry
|
||||
|
||||
if (planEntry.kind === 'primitiveBypass') {
|
||||
const cohort = primitiveCohorts.get(planEntry.primitiveNodeId) ?? []
|
||||
cohort.push(entry)
|
||||
primitiveCohorts.set(planEntry.primitiveNodeId, cohort)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
planEntry.kind === 'alreadyLinked' ||
|
||||
planEntry.kind === 'createSubgraphInput'
|
||||
) {
|
||||
const repair = repairValueWidget({ hostNode, entry })
|
||||
if (repair.ok) {
|
||||
result.repaired += 1
|
||||
} else {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (planEntry.kind === 'previewExposure') {
|
||||
const repair = migratePreviewExposure({
|
||||
hostNode,
|
||||
entry,
|
||||
store: previewStore
|
||||
})
|
||||
if (repair.ok) {
|
||||
result.previewMigrated += 1
|
||||
} else {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (planEntry.kind === 'quarantine') {
|
||||
quarantineToAppend.push(quarantineFor(entry, planEntry.reason))
|
||||
}
|
||||
}
|
||||
|
||||
for (const cohort of primitiveCohorts.values()) {
|
||||
const repair = repairPrimitiveFanout({ hostNode, cohort })
|
||||
if (repair.ok) {
|
||||
result.primitiveRepaired += 1
|
||||
} else {
|
||||
for (const entry of cohort) {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quarantineToAppend.length > 0) {
|
||||
appendHostQuarantine(hostNode, quarantineToAppend)
|
||||
result.quarantined = quarantineToAppend.length
|
||||
}
|
||||
|
||||
// Idempotency anchor: once entries have been processed, drop the legacy
|
||||
// payload so subsequent loads/configures take the no-op short-circuit.
|
||||
// Canonical state now lives on linked SubgraphInputs, the
|
||||
// PreviewExposureStore, and properties.proxyWidgetErrorQuarantine.
|
||||
delete hostNode.properties.proxyWidgets
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Sentinel marking a sparse hole in a `widgets_values` array. Distinct from
|
||||
* `undefined` so that an explicitly-stored `undefined` host value can still be
|
||||
* represented when needed.
|
||||
*/
|
||||
export const HOST_VALUE_HOLE = Symbol('proxyWidgetMigration.hostValueHole')
|
||||
export type HostValueHole = typeof HOST_VALUE_HOLE
|
||||
|
||||
export type HostValue = TWidgetValue | HostValueHole
|
||||
|
||||
/**
|
||||
* High-level outcome of classifying a single legacy proxyWidget entry.
|
||||
*
|
||||
* Distinct from {@link MigrationPlanKind} because a single classification can
|
||||
* still produce different plans (e.g. `'value'` may resolve to either
|
||||
* `alreadyLinked` or `createSubgraphInput`).
|
||||
*/
|
||||
export type ProxyEntryClassification =
|
||||
| 'value'
|
||||
| 'preview'
|
||||
| 'primitiveFanout'
|
||||
| 'unknown'
|
||||
|
||||
export interface PrimitiveBypassTargetRef {
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
export type MigrationPlan =
|
||||
| { kind: 'alreadyLinked'; subgraphInputName: string }
|
||||
| { kind: 'createSubgraphInput'; sourceWidgetName: string }
|
||||
| {
|
||||
kind: 'primitiveBypass'
|
||||
primitiveNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
targets: readonly PrimitiveBypassTargetRef[]
|
||||
}
|
||||
| { kind: 'previewExposure'; sourcePreviewName: string }
|
||||
| { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason }
|
||||
|
||||
type MigrationPlanKind = MigrationPlan['kind']
|
||||
|
||||
/**
|
||||
* One pending migration entry produced by the planner.
|
||||
*
|
||||
* @remarks
|
||||
* This is the input to the flush step. The planner does not mutate the graph;
|
||||
* it walks legacy `properties.proxyWidgets` and `widgets_values`, classifies
|
||||
* each entry, and emits a {@link PendingMigrationEntry} describing what the
|
||||
* flush should do. Flush re-validates against the current graph before
|
||||
* applying mutations.
|
||||
*/
|
||||
export interface PendingMigrationEntry {
|
||||
normalized: LegacyProxyEntrySource
|
||||
legacyOrderIndex: number
|
||||
hostValue: HostValue
|
||||
classification: ProxyEntryClassification
|
||||
plan: MigrationPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* The full plan the planner returns for a single host SubgraphNode.
|
||||
*
|
||||
* Entries are ordered by `legacyOrderIndex` ascending. Idempotency: re-running
|
||||
* the planner over a host whose canonical state already represents an entry
|
||||
* yields a `'alreadyLinked'`/`'previewExposure'` plan that the flush step
|
||||
* treats as a no-op.
|
||||
*/
|
||||
export interface ProxyWidgetMigrationPlan {
|
||||
entries: readonly PendingMigrationEntry[]
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
entries: readonly PendingMigrationEntry[],
|
||||
index: number
|
||||
): PendingMigrationEntry {
|
||||
const entry = entries.find((e) => e.legacyOrderIndex === index)
|
||||
if (!entry) throw new Error(`Expected entry at legacyOrderIndex ${index}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
describe(planProxyWidgetMigration, () => {
|
||||
it('returns an empty plan when properties.proxyWidgets is missing', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('tolerates a malformed proxyWidgets JSON string and returns empty', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = '{not json}'
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('emits classified entries for a mixed value+preview cohort, preserving order', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), 'seed'],
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const plan = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(plan.entries).toHaveLength(2)
|
||||
const valueEntry = findEntry(plan.entries, 0)
|
||||
expect(valueEntry.classification).toBe('value')
|
||||
expect(valueEntry.plan).toEqual({
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(valueEntry.hostValue).toBe(99)
|
||||
|
||||
const previewEntry = findEntry(plan.entries, 1)
|
||||
expect(previewEntry.classification).toBe('preview')
|
||||
expect(previewEntry.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
expect(previewEntry.hostValue).toBe(HOST_VALUE_HOLE)
|
||||
})
|
||||
|
||||
it('preserves sparse holes in widgets_values when they are missing', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'a', 0, () => {})
|
||||
innerNode.addWidget('number', 'b', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), 'a'],
|
||||
[String(innerNode.id), 'b']
|
||||
]
|
||||
|
||||
const sparse: unknown[] = []
|
||||
sparse[1] = 'second-value'
|
||||
|
||||
const plan = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(findEntry(plan.entries, 0).hostValue).toBe(HOST_VALUE_HOLE)
|
||||
expect(findEntry(plan.entries, 1).hostValue).toBe('second-value')
|
||||
})
|
||||
|
||||
it('emits a primitiveBypass plan per cohort entry pointing at the same primitive', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const targetA = new LGraphNode('TargetA')
|
||||
targetA.addInput('value', 'INT')
|
||||
targetA.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetA)
|
||||
|
||||
const targetB = new LGraphNode('TargetB')
|
||||
targetB.addInput('value', 'INT')
|
||||
targetB.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetB)
|
||||
|
||||
primitive.connect(0, targetA, 0)
|
||||
primitive.connect(0, targetB, 0)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toHaveLength(2)
|
||||
for (const entry of plan.entries) {
|
||||
expect(entry.classification).toBe('primitiveFanout')
|
||||
expect(entry.plan.kind).toBe('primitiveBypass')
|
||||
if (entry.plan.kind !== 'primitiveBypass') continue
|
||||
expect(entry.plan.primitiveNodeId).toBe(primitive.id)
|
||||
expect(entry.plan.targets).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent: re-running on a host whose entries are already linked yields alreadyLinked plans', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
|
||||
const firstPass = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [42]
|
||||
})
|
||||
|
||||
expect(findEntry(firstPass.entries, 0).plan).toEqual({
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
// Simulate the flush step linking the input.
|
||||
const inputSlot = host.addInput('seed', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
const secondPass = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [42]
|
||||
})
|
||||
|
||||
expect(secondPass.entries).toHaveLength(1)
|
||||
expect(findEntry(secondPass.entries, 0).plan).toEqual({
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines entries pointing at missing source nodes', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toHaveLength(1)
|
||||
expect(findEntry(plan.entries, 0).plan).toEqual({
|
||||
kind: 'quarantine',
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
|
||||
import type {
|
||||
HostValue,
|
||||
PendingMigrationEntry,
|
||||
ProxyWidgetMigrationPlan
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface PlanProxyWidgetMigrationArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** widgets_values from the host node at parse time. May be sparse. */
|
||||
hostWidgetValues?: readonly unknown[]
|
||||
}
|
||||
|
||||
function pickHostValue(
|
||||
hostWidgetValues: readonly unknown[] | undefined,
|
||||
index: number
|
||||
): HostValue {
|
||||
if (!hostWidgetValues) return HOST_VALUE_HOLE
|
||||
if (index < 0 || index >= hostWidgetValues.length) return HOST_VALUE_HOLE
|
||||
if (!Object.prototype.hasOwnProperty.call(hostWidgetValues, index)) {
|
||||
return HOST_VALUE_HOLE
|
||||
}
|
||||
return hostWidgetValues[index] as TWidgetValue
|
||||
}
|
||||
|
||||
export function planProxyWidgetMigration(
|
||||
args: PlanProxyWidgetMigrationArgs
|
||||
): ProxyWidgetMigrationPlan {
|
||||
const { hostNode, hostWidgetValues } = args
|
||||
|
||||
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
|
||||
if (tuples.length === 0) return { entries: [] }
|
||||
|
||||
const normalized: LegacyProxyEntrySource[] = tuples.map(
|
||||
([sourceNodeId, sourceWidgetName, disambiguator]) =>
|
||||
normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguator
|
||||
)
|
||||
)
|
||||
|
||||
const entries: PendingMigrationEntry[] = normalized.map(
|
||||
(entry, legacyOrderIndex) => {
|
||||
const { classification, plan } = classifyProxyEntry({
|
||||
hostNode,
|
||||
normalized: entry,
|
||||
cohort: normalized
|
||||
})
|
||||
return {
|
||||
normalized: entry,
|
||||
legacyOrderIndex,
|
||||
hostValue: pickHostValue(hostWidgetValues, legacyOrderIndex),
|
||||
classification,
|
||||
plan
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entries.sort((a, b) => a.legacyOrderIndex - b.legacyOrderIndex)
|
||||
|
||||
return { entries }
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendHostQuarantine,
|
||||
clearHostQuarantine,
|
||||
makeQuarantineEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
describe(makeQuarantineEntry, () => {
|
||||
it('builds an entry with attemptedAtVersion pinned to 1', () => {
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
|
||||
expect(entry).toEqual({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('includes hostValue when provided', () => {
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 42
|
||||
})
|
||||
|
||||
expect(entry.hostValue).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host quarantine helpers', () => {
|
||||
it('returns an empty array for an unconfigured host', () => {
|
||||
const host = buildHost()
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trips entries via append + read', () => {
|
||||
const host = buildHost()
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget',
|
||||
hostValue: 'preserved'
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [entry])
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([entry])
|
||||
})
|
||||
|
||||
it('deduplicates entries with identical originalEntry tuples', () => {
|
||||
const host = buildHost()
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
const first = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceWidget',
|
||||
hostValue: 1
|
||||
})
|
||||
const duplicate = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'unlinkedSourceWidget',
|
||||
hostValue: 2
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [first])
|
||||
appendHostQuarantine(host, [duplicate])
|
||||
|
||||
const stored = readHostQuarantine(host)
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0]).toEqual(first)
|
||||
})
|
||||
|
||||
it('keeps entries that differ by disambiguator in the originalEntry tuple', () => {
|
||||
const host = buildHost()
|
||||
const baseEntry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
const disambiguatedEntry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed', 'inner-leaf'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [baseEntry, disambiguatedEntry])
|
||||
|
||||
expect(readHostQuarantine(host)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clearHostQuarantine removes the property entirely', () => {
|
||||
const host = buildHost()
|
||||
appendHostQuarantine(host, [
|
||||
makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
])
|
||||
|
||||
clearHostQuarantine(host)
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(readHostQuarantine(host)).toEqual([])
|
||||
})
|
||||
|
||||
it('appendHostQuarantine is a no-op when given an empty list', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendHostQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
ProxyWidgetErrorQuarantineEntry,
|
||||
ProxyWidgetQuarantineReason
|
||||
} from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
|
||||
const QUARANTINE_VERSION = 1
|
||||
|
||||
interface MakeQuarantineEntryArgs {
|
||||
originalEntry: SerializedProxyWidgetTuple
|
||||
reason: ProxyWidgetQuarantineReason
|
||||
hostValue?: TWidgetValue
|
||||
}
|
||||
|
||||
export function readHostQuarantine(
|
||||
hostNode: SubgraphNode
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
return parseProxyWidgetErrorQuarantine(
|
||||
hostNode.properties[QUARANTINE_PROPERTY]
|
||||
)
|
||||
}
|
||||
|
||||
export function makeQuarantineEntry(
|
||||
args: MakeQuarantineEntryArgs
|
||||
): ProxyWidgetErrorQuarantineEntry {
|
||||
const entry: ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: args.originalEntry,
|
||||
reason: args.reason,
|
||||
attemptedAtVersion: QUARANTINE_VERSION
|
||||
}
|
||||
if (args.hostValue !== undefined) {
|
||||
entry.hostValue = args.hostValue
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export function appendHostQuarantine(
|
||||
hostNode: SubgraphNode,
|
||||
entries: readonly ProxyWidgetErrorQuarantineEntry[]
|
||||
): void {
|
||||
if (entries.length === 0) return
|
||||
|
||||
const existing = readHostQuarantine(hostNode)
|
||||
const merged = [...existing]
|
||||
for (const candidate of entries) {
|
||||
const isDuplicate = merged.some((existingEntry) =>
|
||||
isEqual(existingEntry.originalEntry, candidate.originalEntry)
|
||||
)
|
||||
if (!isDuplicate) merged.push(candidate)
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
delete hostNode.properties[QUARANTINE_PROPERTY]
|
||||
return
|
||||
}
|
||||
hostNode.properties[QUARANTINE_PROPERTY] = merged
|
||||
}
|
||||
|
||||
export function clearHostQuarantine(hostNode: SubgraphNode): void {
|
||||
delete hostNode.properties[QUARANTINE_PROPERTY]
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
interface PrimitiveScenario {
|
||||
host: SubgraphNode
|
||||
primitive: LGraphNode
|
||||
targets: LGraphNode[]
|
||||
}
|
||||
|
||||
function buildPrimitiveScenario(targetCount: number): PrimitiveScenario {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
primitive.addWidget('number', 'value', 42, () => {})
|
||||
subgraph.add(primitive)
|
||||
|
||||
const targets: LGraphNode[] = []
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const target = new LGraphNode(`Target${i}`)
|
||||
const slot = target.addInput('value', 'INT')
|
||||
slot.widget = { name: 'value' }
|
||||
target.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(target)
|
||||
primitive.connect(0, target, 0)
|
||||
targets.push(target)
|
||||
}
|
||||
|
||||
return { host, primitive, targets }
|
||||
}
|
||||
|
||||
function buildCohort(
|
||||
primitive: LGraphNode,
|
||||
targets: readonly LGraphNode[],
|
||||
options: { hostValuePerEntry?: readonly (number | undefined)[] } = {}
|
||||
): PendingMigrationEntry[] {
|
||||
return targets.map((target, index) => ({
|
||||
normalized: {
|
||||
sourceNodeId: String(primitive.id),
|
||||
sourceWidgetName: 'value',
|
||||
// Distinguish entries by the downstream target so coalesce keeps each.
|
||||
disambiguatingSourceNodeId: String(target.id)
|
||||
},
|
||||
legacyOrderIndex: index,
|
||||
hostValue:
|
||||
options.hostValuePerEntry?.[index] !== undefined
|
||||
? options.hostValuePerEntry[index]
|
||||
: HOST_VALUE_HOLE,
|
||||
classification: 'primitiveFanout',
|
||||
plan: {
|
||||
kind: 'primitiveBypass',
|
||||
primitiveNodeId: primitive.id,
|
||||
sourceWidgetName: 'value',
|
||||
targets: targets.map((t) => ({
|
||||
targetNodeId: t.id,
|
||||
targetSlot: 0
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
describe(repairPrimitiveFanout, () => {
|
||||
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(3)
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
|
||||
const subgraphInputCountBefore = host.subgraph.inputs.length
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.reconnectCount).toBe(3)
|
||||
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
|
||||
// After mutation each target's slot should no longer be linked to the primitive.
|
||||
for (const target of targets) {
|
||||
const slot = target.inputs[0]
|
||||
expect(slot.link).not.toBeNull()
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('host value (first by legacyOrderIndex) wins over primitive widget value', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(2)
|
||||
const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')!
|
||||
primitiveWidget.value = 11
|
||||
|
||||
const cohort = buildCohort(primitive, targets, {
|
||||
hostValuePerEntry: [123, 456]
|
||||
})
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const created = host.subgraph.inputs.find(
|
||||
(i) => i.name === result.subgraphInputName
|
||||
)
|
||||
expect(created?._widget?.value).toBe(123)
|
||||
})
|
||||
|
||||
it('coalesces duplicate entries that share normalized source', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(2)
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
|
||||
// Append an exact duplicate of the first cohort entry.
|
||||
cohort.push({ ...cohort[0], legacyOrderIndex: 99 })
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
// 2 unique targets → 2 reconnects regardless of duplicate cohort entries.
|
||||
expect(result.reconnectCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns primitiveBypassFailed when a target slot type is incompatible', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(1)
|
||||
// Replace the existing target slot type with something incompatible.
|
||||
targets[0].inputs[0].type = 'STRING'
|
||||
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
const subgraphInputCountBefore = host.subgraph.inputs.length
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
|
||||
// No new SubgraphInput created.
|
||||
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore)
|
||||
})
|
||||
|
||||
it('returns primitiveBypassFailed for an empty cohort', () => {
|
||||
const { host } = buildPrimitiveScenario(0)
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort: [] })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
|
||||
})
|
||||
})
|
||||
@@ -1,283 +0,0 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import type {
|
||||
PendingMigrationEntry,
|
||||
PrimitiveBypassTargetRef
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type RepairPrimitiveFanoutResult =
|
||||
| { ok: true; subgraphInputName: string; reconnectCount: number }
|
||||
| { ok: false; reason: 'primitiveBypassFailed' }
|
||||
|
||||
interface RepairPrimitiveFanoutArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** All cohort entries whose plan is `primitiveBypass` for this primitive. */
|
||||
cohort: readonly PendingMigrationEntry[]
|
||||
}
|
||||
|
||||
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
|
||||
const FAILED: RepairPrimitiveFanoutResult = {
|
||||
ok: false,
|
||||
reason: 'primitiveBypassFailed'
|
||||
}
|
||||
|
||||
interface SnapshotLink {
|
||||
primitiveSlot: number
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
function fail(message: string, context?: unknown): RepairPrimitiveFanoutResult {
|
||||
console.warn(`[repairPrimitiveFanout] ${message}`, context)
|
||||
return FAILED
|
||||
}
|
||||
|
||||
interface CohortValidationOk {
|
||||
ok: true
|
||||
primitiveNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
uniqueEntries: readonly PendingMigrationEntry[]
|
||||
}
|
||||
|
||||
function validateCohort(
|
||||
cohort: readonly PendingMigrationEntry[]
|
||||
): CohortValidationOk | { ok: false } {
|
||||
if (cohort.length === 0) return { ok: false }
|
||||
|
||||
const first = cohort[0]
|
||||
if (first.plan.kind !== 'primitiveBypass') return { ok: false }
|
||||
|
||||
const primitiveNodeId = first.plan.primitiveNodeId
|
||||
const sourceWidgetName = first.plan.sourceWidgetName
|
||||
|
||||
for (const entry of cohort) {
|
||||
if (entry.plan.kind !== 'primitiveBypass') return { ok: false }
|
||||
if (entry.plan.primitiveNodeId !== primitiveNodeId) return { ok: false }
|
||||
if (entry.plan.sourceWidgetName !== sourceWidgetName) return { ok: false }
|
||||
}
|
||||
|
||||
// Coalesce exact duplicates by `normalized`.
|
||||
const uniqueEntries: PendingMigrationEntry[] = []
|
||||
for (const entry of cohort) {
|
||||
if (
|
||||
!uniqueEntries.some((kept) => isEqual(kept.normalized, entry.normalized))
|
||||
) {
|
||||
uniqueEntries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
|
||||
}
|
||||
|
||||
function pickBaseName(
|
||||
primitiveNode: LGraphNode,
|
||||
sourceWidgetName: string
|
||||
): string {
|
||||
// Heuristic: a user-renamed PrimitiveNode title differs from its default
|
||||
// 'PrimitiveNode' label. When unrenamed, fall back to the source widget name.
|
||||
if (primitiveNode.title && primitiveNode.title !== PRIMITIVE_NODE_TYPE) {
|
||||
return primitiveNode.title
|
||||
}
|
||||
return sourceWidgetName
|
||||
}
|
||||
|
||||
function collectTargets(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] | undefined {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const targets: PrimitiveBypassTargetRef[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) return undefined
|
||||
targets.push({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function snapshotLinksForRollback(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): SnapshotLink[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const snapshot: SnapshotLink[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) continue
|
||||
snapshot.push({
|
||||
primitiveSlot: link.origin_slot,
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function rollback(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode,
|
||||
newSubgraphInput: SubgraphInput | undefined,
|
||||
snapshot: readonly SnapshotLink[]
|
||||
): void {
|
||||
if (newSubgraphInput) {
|
||||
try {
|
||||
hostNode.subgraph.removeInput(newSubgraphInput)
|
||||
} catch (e) {
|
||||
console.warn('[repairPrimitiveFanout] rollback removeInput failed', e)
|
||||
}
|
||||
}
|
||||
for (const link of snapshot) {
|
||||
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
|
||||
if (!targetNode) continue
|
||||
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
|
||||
}
|
||||
}
|
||||
|
||||
function pickHostValue(
|
||||
uniqueEntries: readonly PendingMigrationEntry[]
|
||||
): TWidgetValue | undefined {
|
||||
const ordered = [...uniqueEntries].sort(
|
||||
(a, b) => a.legacyOrderIndex - b.legacyOrderIndex
|
||||
)
|
||||
for (const entry of ordered) {
|
||||
if (entry.hostValue !== HOST_VALUE_HOLE) {
|
||||
return entry.hostValue as TWidgetValue
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* All-or-quarantine repair of one primitive's fan-out into a single
|
||||
* SubgraphInput.
|
||||
*
|
||||
* Each call repairs ONE primitive node and the cohort of legacy entries that
|
||||
* pointed at it. On any failure during validation or mutation, the helper
|
||||
* rolls back any partial changes and returns
|
||||
* `{ ok: false, reason: 'primitiveBypassFailed' }` so the caller can
|
||||
* quarantine all cohort entries.
|
||||
*/
|
||||
export function repairPrimitiveFanout(
|
||||
args: RepairPrimitiveFanoutArgs
|
||||
): RepairPrimitiveFanoutResult {
|
||||
const { hostNode, cohort } = args
|
||||
|
||||
const validated = validateCohort(cohort)
|
||||
if (!validated.ok) return fail('cohort validation failed', { cohort })
|
||||
|
||||
const subgraph = hostNode.subgraph
|
||||
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
|
||||
if (!primitiveNode) {
|
||||
return fail('primitive node missing', {
|
||||
primitiveNodeId: validated.primitiveNodeId
|
||||
})
|
||||
}
|
||||
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
|
||||
return fail('node is not a PrimitiveNode', {
|
||||
primitiveNodeId: validated.primitiveNodeId,
|
||||
type: primitiveNode.type
|
||||
})
|
||||
}
|
||||
|
||||
const targets = collectTargets(hostNode, primitiveNode)
|
||||
if (!targets || targets.length === 0) {
|
||||
return fail('no targets to reconnect', {
|
||||
primitiveNodeId: validated.primitiveNodeId
|
||||
})
|
||||
}
|
||||
|
||||
const primitiveOutput = primitiveNode.outputs?.[0]
|
||||
if (!primitiveOutput) return fail('primitive has no output')
|
||||
const primitiveOutputType = String(primitiveOutput.type ?? '*')
|
||||
|
||||
// Pre-validate compatibility of every target before mutating.
|
||||
for (const target of targets) {
|
||||
const targetNode = subgraph.getNodeById(target.targetNodeId)
|
||||
if (!targetNode) return fail('target node missing', target)
|
||||
const targetSlot = targetNode.inputs?.[target.targetSlot]
|
||||
if (!targetSlot) return fail('target slot missing', target)
|
||||
const targetType = String(targetSlot.type ?? '*')
|
||||
if (
|
||||
targetType !== primitiveOutputType &&
|
||||
targetType !== '*' &&
|
||||
primitiveOutputType !== '*'
|
||||
) {
|
||||
return fail('target slot type incompatible', {
|
||||
target,
|
||||
targetType,
|
||||
primitiveOutputType
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = pickBaseName(primitiveNode, validated.sourceWidgetName)
|
||||
const existingNames = subgraph.inputs.map((input) => input.name)
|
||||
const uniqueName = nextUniqueName(baseName, existingNames)
|
||||
|
||||
const snapshot = snapshotLinksForRollback(hostNode, primitiveNode)
|
||||
|
||||
let newSubgraphInput: SubgraphInput | undefined
|
||||
try {
|
||||
newSubgraphInput = subgraph.addInput(uniqueName, primitiveOutputType)
|
||||
|
||||
// Disconnect every former primitive→target link.
|
||||
for (const snap of snapshot) {
|
||||
const targetNode = subgraph.getNodeById(snap.targetNodeId)
|
||||
if (!targetNode)
|
||||
throw new Error(
|
||||
`target node ${snap.targetNodeId} disappeared mid-mutation`
|
||||
)
|
||||
targetNode.disconnectInput(snap.targetSlot, false)
|
||||
}
|
||||
|
||||
// Reconnect each target slot from the new SubgraphInput, in target order.
|
||||
for (const target of targets) {
|
||||
const targetNode = subgraph.getNodeById(target.targetNodeId)
|
||||
if (!targetNode)
|
||||
throw new Error(`target node ${target.targetNodeId} disappeared`)
|
||||
const targetSlot = targetNode.inputs?.[target.targetSlot]
|
||||
if (!targetSlot)
|
||||
throw new Error(`target slot ${target.targetSlot} disappeared`)
|
||||
const link = newSubgraphInput.connect(targetSlot, targetNode)
|
||||
if (!link) {
|
||||
throw new Error('SubgraphInput.connect returned no link')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rollback(hostNode, primitiveNode, newSubgraphInput, snapshot)
|
||||
return fail('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply value: prefer first-by-legacyOrderIndex non-hole host value;
|
||||
// otherwise seed from the primitive's source widget value if present.
|
||||
const hostValue = pickHostValue(validated.uniqueEntries)
|
||||
const valueToApply: TWidgetValue | undefined =
|
||||
hostValue !== undefined
|
||||
? hostValue
|
||||
: (primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined)
|
||||
|
||||
if (valueToApply !== undefined && newSubgraphInput._widget) {
|
||||
newSubgraphInput._widget.value = valueToApply
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subgraphInputName: newSubgraphInput.name,
|
||||
reconnectCount: targets.length
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function buildEntry(args: {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
plan: PendingMigrationEntry['plan']
|
||||
hostValue?: PendingMigrationEntry['hostValue']
|
||||
}): PendingMigrationEntry {
|
||||
return {
|
||||
normalized: {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
...(args.disambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: args.disambiguatingSourceNodeId
|
||||
})
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: args.hostValue ?? HOST_VALUE_HOLE,
|
||||
classification: 'value',
|
||||
plan: args.plan
|
||||
}
|
||||
}
|
||||
|
||||
describe(repairValueWidget, () => {
|
||||
describe('alreadyLinked plan', () => {
|
||||
it('applies host value to the linked input widget (host wins over interior)', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 7
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' },
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
|
||||
expect(inputSlot._widget?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('leaves widget value unchanged when hostValue is HOST_VALUE_HOLE', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 7
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
|
||||
expect(inputSlot._widget?.value).toBe(7)
|
||||
})
|
||||
|
||||
it('routes by subgraphInputName, ignoring legacy disambiguator metadata', () => {
|
||||
// ADR 0009: canonical PromotedWidgetView no longer carries a
|
||||
// `disambiguatingSourceNodeId`. Repair routes the host value to the
|
||||
// input named by `subgraphInputName`; any disambiguator carried on the
|
||||
// legacy entry is metadata only and does not affect the canonical
|
||||
// match.
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const firstInput = host.addInput('first_seed', '*')
|
||||
firstInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 1
|
||||
})
|
||||
const secondInput = host.addInput('second_seed', '*')
|
||||
secondInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 2
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: 'second',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'second_seed' },
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'second_seed' })
|
||||
expect(firstInput._widget?.value).toBe(1)
|
||||
expect(secondInput._widget?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('does not apply host value when already-linked inputs are ambiguous', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const firstInput = host.addInput('first_seed', '*')
|
||||
firstInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 1
|
||||
})
|
||||
const secondInput = host.addInput('second_seed', '*')
|
||||
secondInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 2
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: {
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: undefined as never
|
||||
},
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'ambiguousSubgraphInput' })
|
||||
expect(firstInput._widget?.value).toBe(1)
|
||||
expect(secondInput._widget?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('returns missingSubgraphInput when the linked SubgraphInput is gone', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSubgraphInput' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSubgraphInput plan', () => {
|
||||
it('creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
const slot = innerNode.addInput('seed', 'INT')
|
||||
slot.widget = { name: 'seed' }
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
if (result.ok) {
|
||||
expect(result.subgraphInputName).toBe(created?.name)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns missingSourceNode when the source node is absent', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: '999',
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
|
||||
})
|
||||
|
||||
it('returns missingSourceWidget when the widget is absent on the source node', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'nonexistent',
|
||||
plan: {
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'nonexistent'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceWidget' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid plan kind', () => {
|
||||
it('throws on unsupported plan kinds', () => {
|
||||
const host = buildHost()
|
||||
const entry = buildEntry({
|
||||
sourceNodeId: '7',
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
})
|
||||
|
||||
expect(() => repairValueWidget({ hostNode: host, entry })).toThrow(
|
||||
/invalid plan kind/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
type RepairValueWidgetResult =
|
||||
| { ok: true; subgraphInputName: string }
|
||||
| { ok: false; reason: ProxyWidgetQuarantineReason }
|
||||
|
||||
interface RepairValueWidgetArgs {
|
||||
hostNode: SubgraphNode
|
||||
entry: PendingMigrationEntry
|
||||
}
|
||||
|
||||
function findHostInputForLinkedSource(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
subgraphInputName: string | undefined
|
||||
):
|
||||
| { kind: 'none' }
|
||||
| { kind: 'one'; input: INodeInputSlot }
|
||||
| { kind: 'ambiguous' } {
|
||||
const candidates = subgraphInputName
|
||||
? hostNode.inputs.filter((input) => input.name === subgraphInputName)
|
||||
: hostNode.inputs
|
||||
|
||||
const matches = candidates.filter((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
return (
|
||||
widget.sourceNodeId === sourceNodeId &&
|
||||
widget.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
if (matches.length === 0) return { kind: 'none' }
|
||||
if (matches.length === 1) return { kind: 'one', input: matches[0] }
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
function applyHostValue(
|
||||
widget: IBaseWidget,
|
||||
hostValue: PendingMigrationEntry['hostValue']
|
||||
): void {
|
||||
if (hostValue === HOST_VALUE_HOLE) return
|
||||
widget.value = hostValue as TWidgetValue
|
||||
}
|
||||
|
||||
function repairAlreadyLinked(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingMigrationEntry
|
||||
): RepairValueWidgetResult {
|
||||
const hostInput = findHostInputForLinkedSource(
|
||||
hostNode,
|
||||
entry.normalized.sourceNodeId,
|
||||
entry.normalized.sourceWidgetName,
|
||||
entry.plan.kind === 'alreadyLinked'
|
||||
? entry.plan.subgraphInputName
|
||||
: undefined
|
||||
)
|
||||
if (hostInput.kind === 'ambiguous') {
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
if (hostInput.kind === 'none' || !hostInput.input._widget) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput.input._widget, entry.hostValue)
|
||||
return { ok: true, subgraphInputName: hostInput.input.name }
|
||||
}
|
||||
|
||||
function repairCreateSubgraphInput(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingMigrationEntry,
|
||||
sourceWidgetName: string
|
||||
): RepairValueWidgetResult {
|
||||
const subgraph = hostNode.subgraph
|
||||
const sourceNode: LGraphNode | null = subgraph.getNodeById(
|
||||
entry.normalized.sourceNodeId
|
||||
)
|
||||
if (!sourceNode) {
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(w) => w.name === sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
|
||||
const slot: INodeInputSlot | undefined =
|
||||
sourceNode.getSlotFromWidget(sourceWidget)
|
||||
if (!slot) {
|
||||
// TODO(adr-0009): When the source widget has no backing input slot,
|
||||
// promotion currently has no canonical path to wire it through a
|
||||
// SubgraphInput without first synthesizing the slot. The wiring slice
|
||||
// (slice 5) will reconcile this — for now we surface a quarantine reason
|
||||
// so the entry is preserved and visible to the user.
|
||||
console.warn(
|
||||
'[repairValueWidget] source widget has no backing input slot; quarantining',
|
||||
{
|
||||
sourceNodeId: entry.normalized.sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
)
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
const existingNames = subgraph.inputs.map((input) => input.name)
|
||||
const desiredName = nextUniqueName(sourceWidgetName, existingNames)
|
||||
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
|
||||
|
||||
const newSubgraphInput = subgraph.addInput(desiredName, slotType)
|
||||
// Mirror LGraphNode.configure: input.label → widget.label propagation.
|
||||
if (slot.label !== undefined) newSubgraphInput.label = slot.label
|
||||
const link = newSubgraphInput.connect(slot, sourceNode)
|
||||
if (!link) {
|
||||
subgraph.removeInput(newSubgraphInput)
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry.hostValue)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair a single legacy proxy entry into its canonical linked SubgraphInput.
|
||||
*
|
||||
* Two valid plan kinds: `'alreadyLinked'` and `'createSubgraphInput'`. Any
|
||||
* other plan kind is a programmer error (caller bug) and throws. Failures
|
||||
* during repair return a quarantine reason; the caller is expected to
|
||||
* append the entry to the host's quarantine via `appendHostQuarantine`.
|
||||
*/
|
||||
export function repairValueWidget(
|
||||
args: RepairValueWidgetArgs
|
||||
): RepairValueWidgetResult {
|
||||
const { hostNode, entry } = args
|
||||
const { plan } = entry
|
||||
|
||||
if (plan.kind === 'alreadyLinked') {
|
||||
return repairAlreadyLinked(hostNode, entry)
|
||||
}
|
||||
|
||||
if (plan.kind === 'createSubgraphInput') {
|
||||
return repairCreateSubgraphInput(hostNode, entry, plan.sourceWidgetName)
|
||||
}
|
||||
|
||||
throw new Error(`repairValueWidget: invalid plan kind ${plan.kind}`)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
|
||||
import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook'
|
||||
|
||||
/**
|
||||
* Register the proxyWidget migration flush as the late-bound hook that
|
||||
* `LGraph.configure()` calls for every host SubgraphNode it materializes.
|
||||
*
|
||||
* Called once during app initialization. Safe to call multiple times — the
|
||||
* registry holds a single function reference.
|
||||
*/
|
||||
export function wireProxyWidgetMigrationFlush(): void {
|
||||
setSubgraphMigrationFlushHook(({ hostNode, nodeData }) => {
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
hostWidgetValues: nodeData?.widgets_values
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type { PreviewExposureChainContext } from './previewExposureChain'
|
||||
import { resolvePreviewExposureChain } from './previewExposureChain'
|
||||
|
||||
const rootGraphA = 'root-a' as UUID
|
||||
const rootGraphB = 'root-b' as UUID
|
||||
|
||||
interface FixtureExposure extends PreviewExposure {}
|
||||
|
||||
interface NestedHostMapping {
|
||||
fromHostLocator: string
|
||||
fromSourceNodeId: string
|
||||
toRootGraphId: UUID
|
||||
toHostLocator: string
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
exposureMap: Map<string, FixtureExposure[]>,
|
||||
nested: NestedHostMapping[]
|
||||
): PreviewExposureChainContext {
|
||||
return {
|
||||
getExposures(rootGraphId, hostLocator) {
|
||||
return exposureMap.get(`${rootGraphId}|${hostLocator}`) ?? []
|
||||
},
|
||||
resolveNestedHost(_rootGraphId, hostLocator, sourceNodeId) {
|
||||
const match = nested.find(
|
||||
(n) =>
|
||||
n.fromHostLocator === hostLocator &&
|
||||
n.fromSourceNodeId === sourceNodeId
|
||||
)
|
||||
if (!match) return undefined
|
||||
return {
|
||||
rootGraphId: match.toRootGraphId,
|
||||
hostNodeLocator: match.toHostLocator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe(resolvePreviewExposureChain, () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns undefined when the named exposure is not on the starting host', () => {
|
||||
const ctx = makeContext(new Map(), [])
|
||||
expect(
|
||||
resolvePreviewExposureChain(rootGraphA, 'host-a', 'absent', ctx)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a single-step chain when the source is a leaf (no nested host)', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-a`,
|
||||
[
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-a',
|
||||
'preview',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
steps: [
|
||||
{
|
||||
rootGraphId: rootGraphA,
|
||||
hostNodeLocator: 'host-a',
|
||||
exposure: {
|
||||
name: 'preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
}
|
||||
],
|
||||
leaf: {
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('walks one nested host and returns a two-step chain', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-outer`,
|
||||
[
|
||||
{
|
||||
name: 'outer-preview',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphA}|host-inner`,
|
||||
[
|
||||
{
|
||||
name: 'inner-preview',
|
||||
sourceNodeId: 'leaf-node',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-outer',
|
||||
fromSourceNodeId: '99',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-inner'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-outer',
|
||||
'outer-preview',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result?.steps).toHaveLength(2)
|
||||
expect(result?.steps[0].hostNodeLocator).toBe('host-outer')
|
||||
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: 'leaf-node',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('walks two nested hosts (three-step chain) crossing a root graph boundary', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-1`,
|
||||
[
|
||||
{
|
||||
name: 'p1',
|
||||
sourceNodeId: 'sub-a',
|
||||
sourcePreviewName: 'p2'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphA}|host-2`,
|
||||
[
|
||||
{
|
||||
name: 'p2',
|
||||
sourceNodeId: 'sub-b',
|
||||
sourcePreviewName: 'p3'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphB}|host-3`,
|
||||
[
|
||||
{
|
||||
name: 'p3',
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-1',
|
||||
fromSourceNodeId: 'sub-a',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-2'
|
||||
},
|
||||
{
|
||||
fromHostLocator: 'host-2',
|
||||
fromSourceNodeId: 'sub-b',
|
||||
toRootGraphId: rootGraphB,
|
||||
toHostLocator: 'host-3'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(rootGraphA, 'host-1', 'p1', ctx)
|
||||
|
||||
expect(result?.steps).toHaveLength(3)
|
||||
expect(result?.steps.map((s) => s.exposure.name)).toEqual([
|
||||
'p1',
|
||||
'p2',
|
||||
'p3'
|
||||
])
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphB,
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates at outer step when nested host has no matching exposure', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-outer`,
|
||||
[
|
||||
{
|
||||
name: 'outer',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'missing-on-inner'
|
||||
}
|
||||
]
|
||||
],
|
||||
[`${rootGraphA}|host-inner`, []]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-outer',
|
||||
fromSourceNodeId: '99',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-inner'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-outer',
|
||||
'outer',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result?.steps).toHaveLength(1)
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'missing-on-inner'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects cycles, warns, and stops walking', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-a`,
|
||||
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-a',
|
||||
fromSourceNodeId: 'sub',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-a'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-a',
|
||||
'cyclic',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cycle detected')
|
||||
)
|
||||
expect(result?.steps).toHaveLength(1)
|
||||
expect(result?.leaf.sourceNodeId).toBe('sub')
|
||||
})
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type {
|
||||
ResolvedPreviewChain,
|
||||
ResolvedPreviewChainStep
|
||||
} from './previewExposureTypes'
|
||||
|
||||
/**
|
||||
* Lookup callbacks the chain walker needs to follow nested-host boundaries.
|
||||
*
|
||||
* The walker is graph-agnostic: it does not import LGraph. The store layer or
|
||||
* test harness wires up these callbacks against a real graph or a fixture.
|
||||
*/
|
||||
export interface PreviewExposureChainContext {
|
||||
/**
|
||||
* Return preview exposures registered for a host execution path.
|
||||
*/
|
||||
getExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): readonly PreviewExposure[]
|
||||
/**
|
||||
* Resolve a source node to its nested host execution path when it is a
|
||||
* SubgraphNode.
|
||||
*/
|
||||
resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
sourceNodeId: string
|
||||
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
|
||||
}
|
||||
|
||||
const MAX_CHAIN_DEPTH = 32
|
||||
|
||||
function visitedKey(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string
|
||||
): string {
|
||||
return `${rootGraphId}|${hostNodeLocator}|${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a preview-exposure chain from an outer host through any nested-host
|
||||
* boundaries down to a leaf source.
|
||||
*
|
||||
* @returns The {@link ResolvedPreviewChain} or `undefined` when the named
|
||||
* exposure does not exist on the starting host.
|
||||
*
|
||||
* @remarks
|
||||
* Cycles are detected via a visited set; a cycle terminates the walk at the
|
||||
* cycle entry and emits a `console.warn`. The walk also terminates at a fixed
|
||||
* `MAX_CHAIN_DEPTH` to defend against pathological inputs.
|
||||
*/
|
||||
export function resolvePreviewExposureChain(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string,
|
||||
ctx: PreviewExposureChainContext
|
||||
): ResolvedPreviewChain | undefined {
|
||||
const steps: ResolvedPreviewChainStep[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
let currentRootGraphId: UUID = rootGraphId
|
||||
let currentHost = hostNodeLocator
|
||||
let currentName = name
|
||||
|
||||
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
||||
const key = visitedKey(currentRootGraphId, currentHost, currentName)
|
||||
if (visited.has(key)) {
|
||||
console.warn(
|
||||
`[previewExposureChain] cycle detected at ${key}; terminating walk`
|
||||
)
|
||||
break
|
||||
}
|
||||
visited.add(key)
|
||||
|
||||
const exposures = ctx.getExposures(currentRootGraphId, currentHost)
|
||||
const exposure = exposures.find((e) => e.name === currentName)
|
||||
if (!exposure) {
|
||||
if (steps.length === 0) return undefined
|
||||
// Source on outer host pointed at a non-existent inner exposure; treat
|
||||
// the outer step as the leaf and stop walking.
|
||||
const last = steps[steps.length - 1].exposure
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: last.sourceNodeId,
|
||||
sourcePreviewName: last.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
steps.push({
|
||||
rootGraphId: currentRootGraphId,
|
||||
hostNodeLocator: currentHost,
|
||||
exposure
|
||||
})
|
||||
|
||||
const nested = ctx.resolveNestedHost(
|
||||
currentRootGraphId,
|
||||
currentHost,
|
||||
exposure.sourceNodeId
|
||||
)
|
||||
if (!nested) {
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourcePreviewName: exposure.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentRootGraphId = nested.rootGraphId
|
||||
currentHost = nested.hostNodeLocator
|
||||
currentName = exposure.sourcePreviewName
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[previewExposureChain] max chain depth (${MAX_CHAIN_DEPTH}) reached; terminating walk`
|
||||
)
|
||||
if (steps.length === 0) return undefined
|
||||
const last = steps[steps.length - 1].exposure
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: last.sourceNodeId,
|
||||
sourcePreviewName: last.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
/**
|
||||
* One step along a chain of preview exposures rooted at an outer host.
|
||||
*/
|
||||
export interface ResolvedPreviewChainStep {
|
||||
rootGraphId: UUID
|
||||
hostNodeLocator: string
|
||||
exposure: PreviewExposure
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of walking a preview-exposure chain through zero or more nested
|
||||
* subgraph hosts.
|
||||
*
|
||||
* @remarks
|
||||
* `steps` is ordered outer-most first. A single-link chain has exactly one
|
||||
* step. `leaf` describes the final non-host source — the interior node id and
|
||||
* preview name reached at the bottom of the walk.
|
||||
*/
|
||||
export interface ResolvedPreviewChain {
|
||||
steps: readonly ResolvedPreviewChainStep[]
|
||||
leaf: {
|
||||
rootGraphId: UUID
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
}
|
||||
|
||||
export type { PreviewExposure }
|
||||
@@ -10,33 +10,20 @@ export interface ResolvedPromotedWidget {
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy proxyWidget tuple shape carried through migration. The optional
|
||||
* `disambiguatingSourceNodeId` is read from legacy `properties.proxyWidgets`
|
||||
* payloads only — canonical runtime state never sets it. See ADR 0009.
|
||||
*/
|
||||
export interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
/**
|
||||
* Identity of the immediate interior child whose widget (or input slot, for
|
||||
* nested SubgraphNode children) this view exposes. Per ADR 0009 each
|
||||
* SubgraphNode is opaque: the parent's promoted view references the
|
||||
* immediate child only and does not flatten to deeper origins.
|
||||
*/
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
/**
|
||||
* Per-instance value hydration that writes only to host widget state, never
|
||||
* cascading into the shared interior widget. Used during configure/clone.
|
||||
* The original leaf-level source node ID, used to distinguish promoted
|
||||
* widgets with the same name on the same intermediate node. Unlike
|
||||
* `sourceNodeId` (the direct interior node), this traces to the deepest
|
||||
* origin.
|
||||
*/
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
2680
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
2680
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
@@ -28,12 +27,6 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export function getPromotedWidgetHostStateName(
|
||||
widget: IPromotedWidgetView
|
||||
): string {
|
||||
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
|
||||
}
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
@@ -48,14 +41,6 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isValueControlWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
|
||||
typeof widget.beforeQueued === 'function' &&
|
||||
typeof widget.afterQueued === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
@@ -71,6 +56,7 @@ export function createPromotedWidgetView(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
@@ -78,6 +64,7 @@ export function createPromotedWidgetView(
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
@@ -113,6 +100,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
@@ -162,17 +150,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -217,43 +200,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
this.subgraphNode.id,
|
||||
this.hostWidgetStateName
|
||||
)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
useWidgetValueStore().registerWidget(this.graphId, {
|
||||
nodeId: this.subgraphNode.id,
|
||||
name: this.hostWidgetStateName,
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
// Clone — never share the interior widget's options reference, or
|
||||
// host-state mutations (e.g. disabled toggle) leak into the shared
|
||||
// interior across every SubgraphNode instance.
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
private get hostWidgetStateName(): string {
|
||||
return getPromotedWidgetHostStateName(this)
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
@@ -271,16 +217,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value into this host's widget store entry without cascading into
|
||||
* the shared interior widget — the only safe path for per-instance hydration
|
||||
* during `configure()` and clone, where multiple SubgraphNode instances
|
||||
* reference the same shared interior nodes.
|
||||
*/
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached bound subgraph slot reference, refreshing only when
|
||||
* the subgraph node's input list has changed (length mismatch).
|
||||
@@ -415,50 +351,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
beforeQueued(): void {
|
||||
// Source widgets linked through subgraph inputs are inert for prompt
|
||||
// serialization. Control-after-generate is applied to the promoted host
|
||||
// value in afterQueued so the next prompt uses the updated SubgraphNode
|
||||
// value, not the linked source value.
|
||||
}
|
||||
|
||||
afterQueued(): void {
|
||||
this.applyValueControlToHost()
|
||||
}
|
||||
|
||||
private applyValueControlToHost(): void {
|
||||
const resolved = this.resolveAtHost()
|
||||
const controlWidget =
|
||||
resolved?.widget.linkedWidgets?.find(isValueControlWidget)
|
||||
if (!controlWidget) return
|
||||
|
||||
const mode = controlWidget.value
|
||||
if (mode === 'fixed') return
|
||||
|
||||
const current = this.value
|
||||
if (typeof current !== 'number') return
|
||||
|
||||
const { min = 0, max = 1, step2 = 1 } = this.options
|
||||
let next = current
|
||||
if (mode === 'increment') next += step2
|
||||
else if (mode === 'decrement') next -= step2
|
||||
else if (mode === 'randomize') {
|
||||
const safeMax = Math.min(1125899906842624, max)
|
||||
const safeMin = Math.max(-1125899906842624, min)
|
||||
const range = (safeMax - safeMin) / step2
|
||||
next = Math.floor(Math.random() * range) * step2 + safeMin
|
||||
}
|
||||
next = Math.min(Math.max(next, min), max)
|
||||
this.value = next
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,7 +372,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
@@ -512,7 +413,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName &&
|
||||
boundWidget.disambiguatingSourceNodeId ===
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,7 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -24,16 +19,11 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputAtIndex,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
pruneDisconnected
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -122,64 +112,58 @@ describe('pruneDisconnected', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('removes disconnected linked inputs and emits a dev warning', () => {
|
||||
it('removes disconnected entries and emits a dev warning', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
subgraphNode.subgraph.add(interiorNode)
|
||||
const keptInput = interiorNode.addInput('kept', 'STRING')
|
||||
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
|
||||
keptInput.widget = { name: keptWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
|
||||
interiorNode.addWidget('text', 'kept', 'value', () => {})
|
||||
|
||||
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
|
||||
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
})
|
||||
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
|
||||
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
|
||||
sourceNodeId: '9999',
|
||||
sourceWidgetName: 'missing-node'
|
||||
})
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
},
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
|
||||
])
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
pruneDisconnected(subgraphNode)
|
||||
|
||||
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
|
||||
])
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not prune preview exposures for PreviewImage nodes', () => {
|
||||
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('PreviewImage')
|
||||
interiorNode.type = 'PreviewImage'
|
||||
subgraphNode.subgraph.add(interiorNode)
|
||||
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
usePreviewExposureStore().addExposure(
|
||||
subgraphNode.rootGraph.id,
|
||||
hostLocator,
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
)
|
||||
])
|
||||
|
||||
pruneDisconnected(subgraphNode)
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
hostLocator
|
||||
)
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -248,50 +232,6 @@ describe('promoteRecommendedWidgets', () => {
|
||||
updatePreviewsMock.mockReset()
|
||||
})
|
||||
|
||||
it('promotes recommended value widgets through linked subgraph inputs', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Sampler')
|
||||
const input = interiorNode.addInput('seed', 'INT')
|
||||
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
|
||||
expect(linkedInput).toBeDefined()
|
||||
expect(input.link).not.toBeNull()
|
||||
expect(linkedInput?.linkIds).toContain(input.link)
|
||||
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
hostLocator
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips deferred updatePreviews when a preview widget already exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -312,7 +252,7 @@ describe('promoteRecommendedWidgets', () => {
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
|
||||
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
@@ -321,21 +261,17 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
hostLocator
|
||||
)
|
||||
).toContainEqual({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates $$canvas-image-preview exposure on configure for GLSLShader in saved workflow', () => {
|
||||
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
|
||||
// Simulate loading a saved workflow where proxyWidgets does NOT contain
|
||||
// the $$canvas-image-preview entry (e.g. blueprint authored before the
|
||||
// promotion system, or old workflow save).
|
||||
@@ -348,17 +284,13 @@ describe('promoteRecommendedWidgets', () => {
|
||||
// which eagerly registers $$canvas-image-preview for supported node types
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
hostLocator
|
||||
)
|
||||
).toContainEqual({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -382,11 +314,12 @@ describe('hasUnpromotedWidgets', () => {
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('seed', 'STRING')
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
@@ -483,193 +416,3 @@ describe('isLinkedPromotion', () => {
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputsByName', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'third',
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(host.inputs.map((input) => input.name)).toEqual([
|
||||
'third',
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders promoted widgets on the host node from subgraph input order', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
const [secondSlot, firstSlot] = subgraph.inputs
|
||||
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
|
||||
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
|
||||
|
||||
expect(secondLink?.origin_slot).toBe(0)
|
||||
expect(firstLink?.origin_slot).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputAtIndex', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('moves host widget values with dragged input rows', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 0, 1)
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(host.widgets.map((widget) => widget.value)).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph link slot indices after moving a row', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 0, 1)
|
||||
|
||||
const [secondSlot, firstSlot] = subgraph.inputs
|
||||
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
|
||||
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
|
||||
|
||||
expect(secondLink?.origin_slot).toBe(0)
|
||||
expect(firstLink?.origin_slot).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reorders duplicate-named promoted inputs by widget identity', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
@@ -16,12 +15,12 @@ import {
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
@@ -38,20 +37,7 @@ export function isLinkedPromotion(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): boolean {
|
||||
return (
|
||||
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the host input on `subgraphNode` whose `_widget` is the
|
||||
* `PromotedWidgetView` for `(sourceNodeId, sourceWidgetName)`. */
|
||||
function findHostInputForPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
return subgraphNode.inputs.some((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
w &&
|
||||
@@ -62,157 +48,9 @@ function findHostInputForPromotion(
|
||||
})
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
): void {
|
||||
const order = new Map(
|
||||
orderedInputNames.map((name, index) => [name, index] as const)
|
||||
)
|
||||
const byOrder = <T extends { name: string }>(left: T, right: T) => {
|
||||
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
|
||||
return leftOrder - rightOrder
|
||||
}
|
||||
|
||||
const orderedIndices = subgraphNode.subgraph.inputs
|
||||
.map((input, index) => ({ input, index }))
|
||||
.sort((left, right) => byOrder(left.input, right.input))
|
||||
.map(({ index }) => index)
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
for (const index of remainingIndices) orderedIndices.push(index)
|
||||
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputAtIndex(
|
||||
subgraphNode: SubgraphNode,
|
||||
oldPosition: number,
|
||||
newPosition: number
|
||||
): void {
|
||||
if (
|
||||
oldPosition < 0 ||
|
||||
newPosition < 0 ||
|
||||
oldPosition >= subgraphNode.subgraph.inputs.length ||
|
||||
newPosition >= subgraphNode.subgraph.inputs.length
|
||||
)
|
||||
return
|
||||
|
||||
const orderedIndices = subgraphNode.subgraph.inputs.map((_, index) => index)
|
||||
const [movedIndex] = orderedIndices.splice(oldPosition, 1)
|
||||
if (movedIndex !== undefined)
|
||||
orderedIndices.splice(newPosition, 0, movedIndex)
|
||||
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const rows = subgraphNode.subgraph.inputs.map((input, index) => ({
|
||||
subgraphInput: input,
|
||||
hostInput: subgraphNode.inputs[index],
|
||||
value: subgraphNode.inputs[index]?._widget?.value
|
||||
}))
|
||||
|
||||
const orderedRows = orderedIndices.flatMap((index) => rows[index] ?? [])
|
||||
|
||||
subgraphNode.subgraph.inputs.splice(
|
||||
0,
|
||||
subgraphNode.subgraph.inputs.length,
|
||||
...orderedRows.map((row) => row.subgraphInput)
|
||||
)
|
||||
subgraphNode.inputs.splice(
|
||||
0,
|
||||
subgraphNode.inputs.length,
|
||||
...orderedRows.flatMap((row) => row.hostInput ?? [])
|
||||
)
|
||||
|
||||
for (const [index, input] of subgraphNode.subgraph.inputs.entries()) {
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (link) link.origin_slot = index
|
||||
}
|
||||
}
|
||||
|
||||
subgraphNode.widgets.forEach((widget, index) => {
|
||||
const value = orderedRows[index]?.value
|
||||
if (value !== undefined) widget.value = value
|
||||
})
|
||||
}
|
||||
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.sourceNodeId
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
return usePreviewExposureStore()
|
||||
.getExposures(subgraphNode.rootGraph.id, hostLocator)
|
||||
.some(
|
||||
(exposure) =>
|
||||
exposure.sourceNodeId === source.sourceNodeId &&
|
||||
exposure.sourcePreviewName === source.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function isPromotedOnParent(
|
||||
subgraphNode: SubgraphNode,
|
||||
widget: IBaseWidget,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
if (isPreviewPseudoWidget(widget))
|
||||
return isPreviewExposed(subgraphNode, source)
|
||||
return isLinkedPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
export function isWidgetPromotedOnSubgraphNode(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
return (
|
||||
isLinkedPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
) || isPreviewExposed(subgraphNode, source)
|
||||
)
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
}
|
||||
|
||||
function toPromotionSource(
|
||||
@@ -221,75 +59,21 @@ function toPromotionSource(
|
||||
): PromotedWidgetSource {
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
|
||||
for (const parent of parents) {
|
||||
parent.computeSize(parent.size)
|
||||
parent.setDirtyCanvas?.(true, true)
|
||||
parent.setDirtyCanvas(true, true)
|
||||
}
|
||||
useCanvasStore().canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
type CanonicalPromotionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
|
||||
|
||||
export function promoteValueWidgetViaSubgraphInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
sourceWidget: IBaseWidget
|
||||
): CanonicalPromotionResult {
|
||||
const sourceWidgetName = getWidgetName(sourceWidget)
|
||||
if (
|
||||
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
|
||||
) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
|
||||
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
|
||||
|
||||
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
|
||||
const inputName = nextUniqueName(sourceWidgetName, existingNames)
|
||||
const subgraphInput = subgraphNode.subgraph.addInput(
|
||||
inputName,
|
||||
String(sourceSlot.type ?? sourceWidget.type ?? '*')
|
||||
)
|
||||
const link = subgraphInput.connect(sourceSlot, sourceNode)
|
||||
if (!link) {
|
||||
subgraphNode.subgraph.removeInput(subgraphInput)
|
||||
return { ok: false, reason: 'connectFailed' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
sourcePreviewName: string
|
||||
): void {
|
||||
const store = usePreviewExposureStore()
|
||||
const rootGraphId = subgraphNode.rootGraph.id
|
||||
const hostLocator = String(subgraphNode.id)
|
||||
const existing = store
|
||||
.getExposures(rootGraphId, hostLocator)
|
||||
.some(
|
||||
(exposure) =>
|
||||
exposure.sourceNodeId === String(sourceNode.id) &&
|
||||
exposure.sourcePreviewName === sourcePreviewName
|
||||
)
|
||||
if (existing) return
|
||||
|
||||
store.addExposure(rootGraphId, hostLocator, {
|
||||
sourceNodeId: String(sourceNode.id),
|
||||
sourcePreviewName
|
||||
})
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
@@ -314,19 +98,10 @@ export function promoteWidget(
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
for (const parent of parents) {
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
promotePreviewViaExposure(
|
||||
parent,
|
||||
node as LGraphNode,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
continue
|
||||
}
|
||||
if ('getSlotFromWidget' in node) {
|
||||
promoteValueWidgetViaSubgraphInput(parent, node as LGraphNode, widget)
|
||||
}
|
||||
store.promote(parent.rootGraph.id, parent.id, source)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
@@ -341,40 +116,10 @@ export function demoteWidget(
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
for (const parent of parents) {
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
const hostLocator = String(parent.id)
|
||||
const exposure = previewStore
|
||||
.getExposures(parent.rootGraph.id, hostLocator)
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.sourceNodeId === source.sourceNodeId &&
|
||||
entry.sourcePreviewName === source.sourceWidgetName
|
||||
)
|
||||
if (exposure) {
|
||||
previewStore.removeExposure(
|
||||
parent.rootGraph.id,
|
||||
hostLocator,
|
||||
exposure.name
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
store.demote(parent.rootGraph.id, parent.id, source)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
@@ -407,10 +152,11 @@ export function addWidgetPromotionOptions(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const parents = getParentNodes()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const promotableParents = parents.filter(
|
||||
(parent) => !isPromotedOnParent(parent, widget, source)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
@@ -443,9 +189,10 @@ export function tryToggleWidgetPromotion() {
|
||||
const widget = node.getWidgetOnPos(x, y, true)
|
||||
const parents = getParentNodes()
|
||||
if (!parents.length || !widget) return
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
const promotableParents = parents.filter(
|
||||
(parent) => !isPromotedOnParent(parent, widget, source)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
@@ -501,6 +248,7 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
}
|
||||
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const store = usePromotionStore()
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
@@ -512,7 +260,14 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
function promotePreviewWidget() {
|
||||
const widget = node.widgets?.find(isPreviewPseudoWidget)
|
||||
if (!widget) return
|
||||
promotePreviewViaExposure(subgraphNode, node, widget.name)
|
||||
if (
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
)
|
||||
return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
// Promote preview widgets that already exist (e.g. custom node DOM widgets
|
||||
// like VHS videopreview that are created in onNodeCreated).
|
||||
@@ -527,7 +282,19 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
// includes this node and onDrawBackground can call updatePreviews on it
|
||||
// once execution outputs arrive.
|
||||
if (supportsVirtualCanvasImagePreview(node)) {
|
||||
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
const canvasSource: PromotedWidgetSource = {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
if (
|
||||
!store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
canvasSource
|
||||
)
|
||||
) {
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -538,42 +305,43 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const filteredWidgets: WidgetItem[] = interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
.filter(isRecommendedWidget)
|
||||
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
|
||||
for (const [n, w] of filteredWidgets) {
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
toPromotionSource(n, w)
|
||||
)
|
||||
}
|
||||
subgraphNode.computeSize(subgraphNode.size)
|
||||
}
|
||||
|
||||
export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const store = usePromotionStore()
|
||||
const subgraph = subgraphNode.subgraph
|
||||
const entries = store.getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const staleInputs = subgraph.inputs.filter((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
|
||||
const node = subgraph.getNodeById(widget.sourceNodeId)
|
||||
const validEntries = entries.filter((entry) => {
|
||||
const node = subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(widget)
|
||||
return true
|
||||
removedEntries.push(entry)
|
||||
return false
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === widget.sourceWidgetName
|
||||
(iw) => iw.name === entry.sourceWidgetName
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(widget)
|
||||
removedEntries.push(entry)
|
||||
}
|
||||
return !hasWidget
|
||||
return hasWidget
|
||||
})
|
||||
|
||||
for (const input of staleInputs) {
|
||||
subgraph.removeInput(input)
|
||||
}
|
||||
|
||||
if (removedEntries.length > 0 && import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[subgraphInputs] Pruned disconnected promoted widget inputs',
|
||||
'[proxyWidgetUtils] Pruned disconnected promotions',
|
||||
removedEntries,
|
||||
{
|
||||
graphId: subgraphNode.rootGraph.id,
|
||||
@@ -582,22 +350,24 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
)
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
refreshPromotedWidgetRendering([subgraphNode])
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const { subgraph } = subgraphNode
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
getPromotableWidgets(interiorNode).some(
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!isPromotedOnParent(subgraphNode, widget, {
|
||||
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
@@ -51,7 +52,8 @@ function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
node?: SubgraphNode,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
@@ -61,6 +63,7 @@ function createPromotedWidget(
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
|
||||
const host = createHostNode(100)
|
||||
const sourceNode = addNodeToHost(host, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
|
||||
]
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(sourceNode.id),
|
||||
'text',
|
||||
'2'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
|
||||
@@ -20,7 +20,8 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
|
||||
function traversePromotedWidgetChain(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
const visited = new Set<string>()
|
||||
const hostUidByObject = new WeakMap<SubgraphNode, number>()
|
||||
@@ -28,6 +29,7 @@ function traversePromotedWidgetChain(
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
let currentSourceNodeId = sourceNodeId
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
let hostUid = hostUidByObject.get(currentHost)
|
||||
@@ -37,7 +39,7 @@ function traversePromotedWidgetChain(
|
||||
hostUidByObject.set(currentHost, hostUid)
|
||||
}
|
||||
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
|
||||
if (visited.has(key)) {
|
||||
return { status: 'failure', failure: 'cycle' }
|
||||
}
|
||||
@@ -50,7 +52,8 @@ function traversePromotedWidgetChain(
|
||||
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
sourceNode.widgets,
|
||||
currentWidgetName
|
||||
currentWidgetName,
|
||||
currentSourceNodeId
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
@@ -70,6 +73,7 @@ function traversePromotedWidgetChain(
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
currentSourceNodeId = undefined
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
@@ -77,20 +81,34 @@ function traversePromotedWidgetChain(
|
||||
|
||||
function findWidgetByIdentity(
|
||||
widgets: IBaseWidget[] | undefined,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
return widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widgets) return undefined
|
||||
|
||||
if (sourceNodeId) {
|
||||
return widgets.find(
|
||||
(entry) =>
|
||||
isPromotedWidgetView(entry) &&
|
||||
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
|
||||
sourceNodeId &&
|
||||
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
return widgets.find((entry) => entry.name === widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName)
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
@@ -99,10 +117,11 @@ export function resolvePromotedWidgetAtHost(
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
if (!hostNode.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'invalid-host' }
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export function resolvePromotedWidgetSource(
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
widget.sourceWidgetName,
|
||||
widget.disambiguatingSourceNodeId
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
|
||||
|
||||
type ResolvedSubgraphInputTarget = {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId?: string
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputTarget(
|
||||
@@ -15,18 +17,29 @@ export function resolveSubgraphInputTarget(
|
||||
node,
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
// ADR 0009: each SubgraphNode is opaque. The promoted target is the
|
||||
// child SubgraphNode's input slot, not a deeper leaf widget.
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.sourceWidgetName,
|
||||
sourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
|
||||
408
src/core/graph/subgraph/subgraphNodePromotion.test.ts
Normal file
408
src/core/graph/subgraph/subgraphNodePromotion.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[], string[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes: LGraphNode[] = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
const innerIds = innerNodes.map((n) => String(n.id))
|
||||
return [subgraphNode, innerNodes, innerIds]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
|
||||
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
// Both views share the widget name; they're distinguished by sourceNodeId
|
||||
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
|
||||
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
|
||||
})
|
||||
test('Will reflect proxyWidgets order changes', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetB')
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
innerNodes[0].widgets[0].last_y = 11
|
||||
innerNodes[0].widgets[0].computedHeight = 12
|
||||
subgraphNode.widgets[0].y = 20
|
||||
subgraphNode.widgets[0].last_y = 21
|
||||
subgraphNode.widgets[0].computedHeight = 22
|
||||
expect(innerNodes[0].widgets[0].y).toBe(10)
|
||||
expect(innerNodes[0].widgets[0].last_y).toBe(11)
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Renders placeholder when interior widget is detached', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
// View resolves the interior widget's type
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
|
||||
// Remove interior widget — view falls back to disconnected state
|
||||
innerNodes[0].widgets.pop()
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
|
||||
// Re-add — view resolves again
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list and view cache', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
const widgetToRemove = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(widgetToRemove)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
|
||||
subgraphNode.removeWidget(widgetA)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
})
|
||||
|
||||
test('removeWidget cleans up input references', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
// Simulate an input referencing the widget
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
subgraphNode.removeWidget(view)
|
||||
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
subgraphNode.graph!.add(subgraphNode)
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
const inputA = nodeA.addInput('shared_input', '*')
|
||||
nodeA.addWidget('text', 'shared_input', 'first', () => {})
|
||||
inputA.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
const inputB = nodeB.addInput('shared_input', '*')
|
||||
nodeB.addWidget('text', 'shared_input', 'second', () => {})
|
||||
inputB.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeB)
|
||||
|
||||
const nodeC = new LGraphNode('NodeC')
|
||||
const inputC = nodeC.addInput('shared_input', '*')
|
||||
nodeC.addWidget('text', 'shared_input', 'third', () => {})
|
||||
inputC.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeC)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(inputA, nodeA)
|
||||
subgraph.inputNode.slots[0].connect(inputB, nodeB)
|
||||
subgraph.inputNode.slots[0].connect(inputC, nodeC)
|
||||
|
||||
const firstRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const secondRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const thirdRead = subgraphNode.widgets.map((w) => w.value)
|
||||
|
||||
expect(firstRead).toStrictEqual(secondRead)
|
||||
expect(secondRead).toStrictEqual(thirdRead)
|
||||
expect(subgraphNode.widgets[0].value).toBe('first')
|
||||
})
|
||||
|
||||
test('3-level nested promotion resolves concrete widget type and value', () => {
|
||||
usePromotionStore()
|
||||
|
||||
// Level C: innermost subgraph with a concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'deep_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('deep_input', '*')
|
||||
concreteNode.addWidget('number', 'deep_input', 42, () => {})
|
||||
concreteInput.widget = { name: 'deep_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
|
||||
|
||||
// Level B: middle subgraph containing C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'mid_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
|
||||
|
||||
// Level A: outermost subgraph containing B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'outer_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
|
||||
|
||||
// Outermost promoted widget should resolve through all 3 levels
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
// Remove: should clean up store AND input reference
|
||||
subgraphNode.removeWidget(view)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(0)
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote: should work correctly after cleanup
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parsePreviewExposures } from './previewExposureSchema'
|
||||
|
||||
describe(parsePreviewExposures, () => {
|
||||
it('parses a valid array of preview exposure objects', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
},
|
||||
{
|
||||
name: 'preview2',
|
||||
sourceNodeId: '7',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parsePreviewExposures(undefined)).toEqual([])
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parsePreviewExposures('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(
|
||||
parsePreviewExposures({
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
).toEqual([])
|
||||
expect(parsePreviewExposures(42)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries are missing required fields', () => {
|
||||
expect(
|
||||
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries have wrong types', () => {
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 123,
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: 5,
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export const previewExposureSchema = z.object({
|
||||
name: z.string(),
|
||||
sourceNodeId: z.string(),
|
||||
sourcePreviewName: z.string()
|
||||
})
|
||||
export type PreviewExposure = z.infer<typeof previewExposureSchema>
|
||||
|
||||
const previewExposuresPropertySchema = z.array(previewExposureSchema)
|
||||
|
||||
export function parsePreviewExposures(
|
||||
property: NodeProperty | undefined
|
||||
): PreviewExposure[] {
|
||||
if (property === undefined) return []
|
||||
|
||||
try {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = previewExposuresPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.previewExposures:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.previewExposures:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -3,14 +3,11 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export const serializedProxyWidgetTupleSchema = z.union([
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
])
|
||||
export type SerializedProxyWidgetTuple = z.infer<
|
||||
typeof serializedProxyWidgetTupleSchema
|
||||
>
|
||||
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
|
||||
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
|
||||
|
||||
const baseEntry = {
|
||||
originalEntry: ['10', 'seed'] as [string, string],
|
||||
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
|
||||
attemptedAtVersion: 1 as const
|
||||
}
|
||||
|
||||
describe(parseProxyWidgetErrorQuarantine, () => {
|
||||
it('parses a valid entry without hostValue', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine([baseEntry])).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('parses a valid entry with hostValue', () => {
|
||||
const entry = { ...baseEntry, hostValue: 42 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 2-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['10', 'seed'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 3-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['3', 'text', '1'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
] as const)('parses reason %s', (reason) => {
|
||||
const entry = { ...baseEntry, reason }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = JSON.stringify([baseEntry])
|
||||
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine(baseEntry)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when attemptedAtVersion is not 1', () => {
|
||||
const entry = { ...baseEntry, attemptedAtVersion: 2 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when reason is not in the enum', () => {
|
||||
const entry = { ...baseEntry, reason: 'somethingElse' }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when originalEntry is malformed', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['only-one'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
|
||||
|
||||
export const proxyWidgetQuarantineReasonSchema = z.enum([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
])
|
||||
export type ProxyWidgetQuarantineReason = z.infer<
|
||||
typeof proxyWidgetQuarantineReasonSchema
|
||||
>
|
||||
|
||||
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
|
||||
originalEntry: serializedProxyWidgetTupleSchema,
|
||||
reason: proxyWidgetQuarantineReasonSchema,
|
||||
hostValue: z.unknown().optional(),
|
||||
attemptedAtVersion: z.literal(1)
|
||||
})
|
||||
|
||||
const proxyWidgetErrorQuarantinePropertySchema = z.array(
|
||||
proxyWidgetErrorQuarantineEntrySchema
|
||||
)
|
||||
|
||||
export type ProxyWidgetErrorQuarantineEntry = Omit<
|
||||
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
|
||||
'hostValue'
|
||||
> & { hostValue?: TWidgetValue }
|
||||
|
||||
export function parseProxyWidgetErrorQuarantine(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
if (property === undefined) return []
|
||||
|
||||
try {
|
||||
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
39
src/extension-api-v2/__tests__/bc-01.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-01.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 nodeCreated(node) + beforeRegisterNodeDef → v2 defineNodeExtension({ nodeCreated(handle) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 migration — node lifecycle: creation', () => {
|
||||
describe('nodeCreated parity (S2.N1)', () => {
|
||||
it.todo(
|
||||
'v1 nodeCreated and v2 nodeCreated are both invoked the same number of times when N nodes are created'
|
||||
)
|
||||
it.todo(
|
||||
'side-effects applied to the node in v1 nodeCreated(node) are reproducible via NodeHandle methods in v2'
|
||||
)
|
||||
it.todo(
|
||||
'v2 nodeCreated fires in the same relative order as v1 for extensions registered in the same order'
|
||||
)
|
||||
})
|
||||
|
||||
describe('beforeRegisterNodeDef → type-scoped defineNodeExtension (S2.N8)', () => {
|
||||
it.todo(
|
||||
'prototype mutation applied in v1 beforeRegisterNodeDef produces the same per-instance behavior as v2 type-scoped nodeCreated'
|
||||
)
|
||||
it.todo(
|
||||
'v2 type-scoped extension does not affect node types that were excluded, matching v1 type-guard behavior'
|
||||
)
|
||||
})
|
||||
|
||||
describe('VueNode mount timing invariant', () => {
|
||||
it.todo(
|
||||
'both v1 and v2 nodeCreated fire before VueNode mounts — extensions relying on this ordering do not need changes'
|
||||
)
|
||||
it.todo(
|
||||
'extensions that deferred DOM work to a callback in v1 can use onNodeMounted in v2 for the same guarantee'
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/extension-api-v2/__tests__/bc-01.v1.test.ts
Normal file
45
src/extension-api-v2/__tests__/bc-01.v1.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// Surface: S2.N1 = nodeCreated hook, S2.N8 = beforeRegisterNodeDef
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: app.registerExtension({ nodeCreated(node) { ... } })
|
||||
// Note: nodeCreated fires BEFORE the VueNode Vue component mounts; extensions needing
|
||||
// VueNode-backed state must defer (see BC.37).
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 v1 contract — node lifecycle: creation', () => {
|
||||
describe('S2.N1 — nodeCreated hook', () => {
|
||||
it.todo(
|
||||
'nodeCreated is called once per node instance immediately after the node is constructed'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated receives the LGraphNode instance as its first argument'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before the node is added to the graph (graph.nodes does not yet contain the node)'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before the VueNode Vue component is mounted (vm.$el is null at call time)'
|
||||
)
|
||||
it.todo(
|
||||
'properties set on node inside nodeCreated are accessible in subsequent lifecycle hooks'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N8 — beforeRegisterNodeDef hook', () => {
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef is called once per node type before the type is registered in the node registry'
|
||||
)
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef receives the node constructor and the raw node definition object'
|
||||
)
|
||||
it.todo(
|
||||
'prototype mutations made in beforeRegisterNodeDef affect all subsequently created instances of that type'
|
||||
)
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef is NOT called again on graph reload if the type is already registered'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-01.v2.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-01.v2.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Category: BC.01 — Node lifecycle: creation
|
||||
// DB cross-ref: S2.N1, S2.N8
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
// compat-floor: blast_radius 4.48 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ nodeCreated(handle) { ... } })
|
||||
// Note: v2 nodeCreated receives a NodeHandle, not a raw LGraphNode. VueNode mount
|
||||
// timing guarantee is unchanged — defer to onNodeMounted for Vue-backed state.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.01 v2 contract — node lifecycle: creation', () => {
|
||||
describe('nodeCreated(handle) — per-instance setup', () => {
|
||||
it.todo(
|
||||
'nodeCreated is called once per node instance and receives a NodeHandle wrapping the created node'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.id is stable and matches the underlying LGraphNode id at call time'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.type returns the registered node type string'
|
||||
)
|
||||
it.todo(
|
||||
'state stored via NodeHandle.setState() inside nodeCreated is retrievable in subsequent hooks for the same instance'
|
||||
)
|
||||
it.todo(
|
||||
'nodeCreated fires before VueNode mounts; accessing NodeHandle.vueRef inside nodeCreated returns null'
|
||||
)
|
||||
})
|
||||
|
||||
describe('type-level registration (replacement for S2.N8)', () => {
|
||||
it.todo(
|
||||
'defineNodeExtension({ types: [\"MyNode\"] }) scopes nodeCreated to only instances of the listed types'
|
||||
)
|
||||
it.todo(
|
||||
'omitting types: causes nodeCreated to fire for every node type (global registration)'
|
||||
)
|
||||
it.todo(
|
||||
'type-scoped registration does not receive nodeCreated calls for unregistered node types'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-02.migration.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-02.migration.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onRemoved assignment → v2 defineNodeExtension({ onRemoved(handle) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.02 migration — node lifecycle: teardown', () => {
|
||||
describe('invocation parity (S2.N4)', () => {
|
||||
it.todo(
|
||||
'v1 onRemoved and v2 onRemoved are both called the same number of times for the same sequence of node removals'
|
||||
)
|
||||
it.todo(
|
||||
'v2 onRemoved fires at the same point in the removal lifecycle as v1 (after node is detached from graph)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('resource cleanup equivalence', () => {
|
||||
it.todo(
|
||||
'intervals cleared in v1 onRemoved are equally suppressible via NodeHandle.onDispose() in v2 without manual tracking'
|
||||
)
|
||||
it.todo(
|
||||
'DOM elements removed manually in v1 onRemoved are automatically removed by v2 auto-disposal when registered via addDOMWidget()'
|
||||
)
|
||||
it.todo(
|
||||
'observer.disconnect() patterns in v1 can be replaced by NodeHandle.onDispose(() => observer.disconnect()) in v2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('graph clear coverage', () => {
|
||||
it.todo(
|
||||
'both v1 and v2 teardown hooks are invoked for all nodes when graph.clear() is called'
|
||||
)
|
||||
})
|
||||
})
|
||||
135
src/extension-api-v2/__tests__/bc-02.v1.test.ts
Normal file
135
src/extension-api-v2/__tests__/bc-02.v1.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// Surface: S2.N4 = node.onRemoved
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.onRemoved = function() { /* cleanup DOM, intervals, observers */ }
|
||||
//
|
||||
// I-TF.3.C3 — proof-of-concept harness wiring.
|
||||
// Phase A harness limitation: MiniGraph.remove() deletes the entity from the World
|
||||
// but does NOT automatically call onRemoved (that requires Phase B eval sandbox +
|
||||
// LiteGraph prototype wiring). The wired tests below call onRemoved explicitly after
|
||||
// graph.remove() to prove the harness mechanics and assertion patterns work.
|
||||
// The TODO stubs below them track what needs Phase B to become real assertions.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
countEvidenceExcerpts,
|
||||
createHarnessWorld,
|
||||
createMiniComfyApp,
|
||||
loadEvidenceSnippet
|
||||
} from '../harness'
|
||||
|
||||
// ── Proof-of-concept wired tests (I-TF.3.C3) ────────────────────────────────
|
||||
// These pass today. They prove: (a) the harness can model the v1 teardown
|
||||
// pattern, (b) removal is reflected in the World, (c) the cleanup callback
|
||||
// fires when the extension calls it, (d) evidence excerpts load for S2.N4.
|
||||
|
||||
describe('BC.02 v1 contract — node lifecycle: teardown [harness POC]', () => {
|
||||
describe('S2.N4 — onRemoved harness mechanics', () => {
|
||||
it('cleanup callback fires when extension calls it after graph.remove()', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
// v1 pattern: extension patches onRemoved on the node during nodeCreated.
|
||||
// We model this as a plain function stored on a node-shaped object.
|
||||
const cleanupFn = vi.fn()
|
||||
const node = {
|
||||
type: 'LTXVideo',
|
||||
entityId: app.graph.add({ type: 'LTXVideo' }),
|
||||
onRemoved: cleanupFn
|
||||
}
|
||||
|
||||
expect(world.findNode(node.entityId)).toBeDefined()
|
||||
|
||||
// Simulate the LiteGraph removal sequence (Phase A: explicit call).
|
||||
app.graph.remove(node.entityId)
|
||||
node.onRemoved()
|
||||
|
||||
expect(world.findNode(node.entityId)).toBeUndefined()
|
||||
expect(cleanupFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleanup callback does not fire if remove is never called', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
const cleanupFn = vi.fn()
|
||||
const entityId = app.graph.add({ type: 'KSampler' })
|
||||
|
||||
// Node exists; no removal; callback should not have been invoked.
|
||||
void entityId
|
||||
expect(cleanupFn).not.toHaveBeenCalled()
|
||||
expect(world.allNodes()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('multiple nodes — each removal triggers only its own callback', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
const cbA = vi.fn()
|
||||
const cbB = vi.fn()
|
||||
const idA = app.graph.add({ type: 'NodeA' })
|
||||
const idB = app.graph.add({ type: 'NodeB' })
|
||||
|
||||
// Remove only A.
|
||||
app.graph.remove(idA)
|
||||
cbA() // simulate LiteGraph calling onRemoved on the removed node only
|
||||
|
||||
expect(cbA).toHaveBeenCalledOnce()
|
||||
expect(cbB).not.toHaveBeenCalled()
|
||||
expect(world.findNode(idA)).toBeUndefined()
|
||||
expect(world.findNode(idB)).toBeDefined()
|
||||
})
|
||||
|
||||
it('graph.clear() removes all nodes from the World', () => {
|
||||
const world = createHarnessWorld()
|
||||
const app = createMiniComfyApp(world)
|
||||
|
||||
app.graph.add({ type: 'NodeA' })
|
||||
app.graph.add({ type: 'NodeB' })
|
||||
app.graph.add({ type: 'NodeC' })
|
||||
expect(world.allNodes()).toHaveLength(3)
|
||||
|
||||
world.clear()
|
||||
expect(world.allNodes()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('S2.N4 — evidence excerpt (loadEvidenceSnippet)', () => {
|
||||
it('S2.N4 has at least one evidence excerpt in the snapshot', () => {
|
||||
expect(countEvidenceExcerpts('S2.N4')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('S2.N4 excerpt contains onRemoved fingerprint', () => {
|
||||
const snippet = loadEvidenceSnippet('S2.N4', 0)
|
||||
expect(snippet.length).toBeGreaterThan(0)
|
||||
expect(snippet).toMatch(/onRemoved/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Phase B stubs — need eval sandbox + LiteGraph prototype wiring ───────────
|
||||
|
||||
describe('BC.02 v1 contract — node lifecycle: teardown [Phase B]', () => {
|
||||
describe('S2.N4 — node.onRemoved', () => {
|
||||
it.todo(
|
||||
'onRemoved is called exactly once when a node is removed from the graph via graph.remove(node)'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called when a node is deleted via the canvas context-menu delete action'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called for every node when the graph is cleared (graph.clear())'
|
||||
)
|
||||
it.todo(
|
||||
'DOM widgets appended by the extension are accessible for cleanup inside onRemoved (not yet garbage-collected)'
|
||||
)
|
||||
it.todo(
|
||||
'setInterval / requestAnimationFrame handles stored on the node instance can be cancelled inside onRemoved'
|
||||
)
|
||||
it.todo(
|
||||
'MutationObserver and ResizeObserver instances stored on the node can be disconnected inside onRemoved'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-02.v2.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-02.v2.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.02 — Node lifecycle: teardown
|
||||
// DB cross-ref: S2.N4
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
// compat-floor: blast_radius 5.20 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ onRemoved(handle) { ... } })
|
||||
// Note: v2 onRemoved runs inside the NodeHandle scope; extension-owned resources
|
||||
// registered via handle APIs are auto-disposed before onRemoved fires.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.02 v2 contract — node lifecycle: teardown', () => {
|
||||
describe('onRemoved(handle) — cleanup hook', () => {
|
||||
it.todo(
|
||||
'onRemoved is called exactly once per node instance when the node is removed from the graph'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved receives the same NodeHandle that was passed to nodeCreated for the same instance'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle.getState() is still readable inside onRemoved (state not yet cleared)'
|
||||
)
|
||||
it.todo(
|
||||
'onRemoved is called for every node when the graph is cleared, in no guaranteed order'
|
||||
)
|
||||
})
|
||||
|
||||
describe('auto-disposal of handle-registered resources', () => {
|
||||
it.todo(
|
||||
'DOM widgets registered via NodeHandle.addDOMWidget() are removed from the DOM before onRemoved fires'
|
||||
)
|
||||
it.todo(
|
||||
'cleanup functions registered via NodeHandle.onDispose() are invoked before onRemoved fires'
|
||||
)
|
||||
it.todo(
|
||||
'extension can still perform additional teardown in onRemoved after auto-disposal completes'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-03.migration.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-03.migration.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onConfigure / beforeRegisterNodeDef → v2 defineNodeExtension({ onConfigure(handle, data) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 migration — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('onConfigure parity (S2.N7)', () => {
|
||||
it.todo(
|
||||
'v1 node.onConfigure and v2 onConfigure are both called exactly once per node during workflow load'
|
||||
)
|
||||
it.todo(
|
||||
'the serialized data object received in v2 onConfigure contains the same fields as in v1'
|
||||
)
|
||||
it.todo(
|
||||
'custom property restoration logic written for v1 onConfigure is portable to v2 with only handle substitution'
|
||||
)
|
||||
})
|
||||
|
||||
describe('beforeRegisterNodeDef hydration guard → type-scoped extension (S1.H1)', () => {
|
||||
it.todo(
|
||||
'prototype-level onConfigure injected via v1 beforeRegisterNodeDef produces the same hydration result as a v2 type-scoped onConfigure'
|
||||
)
|
||||
it.todo(
|
||||
'v2 type-scoped onConfigure does not fire for node types not listed in types:, matching v1 guard behavior'
|
||||
)
|
||||
})
|
||||
|
||||
describe('fresh-creation exclusion invariant', () => {
|
||||
it.todo(
|
||||
'neither v1 nor v2 onConfigure fires when a node is created fresh (not from a saved workflow)'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-03.v1.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-03.v1.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// Surface: S1.H1 = beforeRegisterNodeDef (used for hydration guards), S2.N7 = node.onConfigure
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: S1.H1 = beforeRegisterNodeDef guard; S2.N7 = node.onConfigure = function(data) { ... }
|
||||
// Note: loadedGraphNode hook exists in LiteGraph but is effectively unused in ComfyUI —
|
||||
// onConfigure is the de-facto hydration surface.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 v1 contract — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('S2.N7 — node.onConfigure', () => {
|
||||
it.todo(
|
||||
'onConfigure is called when a saved workflow is loaded and the node is rehydrated from serialized data'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure receives the raw serialized node object (data) as its first argument'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure is NOT called on freshly created nodes (only on deserialization)'
|
||||
)
|
||||
it.todo(
|
||||
'widget values written to data inside a prior session are accessible via data.widgets_values in onConfigure'
|
||||
)
|
||||
it.todo(
|
||||
'extensions can restore custom properties stored in data.properties inside onConfigure'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S1.H1 — beforeRegisterNodeDef hydration guard', () => {
|
||||
it.todo(
|
||||
'beforeRegisterNodeDef can inject a custom onConfigure override on the node prototype before any instance is created'
|
||||
)
|
||||
it.todo(
|
||||
'prototype-level onConfigure injected in beforeRegisterNodeDef is invoked for all instances during workflow load'
|
||||
)
|
||||
})
|
||||
})
|
||||
36
src/extension-api-v2/__tests__/bc-03.v2.test.ts
Normal file
36
src/extension-api-v2/__tests__/bc-03.v2.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Category: BC.03 — Node lifecycle: hydration from saved workflows
|
||||
// DB cross-ref: S1.H1, S2.N7
|
||||
// Exemplar: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
// compat-floor: blast_radius 4.91 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ onConfigure(handle, data) { ... } })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.03 v2 contract — node lifecycle: hydration from saved workflows', () => {
|
||||
describe('onConfigure(handle, data) — workflow hydration hook', () => {
|
||||
it.todo(
|
||||
'onConfigure is called when a node is rehydrated from a saved workflow and NOT on fresh node creation'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure receives the NodeHandle as first argument and the raw serialized node object as second argument'
|
||||
)
|
||||
it.todo(
|
||||
'data passed to onConfigure contains widgets_values from the saved workflow'
|
||||
)
|
||||
it.todo(
|
||||
'data passed to onConfigure contains properties from the saved workflow'
|
||||
)
|
||||
it.todo(
|
||||
'state written to NodeHandle inside onConfigure is readable in all subsequent hook calls for that instance'
|
||||
)
|
||||
})
|
||||
|
||||
describe('ordering and idempotency guarantees', () => {
|
||||
it.todo(
|
||||
'onConfigure fires after nodeCreated for the same instance during workflow load'
|
||||
)
|
||||
it.todo(
|
||||
'onConfigure is not called a second time if the same node receives a re-configure (idempotent load)'
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/extension-api-v2/__tests__/bc-04.migration.test.ts
Normal file
45
src/extension-api-v2/__tests__/bc-04.migration.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Category: BC.04 — Node interaction: pointer, selection, resize
|
||||
// DB cross-ref: S2.N10, S2.N17, S2.N19
|
||||
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
|
||||
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onMouseDown/onSelected/onResize → v2 handle.on('mousedown'|'selected'|'resize', ...)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.04 migration — node interaction: pointer, selection, resize', () => {
|
||||
describe('mousedown parity (S2.N10)', () => {
|
||||
it.todo(
|
||||
'v1 node.onMouseDown and v2 handle.on("mousedown") are both invoked for the same pointer-down events'
|
||||
)
|
||||
it.todo(
|
||||
'propagation-stop by returning true in v1 is equivalent to event.stopPropagation() in v2 handler'
|
||||
)
|
||||
it.todo(
|
||||
'local coordinates passed to v1 onMouseDown match the x/y in the v2 event object for the same input'
|
||||
)
|
||||
})
|
||||
|
||||
describe('selection parity (S2.N17)', () => {
|
||||
it.todo(
|
||||
'v1 node.onSelected and v2 handle.on("selected") are both invoked when the node is selected'
|
||||
)
|
||||
it.todo(
|
||||
'v2 introduces an explicit deselected event absent in v1; migration must add deselected handler for cleanup that relied on onSelected re-fire'
|
||||
)
|
||||
})
|
||||
|
||||
describe('resize parity (S2.N19)', () => {
|
||||
it.todo(
|
||||
'v1 node.onResize([w,h]) and v2 handle.on("resize", { width, height }) convey the same dimensions for the same resize action'
|
||||
)
|
||||
it.todo(
|
||||
'computeSize overrides that triggered onResize in v1 still trigger the resize event in v2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('listener lifetime', () => {
|
||||
it.todo(
|
||||
'v1 listeners on removed nodes remain registered (leak); v2 handle.on() listeners are auto-removed on node removal'
|
||||
)
|
||||
})
|
||||
})
|
||||
49
src/extension-api-v2/__tests__/bc-04.v1.test.ts
Normal file
49
src/extension-api-v2/__tests__/bc-04.v1.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Category: BC.04 — Node interaction: pointer, selection, resize
|
||||
// DB cross-ref: S2.N10, S2.N17, S2.N19
|
||||
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
|
||||
// Surface: S2.N10 = node.onMouseDown, S2.N17 = node.onSelected, S2.N19 = node.onResize
|
||||
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.onMouseDown, node.onSelected, node.onResize prototype method assignments
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.04 v1 contract — node interaction: pointer, selection, resize', () => {
|
||||
describe('S2.N10 — node.onMouseDown', () => {
|
||||
it.todo(
|
||||
'onMouseDown is called when a pointer-down event occurs within the node bounding box on the canvas'
|
||||
)
|
||||
it.todo(
|
||||
'onMouseDown receives the MouseEvent and the local [x, y] position within the node as arguments'
|
||||
)
|
||||
it.todo(
|
||||
'returning true from onMouseDown stops propagation to LiteGraph default mouse handling'
|
||||
)
|
||||
it.todo(
|
||||
'onMouseDown is NOT called when the pointer down is outside the node bounding box'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N17 — node.onSelected', () => {
|
||||
it.todo(
|
||||
'onSelected is called when the node transitions to selected state (single-click or box-select)'
|
||||
)
|
||||
it.todo(
|
||||
'onSelected is called once per selection event even if the node was already selected'
|
||||
)
|
||||
it.todo(
|
||||
'onSelected is not called when a different node is selected and this node is deselected'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N19 — node.onResize', () => {
|
||||
it.todo(
|
||||
'onResize is called after the node dimensions change (user drag-resize or programmatic setSize)'
|
||||
)
|
||||
it.todo(
|
||||
'onResize receives the new [width, height] array as its argument'
|
||||
)
|
||||
it.todo(
|
||||
'onResize is called after the node size is committed, not during the drag'
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/extension-api-v2/__tests__/bc-04.v2.test.ts
Normal file
48
src/extension-api-v2/__tests__/bc-04.v2.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Category: BC.04 — Node interaction: pointer, selection, resize
|
||||
// DB cross-ref: S2.N10, S2.N17, S2.N19
|
||||
// Exemplar: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
|
||||
// compat-floor: blast_radius 4.95 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: defineNodeExtension({ on('mousedown', ...), on('selected', ...), on('resize', ...) })
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.04 v2 contract — node interaction: pointer, selection, resize', () => {
|
||||
describe('on(\"mousedown\", handler) — pointer events (S2.N10)', () => {
|
||||
it.todo(
|
||||
'handle.on("mousedown", handler) registers a listener called when pointer-down occurs within the node bounding box'
|
||||
)
|
||||
it.todo(
|
||||
'handler receives an event object with local x/y coordinates relative to the node origin'
|
||||
)
|
||||
it.todo(
|
||||
'handler returning true stops propagation to LiteGraph default mouse handling'
|
||||
)
|
||||
it.todo(
|
||||
'listener registered via handle.on() is automatically removed when the node is removed from the graph'
|
||||
)
|
||||
})
|
||||
|
||||
describe('on(\"selected\", handler) — selection focus (S2.N17)', () => {
|
||||
it.todo(
|
||||
'handle.on("selected", handler) is called when the node enters selected state'
|
||||
)
|
||||
it.todo(
|
||||
'handle.on("deselected", handler) is called when the node exits selected state'
|
||||
)
|
||||
it.todo(
|
||||
'selected and deselected events do not fire during programmatic selection with { silent: true } option'
|
||||
)
|
||||
})
|
||||
|
||||
describe('on(\"resize\", handler) — resize feedback (S2.N19)', () => {
|
||||
it.todo(
|
||||
'handle.on("resize", handler) is called after the node dimensions change'
|
||||
)
|
||||
it.todo(
|
||||
'handler receives a { width, height } object matching the new node size'
|
||||
)
|
||||
it.todo(
|
||||
'resize event fires for both user drag-resize and programmatic NodeHandle.setSize() calls'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-05.migration.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-05.migration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Category: BC.05 — Custom DOM widgets and node sizing
|
||||
// DB cross-ref: S4.W2, S2.N11
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
|
||||
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.addDOMWidget + node.computeSize → v2 NodeHandle.addDOMWidget + WidgetHandle.setHeight
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.05 migration — custom DOM widgets and node sizing', () => {
|
||||
describe('widget registration parity (S4.W2)', () => {
|
||||
it.todo(
|
||||
'v1 node.addDOMWidget and v2 NodeHandle.addDOMWidget both result in the element being visible inside the node widget area'
|
||||
)
|
||||
it.todo(
|
||||
'the widget is accessible by name in both v1 node.widgets and v2 NodeHandle.widgets after registration'
|
||||
)
|
||||
it.todo(
|
||||
'v1 opts.getHeight() returning N produces the same reserved height as v2 addDOMWidget({ height: N })'
|
||||
)
|
||||
})
|
||||
|
||||
describe('computeSize elimination (S2.N11)', () => {
|
||||
it.todo(
|
||||
'v1 manual computeSize override is unnecessary in v2; equivalent height reservation is achieved via WidgetHandle.setHeight()'
|
||||
)
|
||||
it.todo(
|
||||
'node rendered with v2 auto-computeSize integration has the same final dimensions as v1 with an equivalent manual computeSize override'
|
||||
)
|
||||
})
|
||||
|
||||
describe('cleanup parity', () => {
|
||||
it.todo(
|
||||
'v1 requires manual DOM removal in onRemoved; v2 auto-removes the widget element — both result in the element being absent after node removal'
|
||||
)
|
||||
it.todo(
|
||||
'v2 auto-cleanup does not remove DOM elements that were not registered via addDOMWidget, matching v1 scoping'
|
||||
)
|
||||
})
|
||||
})
|
||||
43
src/extension-api-v2/__tests__/bc-05.v1.test.ts
Normal file
43
src/extension-api-v2/__tests__/bc-05.v1.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Category: BC.05 — Custom DOM widgets and node sizing
|
||||
// DB cross-ref: S4.W2, S2.N11
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
|
||||
// Surface: S4.W2 = node.addDOMWidget, S2.N11 = node.computeSize override
|
||||
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.addDOMWidget(name, type, element, opts) + node.computeSize = function(out) { ... }
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.05 v1 contract — custom DOM widgets and node sizing', () => {
|
||||
describe('S4.W2 — node.addDOMWidget', () => {
|
||||
it.todo(
|
||||
'addDOMWidget(name, type, element, opts) appends the provided DOM element inside the node widget area'
|
||||
)
|
||||
it.todo(
|
||||
'widget registered via addDOMWidget is accessible via node.widgets array by the given name'
|
||||
)
|
||||
it.todo(
|
||||
'addDOMWidget opts.getHeight() is called during layout to determine the widget reserved height'
|
||||
)
|
||||
it.todo(
|
||||
'addDOMWidget opts.onDraw(ctx) callback is invoked during each canvas render pass'
|
||||
)
|
||||
it.todo(
|
||||
'the DOM element is removed from the document when the node is removed via graph.remove()'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N11 — node.computeSize override', () => {
|
||||
it.todo(
|
||||
'assigning node.computeSize = function(out) { ... } overrides the default size calculation for the node'
|
||||
)
|
||||
it.todo(
|
||||
'overridden computeSize is called by LiteGraph layout engine before rendering'
|
||||
)
|
||||
it.todo(
|
||||
'computeSize can return a [width, height] pair that accounts for the DOM widget reserved height'
|
||||
)
|
||||
it.todo(
|
||||
'computeSize override persists across graph load/reload if set in nodeCreated or beforeRegisterNodeDef'
|
||||
)
|
||||
})
|
||||
})
|
||||
39
src/extension-api-v2/__tests__/bc-05.v2.test.ts
Normal file
39
src/extension-api-v2/__tests__/bc-05.v2.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Category: BC.05 — Custom DOM widgets and node sizing
|
||||
// DB cross-ref: S4.W2, S2.N11
|
||||
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
|
||||
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: NodeHandle.addDOMWidget(opts) — auto-hooks computeSize via WidgetHandle geometry
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
|
||||
describe('NodeHandle.addDOMWidget(opts) — widget registration', () => {
|
||||
it.todo(
|
||||
'NodeHandle.addDOMWidget({ name, element }) appends the element inside the node widget area'
|
||||
)
|
||||
it.todo(
|
||||
'addDOMWidget returns a WidgetHandle that exposes the registered widget for further configuration'
|
||||
)
|
||||
it.todo(
|
||||
'widget registered via addDOMWidget is included in NodeHandle.widgets list under opts.name'
|
||||
)
|
||||
it.todo(
|
||||
'addDOMWidget({ name, element, height }) reserves the specified height without requiring a manual computeSize override'
|
||||
)
|
||||
it.todo(
|
||||
'the DOM element is removed from the document automatically when the node is removed (no manual cleanup)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('WidgetHandle geometry — auto-computeSize integration (S2.N11)', () => {
|
||||
it.todo(
|
||||
'WidgetHandle.setHeight(px) updates the reserved height and triggers a node relayout without a manual computeSize call'
|
||||
)
|
||||
it.todo(
|
||||
'when multiple DOM widgets are registered, the total node height accounts for all widget heights'
|
||||
)
|
||||
it.todo(
|
||||
'calling WidgetHandle.setHeight() after initial mount correctly re-lays out the node on next render frame'
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/extension-api-v2/__tests__/bc-06.migration.test.ts
Normal file
40
src/extension-api-v2/__tests__/bc-06.migration.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
|
||||
// DB cross-ref: S2.N9, S3.C1, S3.C2
|
||||
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
|
||||
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
|
||||
// Migration: v1 node.onDrawForeground → v2 NodeHandle.onDraw (partial).
|
||||
// S3.C1 / S3.C2 canvas-level overrides: no v2 migration path yet (D9 Phase C).
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.06 migration — custom canvas drawing (per-node and canvas-level)', () => {
|
||||
describe('per-node drawing migration (S2.N9)', () => {
|
||||
it.todo(
|
||||
'v1 node.onDrawForeground and v2 NodeHandle.onDraw both produce visually equivalent output on the canvas for the same drawing operations'
|
||||
)
|
||||
it.todo(
|
||||
'draw callback in v2 fires the same number of times per second as v1 onDrawForeground for a static scene'
|
||||
)
|
||||
it.todo(
|
||||
'v2 DrawContext.ctx is the same CanvasRenderingContext2D state as v1 receives (same transform, same clip)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('auto-deregistration vs manual cleanup', () => {
|
||||
it.todo(
|
||||
'v1 onDrawForeground continues to fire after node removal if the reference is not cleared (leak); v2 onDraw is auto-removed'
|
||||
)
|
||||
it.todo(
|
||||
'v2 auto-deregistration on node removal does not affect onDraw callbacks registered for other nodes'
|
||||
)
|
||||
})
|
||||
|
||||
describe('canvas-level override coexistence (S3.C1, S3.C2)', () => {
|
||||
it.todo(
|
||||
'extensions that replace LGraphCanvas.prototype methods in v1 continue to function alongside v2 NodeHandle.onDraw registrations without conflict'
|
||||
)
|
||||
it.todo(
|
||||
'processContextMenu replacement in v1 is not disrupted by extensions migrated to v2 per-node APIs'
|
||||
)
|
||||
})
|
||||
})
|
||||
55
src/extension-api-v2/__tests__/bc-06.v1.test.ts
Normal file
55
src/extension-api-v2/__tests__/bc-06.v1.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
|
||||
// DB cross-ref: S2.N9, S3.C1, S3.C2
|
||||
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
|
||||
// Surface: S2.N9 = node.onDrawForeground, S3.C1 = LGraphCanvas.prototype overrides, S3.C2 = ContextMenu replacement
|
||||
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.onDrawForeground(ctx, area), LGraphCanvas.prototype.processContextMenu = ...,
|
||||
// LGraphCanvas.prototype.drawNodeShape = ... etc.
|
||||
// v1_scope_note: Simon Tranter (COM-3668) vetoed canvas drawing overrides as "too hacky/specific".
|
||||
// S3.C* patterns tracked for blast-radius / strangler-fig planning only.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.06 v1 contract — custom canvas drawing (per-node and canvas-level)', () => {
|
||||
describe('S2.N9 — node.onDrawForeground', () => {
|
||||
it.todo(
|
||||
'onDrawForeground(ctx, visibleArea) is called once per render frame for each visible node'
|
||||
)
|
||||
it.todo(
|
||||
'ctx passed to onDrawForeground is the same CanvasRenderingContext2D used by LiteGraph for the node layer'
|
||||
)
|
||||
it.todo(
|
||||
'drawing operations performed in onDrawForeground appear above the node body and below the selection highlight'
|
||||
)
|
||||
it.todo(
|
||||
'onDrawForeground is NOT called for nodes outside the visible area (culled by LiteGraph)'
|
||||
)
|
||||
it.todo(
|
||||
'canvas transform (scale, translate) is already applied when onDrawForeground fires — coordinates are in graph space'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S3.C1 — LGraphCanvas.prototype method overrides', () => {
|
||||
it.todo(
|
||||
'assigning LGraphCanvas.prototype.drawNodeShape replaces the built-in node shape renderer for all nodes'
|
||||
)
|
||||
it.todo(
|
||||
'prototype override affects all canvas instances sharing the same prototype (global side-effect)'
|
||||
)
|
||||
it.todo(
|
||||
'two extensions both overriding the same LGraphCanvas.prototype method result in last-writer-wins behavior'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S3.C2 — ContextMenu global replacement', () => {
|
||||
it.todo(
|
||||
'reassigning LGraphCanvas.prototype.processContextMenu replaces the context-menu handler for every right-click on the canvas'
|
||||
)
|
||||
it.todo(
|
||||
'extensions replacing processContextMenu must call the original to preserve built-in menu items'
|
||||
)
|
||||
it.todo(
|
||||
'replacing processContextMenu is the most destructive canvas-level override — absence of original call silently drops all built-in menu entries'
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/extension-api-v2/__tests__/bc-06.v2.test.ts
Normal file
41
src/extension-api-v2/__tests__/bc-06.v2.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Category: BC.06 — Custom canvas drawing (per-node and canvas-level)
|
||||
// DB cross-ref: S2.N9, S3.C1, S3.C2
|
||||
// Exemplar: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
|
||||
// compat-floor: blast_radius 5.25 ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: NodeHandle.onDraw(callback) for per-node drawing (S2.N9).
|
||||
// Canvas-level overrides (S3.C1, S3.C2) are OUT OF v2 SCOPE — deferred to D9 Phase C.
|
||||
// S3.C* stubs present for blast-radius tracking and strangler-fig planning.
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.06 v2 contract — custom canvas drawing (per-node and canvas-level)', () => {
|
||||
describe('NodeHandle.onDraw(callback) — per-node foreground drawing (S2.N9)', () => {
|
||||
it.todo(
|
||||
'NodeHandle.onDraw(cb) registers cb to be called once per render frame while the node is visible'
|
||||
)
|
||||
it.todo(
|
||||
'callback receives a DrawContext with ctx (CanvasRenderingContext2D) and area (bounding rect) arguments'
|
||||
)
|
||||
it.todo(
|
||||
'drawing operations in the callback appear in the same layer as v1 onDrawForeground (above node body)'
|
||||
)
|
||||
it.todo(
|
||||
'the canvas transform is pre-applied when the callback fires — coordinates are in graph space, matching v1 behavior'
|
||||
)
|
||||
it.todo(
|
||||
'callback registered via NodeHandle.onDraw() is automatically deregistered when the node is removed'
|
||||
)
|
||||
})
|
||||
|
||||
describe('canvas-level overrides — deferred (S3.C1, S3.C2)', () => {
|
||||
it.todo(
|
||||
'[D9 Phase C] v2 exposes no stable API for replacing LGraphCanvas.prototype.drawNodeShape — extensions using this pattern must remain on v1 shim'
|
||||
)
|
||||
it.todo(
|
||||
'[D9 Phase C] v2 exposes no stable API for replacing processContextMenu — context-menu customization is deferred to the ComfyUI menu extension point'
|
||||
)
|
||||
it.todo(
|
||||
'[D9 Phase C] blast-radius tracking: S3.C1 and S3.C2 overrides coexist with v2 per-node drawing without mutual interference'
|
||||
)
|
||||
})
|
||||
})
|
||||
44
src/extension-api-v2/__tests__/bc-07.migration.test.ts
Normal file
44
src/extension-api-v2/__tests__/bc-07.migration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Category: BC.07 — Connection observation, intercept, and veto
|
||||
// DB cross-ref: S2.N3, S2.N12, S2.N13
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
|
||||
// Migration: v1 prototype method assignment → v2 NodeHandle.on('connectInput'/'connectOutput'/'connectionChange')
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.07 migration — connection observation, intercept, and veto', () => {
|
||||
describe('onConnectionsChange → on(\'connectionChange\') (S2.N3)', () => {
|
||||
it.todo(
|
||||
'v1 onConnectionsChange and v2 on(\'connectionChange\') both fire for the same link connect event with equivalent payload data'
|
||||
)
|
||||
it.todo(
|
||||
'v2 connectionChange event fires at the same point in the link-wiring sequence as v1 onConnectionsChange'
|
||||
)
|
||||
})
|
||||
|
||||
describe('onConnectInput → on(\'connectInput\') (S2.N12)', () => {
|
||||
it.todo(
|
||||
'v1 onConnectInput returning false and v2 on(\'connectInput\') returning false both result in an unwired graph with no link object created'
|
||||
)
|
||||
it.todo(
|
||||
'type coercion performed inside v1 onConnectInput produces the same wired slot type as equivalent mutation inside v2 on(\'connectInput\')'
|
||||
)
|
||||
})
|
||||
|
||||
describe('onConnectOutput → on(\'connectOutput\') (S2.N13)', () => {
|
||||
it.todo(
|
||||
'v1 onConnectOutput veto and v2 on(\'connectOutput\') veto both prevent connectionChange from firing on either endpoint node'
|
||||
)
|
||||
it.todo(
|
||||
'v2 on(\'connectOutput\') listener receives equivalent data to v1 onConnectOutput arguments for the same connection attempt'
|
||||
)
|
||||
})
|
||||
|
||||
describe('scope and cleanup', () => {
|
||||
it.todo(
|
||||
'v1 prototype method persists after extension unregisters (no cleanup); v2 on() listeners are removed on scope dispose'
|
||||
)
|
||||
it.todo(
|
||||
'v2 cleanup does not affect connection listeners registered by other extensions on the same node'
|
||||
)
|
||||
})
|
||||
})
|
||||
53
src/extension-api-v2/__tests__/bc-07.v1.test.ts
Normal file
53
src/extension-api-v2/__tests__/bc-07.v1.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Category: BC.07 — Connection observation, intercept, and veto
|
||||
// DB cross-ref: S2.N3, S2.N12, S2.N13
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
|
||||
// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
|
||||
// v1 contract: node.onConnectInput(slot, type, link, node, fromSlot)
|
||||
// node.onConnectOutput(slot, type, link, node, toSlot)
|
||||
// node.onConnectionsChange(type, slot, connected, link, ioSlot)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.07 v1 contract — connection observation, intercept, and veto', () => {
|
||||
describe('S2.N3 — onConnectionsChange: passive observation', () => {
|
||||
it.todo(
|
||||
'onConnectionsChange is called on the node when any input or output link is connected or disconnected'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectionsChange receives type (INPUT=1/OUTPUT=2), slot index, connected boolean, link info, and ioSlot'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectionsChange fires after the link is already wired into the graph (link is present at call time)'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectionsChange fires for both the source node and the target node on a single link operation'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N12 — onConnectInput: intercept and veto incoming connections', () => {
|
||||
it.todo(
|
||||
'onConnectInput returning false vetoes the connection before it is wired'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectInput returning true (or undefined) allows the connection to proceed'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectInput receives slot index, incoming type, link object, source node, and source slot'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectInput can mutate the slot type to coerce an incompatible type before wiring'
|
||||
)
|
||||
})
|
||||
|
||||
describe('S2.N13 — onConnectOutput: intercept and veto outgoing connections', () => {
|
||||
it.todo(
|
||||
'onConnectOutput returning false vetoes the outgoing connection before it is wired'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectOutput receives slot index, outgoing type, link object, target node, and target slot'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectOutput veto does not trigger onConnectionsChange on either node'
|
||||
)
|
||||
})
|
||||
})
|
||||
51
src/extension-api-v2/__tests__/bc-07.v2.test.ts
Normal file
51
src/extension-api-v2/__tests__/bc-07.v2.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Category: BC.07 — Connection observation, intercept, and veto
|
||||
// DB cross-ref: S2.N3, S2.N12, S2.N13
|
||||
// Exemplar: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
|
||||
// blast_radius: 5.46 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
|
||||
// v2 replacement: NodeHandle.on('connectInput', ...), on('connectOutput', ...), on('connectionChange', ...)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.07 v2 contract — connection observation, intercept, and veto', () => {
|
||||
describe('on(\'connectionChange\', fn) — passive observation', () => {
|
||||
it.todo(
|
||||
'NodeHandle.on(\'connectionChange\', fn) fires fn after any input or output link is connected or disconnected'
|
||||
)
|
||||
it.todo(
|
||||
'connectionChange event payload includes type (\'input\'|\'output\'), slotIndex, connected boolean, and link info'
|
||||
)
|
||||
it.todo(
|
||||
'multiple listeners registered via on(\'connectionChange\') are all invoked in registration order'
|
||||
)
|
||||
it.todo(
|
||||
'listener registered with on() is removed when the extension scope is disposed'
|
||||
)
|
||||
})
|
||||
|
||||
describe('on(\'connectInput\', fn) — intercept and veto incoming connections', () => {
|
||||
it.todo(
|
||||
'fn returning false from on(\'connectInput\') vetoes the connection; graph remains unwired'
|
||||
)
|
||||
it.todo(
|
||||
'fn returning true or undefined from on(\'connectInput\') allows the connection to proceed'
|
||||
)
|
||||
it.todo(
|
||||
'connectInput event payload includes slotIndex, type, link, sourceHandle, and sourceSlot'
|
||||
)
|
||||
it.todo(
|
||||
'fn can mutate event.type to coerce a type mismatch before the connection is wired'
|
||||
)
|
||||
})
|
||||
|
||||
describe('on(\'connectOutput\', fn) — intercept and veto outgoing connections', () => {
|
||||
it.todo(
|
||||
'fn returning false from on(\'connectOutput\') vetoes the outgoing connection; connectionChange does not fire'
|
||||
)
|
||||
it.todo(
|
||||
'connectOutput event payload includes slotIndex, type, link, targetHandle, and targetSlot'
|
||||
)
|
||||
it.todo(
|
||||
'veto from connectOutput does not affect other registered connectOutput listeners on the same node'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/extension-api-v2/__tests__/bc-08.migration.test.ts
Normal file
38
src/extension-api-v2/__tests__/bc-08.migration.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Category: BC.08 — Programmatic linking
|
||||
// DB cross-ref: S10.D2
|
||||
// Exemplar: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
|
||||
// Migration: v1 node.connect/disconnectInput → v2 NodeHandle.connect/disconnectInput (typed handles)
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('BC.08 migration — programmatic linking', () => {
|
||||
describe('connect() equivalence', () => {
|
||||
it.todo(
|
||||
'v1 node.connect(srcSlot, targetNode, dstSlot) and v2 NodeHandle.connect(srcSlot, targetHandle, dstSlot) produce identical graph link state'
|
||||
)
|
||||
it.todo(
|
||||
'link id returned by v2 connect() matches the id on the underlying LGraph link created by an equivalent v1 call'
|
||||
)
|
||||
it.todo(
|
||||
'v2 connect() with a type-incompatible pair raises a typed error; v1 returns null — callers must handle both forms during migration'
|
||||
)
|
||||
})
|
||||
|
||||
describe('disconnectInput() equivalence', () => {
|
||||
it.todo(
|
||||
'v1 node.disconnectInput(slot) and v2 NodeHandle.disconnectInput(slotIndex) both leave the graph with no link on that slot'
|
||||
)
|
||||
it.todo(
|
||||
'onConnectionsChange (v1) and on(\'connectionChange\') (v2) both fire for the same disconnect operation with equivalent payload data'
|
||||
)
|
||||
})
|
||||
|
||||
describe('handle vs. raw node reference', () => {
|
||||
it.todo(
|
||||
'v2 NodeHandle.connect() accepts a NodeHandle for targetHandle; passing a raw LGraphNode instance throws a deprecation error'
|
||||
)
|
||||
it.todo(
|
||||
'NodeHandle obtained from v2 nodeCreated correctly wraps the same node that v1 connect() would operate on'
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user