Compare commits
4 Commits
chore/code
...
worktree-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8b7fbf79 | ||
|
|
caa535e906 | ||
|
|
3a9470018f | ||
|
|
08da6fae72 |
@@ -63,29 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '{src,packages,apps}/**/*.test.ts'
|
||||
instructions: |
|
||||
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
|
||||
Reuse shared factories in src/utils/__tests__/litegraphTestUtils.ts instead of hand-rolling mock builders.
|
||||
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards, litegraph classes, or sibling composables.
|
||||
Use a real createI18n instance rather than vi.mock('vue-i18n').
|
||||
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
|
||||
Use @testing-library/vue for component tests, not @vue/test-utils.
|
||||
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
|
||||
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
|
||||
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
|
||||
Tests should import the module under test from its public entrypoint, not deep internal paths.
|
||||
- path: 'browser_tests/**/*.spec.ts'
|
||||
instructions: |
|
||||
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
|
||||
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
|
||||
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
|
||||
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.
|
||||
- path: 'src/**/*.vue'
|
||||
instructions: |
|
||||
Do not introduce new PrimeVue component usage; use existing design-system components or Reka UI/shadcn-vue primitives.
|
||||
Apply Tailwind semantic tokens from the design system; flag hardcoded hex colors and the dark: variant.
|
||||
Merge classes via cn() from @comfyorg/tailwind-utils; flag :class="[]" array bindings.
|
||||
Avoid <style> blocks except for documented third-party :deep() exceptions.
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,7 +21,6 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: routes.mcp
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
|
||||
@@ -26,10 +26,6 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2200,16 +2196,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -537,6 +537,7 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -28,12 +28,7 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -46,7 +46,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
@@ -691,8 +691,7 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
@@ -234,8 +234,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,8 +14,7 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 94 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
return box
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,27 +84,29 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -293,12 +295,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
@@ -42,10 +42,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -53,7 +50,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,7 +64,11 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
@@ -5,7 +5,12 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
@@ -50,7 +50,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
@@ -237,7 +236,7 @@ defineExpose({ handleDragDrop })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
|
||||
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="
|
||||
cn(
|
||||
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
|
||||
useWidgetHeight()
|
||||
)
|
||||
"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="icon-[lucide--minus]" />
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
|
||||
<input
|
||||
@@ -30,7 +24,7 @@
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
|
||||
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
@@ -60,14 +54,13 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
|
||||
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="icon-[lucide--plus]" />
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,7 +71,6 @@ import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWidgetHeight } from '@/types/widgetTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
|
||||
@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +260,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -34,29 +35,19 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => mockTelemetry
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -77,10 +68,7 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
model: 'Model'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,11 +95,6 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -154,7 +137,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -245,7 +228,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
it('shows error message when service throws', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -253,25 +236,7 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,8 +341,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -387,90 +352,6 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,15 +109,14 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -140,12 +139,7 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -154,17 +148,10 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -178,25 +165,24 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total != null) {
|
||||
if (response.total) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages != null) {
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
} catch (err) {
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,12 +198,6 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -42,34 +42,22 @@ function withStrictMillisecondParser<T>(run: () => T): T {
|
||||
}
|
||||
|
||||
const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as {
|
||||
endDate: string | null
|
||||
duration?: 'ANNUAL' | 'MONTHLY' | null
|
||||
} | null
|
||||
value: null as { endDate: string | null } | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
|
||||
const mockTrackCancellation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
subscription: mockSubscription,
|
||||
tier: mockTier
|
||||
subscription: mockSubscription
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscriptionCancellation: mockTrackCancellation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
@@ -106,95 +94,6 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTier.value = 'STANDARD'
|
||||
})
|
||||
|
||||
describe('cancellation telemetry', () => {
|
||||
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
|
||||
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
|
||||
source: 'cancel_plan_menu',
|
||||
current_tier: 'standard',
|
||||
end_date: '2026-08-01T00:00:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks confirmed and failed with message-carrying rejection values', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'failed',
|
||||
expect.objectContaining({ error_message: 'timed out' })
|
||||
)
|
||||
)
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'confirmed',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks abandoned when the user keeps the subscription', async () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /keep subscription/i })
|
||||
)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
unmount()
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
expect(mockCancelSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks abandoned when the dialog is dismissed by the shell', () => {
|
||||
mockSubscription.value = null
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
mockTrackCancellation.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(mockTrackCancellation).toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.objectContaining({ current_tier: 'standard' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
@@ -239,35 +138,6 @@ describe('CancelSubscriptionDialogContent', () => {
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
|
||||
const { unmount } = renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
expect(
|
||||
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
|
||||
).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
|
||||
'abandoned',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
|
||||
@@ -45,16 +45,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
|
||||
import { getErrorMessage } from '@/utils/errorUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
@@ -63,41 +60,9 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription, tier } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const didCancelSucceed = ref(false)
|
||||
|
||||
function cancellationMetadata(): SubscriptionCancellationMetadata {
|
||||
const endDate = props.cancelAt ?? subscription.value?.endDate
|
||||
return {
|
||||
source: 'cancel_plan_menu' as const,
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
...(subscription.value?.duration
|
||||
? {
|
||||
cycle:
|
||||
subscription.value.duration === 'ANNUAL'
|
||||
? ('yearly' as const)
|
||||
: ('monthly' as const)
|
||||
}
|
||||
: {}),
|
||||
...(endDate ? { end_date: endDate } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
telemetry?.trackSubscriptionCancellation(
|
||||
'flow_opened',
|
||||
cancellationMetadata()
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (didCancelSucceed.value || isLoading.value) return
|
||||
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
|
||||
})
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
|
||||
@@ -119,37 +84,24 @@ function onClose() {
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error)
|
||||
telemetry?.trackSubscriptionCancellation('failed', {
|
||||
...cancellationMetadata(),
|
||||
error_message: errorMessage ?? String(error)
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: errorMessage ?? t('g.unknownError')
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
didCancelSucceed.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
// Cancellation already succeeded; stale local subscription status should not report failure.
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
@@ -135,7 +135,6 @@ watchDebounced(
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(WidgetHeightKey, 'h-7')
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
@@ -19,7 +19,6 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +26,6 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,23 +57,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -165,7 +151,6 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -177,27 +162,16 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -298,7 +272,6 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -307,27 +280,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -339,20 +294,6 @@ describe('useBillingContext', () => {
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never clobbers the list-derived store when a subscription is absent', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
@@ -17,10 +18,10 @@ import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
@@ -34,9 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { type } = useBillingRouting()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
@@ -96,6 +96,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
@@ -160,12 +170,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher.
|
||||
// Subscribed means active AND not cancelled, so the delete button enables
|
||||
// after cancellation, even before the period ends. A null subscription means
|
||||
// "not loaded yet" (adapters are discarded on every workspace/type switch);
|
||||
// skip it so the transient reinit gap can't clobber the list-derived baseline
|
||||
// (personal workspaces and subscribed teams already read subscribed there).
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
@@ -179,27 +186,24 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
|
||||
// init detect that it was superseded (its captured adapter is no longer the
|
||||
// active one), so a stale response can't resolve into a ready state for the
|
||||
// wrong workspace.
|
||||
function resetBillingState() {
|
||||
legacyBillingRef.value = null
|
||||
workspaceBillingRef.value = null
|
||||
isInitialized.value = false
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
resetBillingState()
|
||||
if (!newWorkspaceId) return
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
@@ -212,20 +216,17 @@ function useBillingContextInternal(): BillingContext {
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const adapter = activeContext.value
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await adapter.initialize()
|
||||
if (activeContext.value !== adapter) return
|
||||
await activeContext.value.initialize()
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
if (activeContext.value !== adapter) return
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
if (activeContext.value === adapter) isLoading.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const personal = { id: 'w-personal', type: 'personal' as const }
|
||||
const team = { id: 'w-team', type: 'team' as const }
|
||||
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
it('uses legacy billing when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type { BillingType } from './types'
|
||||
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
|
||||
// An unloaded workspace has no type yet; stay legacy so bootstrap never
|
||||
// eagerly routes to workspace billing.
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
return 'workspace'
|
||||
})
|
||||
|
||||
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
|
||||
|
||||
return { type, shouldUseWorkspaceBilling }
|
||||
}
|
||||
@@ -6,12 +6,6 @@ import {
|
||||
useFeatureFlags
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Mock the API module
|
||||
@@ -225,86 +219,6 @@ describe('useFeatureFlags', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-gated flags on cloud', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {}
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -32,7 +30,6 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -49,26 +46,6 @@ function resolveFlag<T>(
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
|
||||
* Cloud build it is always false; during the auth window it falls back to the
|
||||
* cached session value so anonymous bootstrap config cannot route the user to
|
||||
* the wrong backend before authenticated config confirms the flag.
|
||||
*/
|
||||
function resolveAuthGatedFlag(
|
||||
flagKey: string,
|
||||
remoteConfigValue: boolean | undefined,
|
||||
cachedValue: Ref<boolean | undefined>
|
||||
): boolean {
|
||||
const override = getDevOverride<boolean>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
|
||||
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -127,10 +104,18 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
|
||||
remoteConfig.value.team_workspaces_enabled,
|
||||
cachedTeamWorkspacesEnabled
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
@@ -190,18 +175,6 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
*/
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -179,6 +181,33 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const source = new LGraphNode('CLIPTextEncode')
|
||||
const sourceSlot = source.addInput('text', 'STRING')
|
||||
sourceSlot.widget = { name: 'text' }
|
||||
source.addWidget('text', 'text', 'nested value', () => {})
|
||||
innerSubgraph.add(source)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -240,6 +269,41 @@ describe('flushProxyWidgetMigration', () => {
|
||||
).toBe('renamed_from_sidepanel')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('number')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
const widget = n.addWidget('number', 'seed', 0, () => {})
|
||||
widget.type = undefined as never
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('*')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
@@ -328,6 +392,88 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('uses the primitive title as the promoted input name when it was renamed', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.title = 'Batch Size'
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.inputs.find((input) => input.name === 'Batch Size')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips a stale primitive bypass marker when the host input is absent', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'deleted_input'
|
||||
}
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const slot = targets[0].inputs[0]
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'missingSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
@@ -346,6 +492,64 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive cohorts that disagree on source widget name', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'other']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'other'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries with no fan-out targets', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the target default when the primitive source widget has no value', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.widgets = []
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(0)
|
||||
})
|
||||
|
||||
it('quarantines all cohort entries when a target slot type is incompatible', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
@@ -366,6 +570,73 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target slot disappeared', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
targets[0].inputs = []
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target node id is stale', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = toNodeId(999_999)
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = UNASSIGNED_NODE_ID
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
@@ -572,6 +843,22 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not preserve non-widget host values on quarantine rows', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [null]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.not.objectContaining({
|
||||
hostValue: expect.anything()
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips appended entries via the public read helper', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
@@ -602,6 +889,14 @@ describe('flushProxyWidgetMigration', () => {
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
|
||||
it('ignores empty quarantine append requests', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
@@ -824,6 +1119,22 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips nested legacy prefixes from widget name', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'111: 222: seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: '222'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
|
||||
180
src/core/graph/subgraph/promotedInputWidget.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidget,
|
||||
promotedInputWidgets,
|
||||
widgetPromotedSource
|
||||
} from './promotedInputWidget'
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
widgets: new Map<string, Record<string, unknown>>(),
|
||||
setValue: vi.fn(),
|
||||
resolveSubgraphInputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: (id: string) => mocks.widgets.get(id),
|
||||
setValue: mocks.setValue
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./resolveSubgraphInputTarget', () => ({
|
||||
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
|
||||
}))
|
||||
|
||||
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
|
||||
return {
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
label: 'Prompt',
|
||||
...overrides
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function node(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return {
|
||||
inputs: [],
|
||||
isSubgraphNode: () => true,
|
||||
getSlotFromWidget: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('promotedInputWidget helpers', () => {
|
||||
beforeEach(() => {
|
||||
mocks.widgets.clear()
|
||||
mocks.setValue.mockClear()
|
||||
mocks.resolveSubgraphInputTarget.mockReset()
|
||||
})
|
||||
|
||||
it('resolves promoted input sources only for widget-backed inputs', () => {
|
||||
const graphNode = node()
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(promotedInputSource(graphNode, input())).toBeUndefined()
|
||||
expect(
|
||||
promotedInputSource(
|
||||
graphNode,
|
||||
input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
|
||||
})
|
||||
|
||||
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(
|
||||
node({ getSlotFromWidget: () => backingInput }),
|
||||
widget
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('projects store-backed widget fields with input fallbacks', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const widget = promotedInputWidget(input({ widgetId }))
|
||||
|
||||
expect(widget?.name).toBe('prompt')
|
||||
expect(widget?.label).toBe('Prompt')
|
||||
expect(widget?.y).toBe(0)
|
||||
expect(widget?.type).toBe('text')
|
||||
expect(widget?.options).toEqual({})
|
||||
expect(widget?.value).toBeUndefined()
|
||||
|
||||
widget!.label = 'Ignored'
|
||||
widget!.y = 12
|
||||
widget!.value = 'next'
|
||||
widget!.callback?.('callback')
|
||||
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
|
||||
})
|
||||
|
||||
it('projects live widget store fields and mutates store state', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const state = {
|
||||
name: 'store-name',
|
||||
label: 'Store Label',
|
||||
y: 42,
|
||||
type: 'combo',
|
||||
options: { values: ['a'] },
|
||||
value: 'a'
|
||||
}
|
||||
mocks.widgets.set(widgetId, state)
|
||||
|
||||
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
|
||||
|
||||
expect(widget?.name).toBe('store-name')
|
||||
expect(widget?.label).toBe('Store Label')
|
||||
expect(widget?.y).toBe(42)
|
||||
expect(widget?.type).toBe('combo')
|
||||
expect(widget?.options).toEqual({ values: ['a'] })
|
||||
expect(widget?.value).toBe('a')
|
||||
|
||||
widget!.label = 'New Label'
|
||||
widget!.y = 52
|
||||
|
||||
expect(state.label).toBe('New Label')
|
||||
expect(state.y).toBe(52)
|
||||
})
|
||||
|
||||
it('returns null for non-promoted inputs and filters projected widget lists', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const graphNode = node({
|
||||
inputs: [input(), input({ widgetId })]
|
||||
})
|
||||
|
||||
expect(promotedInputWidget(input())).toBeNull()
|
||||
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns undefined for null stored values', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
mocks.widgets.set(widgetId, { value: null })
|
||||
|
||||
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates input lookup to the graph node', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
const graphNode = node({
|
||||
getSlotFromWidget: vi.fn(() => backingInput)
|
||||
})
|
||||
|
||||
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
|
||||
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,10 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
@@ -51,19 +55,37 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
|
||||
}))
|
||||
|
||||
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@sentry/vue', () => ({
|
||||
addBreadcrumb: addBreadcrumbMock
|
||||
}))
|
||||
|
||||
const mockNavigation = vi.hoisted(() => ({
|
||||
stack: [] as Subgraph[]
|
||||
}))
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
navigationStack: mockNavigation.stack
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
addWidgetPromotionOptions,
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
reorderSubgraphInputsByWidgetOrder,
|
||||
tryToggleWidgetPromotion
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -102,6 +124,11 @@ function buildDuplicateNamePromotion() {
|
||||
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
|
||||
}
|
||||
|
||||
function setupNavigation(host: SubgraphNode) {
|
||||
host.subgraph.rootGraph.add(host)
|
||||
mockNavigation.stack = [host.subgraph]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -303,6 +330,284 @@ describe('getPromotableWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
function setupPromotableWidget() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
setupNavigation(host)
|
||||
const node = new LGraphNode('Prompt')
|
||||
subgraph.add(node)
|
||||
const input = node.addInput('text', 'STRING')
|
||||
input.label = 'Prompt text'
|
||||
const callback = vi.fn()
|
||||
const textWidget = node.addWidget('text', 'text', 'value', callback)
|
||||
textWidget.label = 'Prompt'
|
||||
input.widget = { name: textWidget.name }
|
||||
return { host, node, textWidget, callback }
|
||||
}
|
||||
|
||||
it('adds a promote menu option and runs the widget callback after promotion', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(options[0]?.content).toContain('Prompt')
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('adds an unpromote menu option when the widget is already promoted', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
|
||||
true
|
||||
)
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('reports outside-subgraph promotion attempts through the toast store', () => {
|
||||
const node = new LGraphNode('Prompt')
|
||||
const textWidget = node.addWidget('text', 'text', 'value', () => {})
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(1)
|
||||
expect(options).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles promotion for the widget under the canvas pointer', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
|
||||
graph_mouse: [10, 20],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
|
||||
useCanvasStore().canvas = canvas
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves state unchanged when toggle has no node or widget target', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('records a breadcrumb when value promotion has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const node = new LGraphNode('LooseWidgetNode')
|
||||
subgraph.add(node)
|
||||
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
|
||||
|
||||
promoteWidget(node, looseWidget, [host])
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const partialNode = {
|
||||
id: toNodeId(123),
|
||||
title: 'Partial',
|
||||
type: 'Partial'
|
||||
}
|
||||
|
||||
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the widget name in menu text when label is absent', () => {
|
||||
const { node, textWidget } = setupPromotableWidget()
|
||||
textWidget.label = undefined
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(options[0]?.content).toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: previewNode.id,
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: 'other'
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates preview exposures when the same preview is promoted twice', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('demotes preview exposures when no linked value promotion exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves unexposed preview widgets unchanged when demoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -346,6 +651,49 @@ describe('promoteRecommendedWidgets', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps value promotion idempotent when the widget is already linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
|
||||
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('seeds outer promoted widget state from a nested promoted input', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
writePromotedInputValue(innerHost, 'text', 'inner value')
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
|
||||
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
|
||||
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
|
||||
'inner value'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -414,6 +762,24 @@ describe('promoteRecommendedWidgets', () => {
|
||||
})
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a breadcrumb when a recommended value widget has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
interiorNode.type = 'CLIPTextEncode'
|
||||
interiorNode.addWidget('text', 'text', '', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoExposeKnownPreviewNodes', () => {
|
||||
@@ -482,6 +848,52 @@ describe('autoExposeKnownPreviewNodes', () => {
|
||||
.map((e) => e.sourceNodeId)
|
||||
).not.toContain(String(glslNode.id))
|
||||
})
|
||||
|
||||
it('defers preview discovery for nodes without eager preview widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('DeferredPreview')
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
try {
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
rafCallbacks[0]?.(0)
|
||||
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
|
||||
const previewWidget = interiorNode.addWidget(
|
||||
'preview' as Parameters<typeof interiorNode.addWidget>[0],
|
||||
'preview',
|
||||
'',
|
||||
() => {}
|
||||
)
|
||||
previewWidget.serialize = false
|
||||
previewWidget.type = 'preview'
|
||||
updateCallback?.()
|
||||
|
||||
expect(updatePreviewsMock).toHaveBeenCalledWith(
|
||||
interiorNode,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toContainEqual({
|
||||
name: 'preview',
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
@@ -673,6 +1085,25 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves unordered names after explicitly ordered inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first',
|
||||
'third'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -768,6 +1199,33 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends promoted inputs that are absent from the widget 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)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
promotedWidgetRef(host, 'second')
|
||||
])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
@@ -798,6 +1256,23 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
return { host, interiorNode, interiorWidget }
|
||||
}
|
||||
|
||||
it('runs as a no-op for an unpromoted non-preview widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
host.subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
|
||||
demoteWidget(interiorNode, widget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Demoted widget "value"')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
@@ -943,4 +1418,54 @@ describe('disambiguated nested promotion identity', () => {
|
||||
|
||||
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
|
||||
})
|
||||
|
||||
it('promotes a widget whose source widget state is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
interiorInput.widgetId = 'missing-widget-state' as WidgetId
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('keeps plain inputs after ordered promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'plain', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
{ widgetId: 'missing-widget-state' as WidgetId }
|
||||
])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
|
||||
})
|
||||
|
||||
it('falls back to append order when promoted input links are stale', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
const promotedInput = host.subgraph.inputs[0]
|
||||
const linkId = promotedInput.linkIds[0]
|
||||
host.subgraph.links.delete(linkId)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
})
|
||||
|
||||
52
src/core/graph/widgets/dynamicTypes.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('splits concrete comma-delimited input types', () => {
|
||||
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
|
||||
'MODEL',
|
||||
'CLIP'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves match-type templates from allowed types', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
template: {
|
||||
allowed_types: 'IMAGE,MASK',
|
||||
template_id: 'image'
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid match-type templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves autogrow templates from required and optional inputs', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_AUTOGROW_V3',
|
||||
template: {
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
optional: {
|
||||
mask: ['MASK,IMAGE', {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid autogrow templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,19 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
@@ -15,6 +21,12 @@ type TestAutogrowNode = LGraphNode & {
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(app as unknown as { configuringGraphLevel: number }).configuringGraphLevel =
|
||||
0
|
||||
})
|
||||
|
||||
function nextTick() {
|
||||
return new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
@@ -56,6 +68,23 @@ function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
})
|
||||
)
|
||||
}
|
||||
function addMatchType(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
allowedTypes = '*',
|
||||
templateId = 'a'
|
||||
) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_MATCHTYPE_V3',
|
||||
{ template: { allowed_types: allowedTypes, template_id: templateId } }
|
||||
],
|
||||
{ name, isOptional: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
@@ -116,7 +145,312 @@ describe('Dynamic Combos', () => {
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
|
||||
test('throws for malformed dynamic combo specs before creating a widget', () => {
|
||||
const node = testNode()
|
||||
const comboApp = fromPartial<
|
||||
Parameters<typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3>[3]
|
||||
>({ widgets: { COMBO: vi.fn() } })
|
||||
|
||||
expect(() =>
|
||||
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
|
||||
node,
|
||||
'bad',
|
||||
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
|
||||
comboApp
|
||||
)
|
||||
).toThrow('invalid DynamicCombo spec')
|
||||
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears grouped widgets when selection becomes empty', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
node.widgets[0].value = '1'
|
||||
const onRemove = vi.fn()
|
||||
node.widgets[1].onRemove = onRemove
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('deletes widget state when removing grouped dynamic widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const childWidget = node.widgets[1]
|
||||
const childWidgetId = childWidget.widgetId
|
||||
if (!childWidgetId) throw new Error('Missing child widget id')
|
||||
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
|
||||
})
|
||||
|
||||
test('preserves an existing dynamic input link when refreshing a selection', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['IMAGE'], ['STRING']])
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const linkId = node.inputs[1].link
|
||||
expect(linkId).not.toBeNull()
|
||||
onConnectionsChange.mockClear()
|
||||
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
expect(node.inputs[1].link).toBe(linkId)
|
||||
expect(graph.links[linkId!].target_slot).toBe(1)
|
||||
expect(onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
1,
|
||||
true,
|
||||
graph.links[linkId!],
|
||||
node.inputs[1]
|
||||
)
|
||||
})
|
||||
|
||||
test('throws if the backing widgets array disappears during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = undefined as unknown as typeof node.widgets
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Not Reachable')
|
||||
})
|
||||
|
||||
test('throws when the dynamic controller widget is missing during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = node.widgets.slice(1)
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow("Dynamic widget doesn't exist on node")
|
||||
})
|
||||
|
||||
test('throws when input-only dynamic sockets have no insertion point', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
const controller = node.widgets[0]
|
||||
node.inputs = []
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Failed to find input socket for 0')
|
||||
})
|
||||
|
||||
test('updates dynamic inputs without requiring a graph', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
|
||||
node.widgets[0].value = '1'
|
||||
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('reads dynamic combo values from widget state when available', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
const controllerId = controller.widgetId
|
||||
if (!controllerId) throw new Error('Missing controller widget id')
|
||||
|
||||
controller.value = '1'
|
||||
useWidgetValueStore().setValue(controllerId, '0')
|
||||
|
||||
expect(controller.value).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic input dispatch', () => {
|
||||
test('returns false for unknown dynamic input types', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'plain',
|
||||
type: 'STRING',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true after applying a known dynamic input type', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_AUTOGROW_V3',
|
||||
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
|
||||
],
|
||||
{ name: 'grow', isOptional: false }
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('throws when an autogrow input spec is malformed', () => {
|
||||
const node = testNode()
|
||||
const inputSpec = {
|
||||
name: 'bad',
|
||||
type: 'COMFY_AUTOGROW_V3'
|
||||
} as InputSpecV2
|
||||
|
||||
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
|
||||
})
|
||||
|
||||
test('ignores malformed match type specs', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'bad',
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MatchType inputs', () => {
|
||||
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
|
||||
const node = testNode()
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: outputMatchTypes
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'on_true')
|
||||
addMatchType(node, 'on_false')
|
||||
return node
|
||||
}
|
||||
|
||||
function createSourceNode(graph: LGraph, type: string) {
|
||||
const node = testNode()
|
||||
node.addOutput('out', type)
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
test('ignores match type notifications outside registered inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.addInput('plain', 'STRING')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('uses wildcard types for stale match type links', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.inputs[0].link = toLinkId(999)
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('leaves unmatched output groups unchanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph, ['other'])
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('throws when match group input constraints cannot overlap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation(() => 1)
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: ['a']
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'image', 'IMAGE')
|
||||
addMatchType(node, 'latent', 'LATENT')
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
try {
|
||||
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('disconnects downstream links when a match type output narrows', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
const downstream = testNode()
|
||||
downstream.addInput('latent', 'LATENT')
|
||||
downstream.onConnectionsChange = vi.fn()
|
||||
graph.add(downstream)
|
||||
node.connect(0, downstream, 0)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(downstream.inputs[0].link).toBeNull()
|
||||
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
expect.anything(),
|
||||
downstream.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores deferred match type refresh after the input is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
|
||||
try {
|
||||
addMatchType(node, 'removed')
|
||||
node.inputs.pop()
|
||||
rafCallbacks[0]?.(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
test('Can name by prefix', () => {
|
||||
@@ -162,6 +496,258 @@ describe('Autogrow', () => {
|
||||
connectInput(node, 2, graph)
|
||||
expect(node.inputs.length).toBe(3)
|
||||
})
|
||||
|
||||
test('ignores autogrow notifications that cannot affect a known input group', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const inputCount = node.inputs.length
|
||||
const unknownInput = node.addInput('outside.0', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
99,
|
||||
true,
|
||||
null,
|
||||
undefined as unknown as Parameters<
|
||||
NonNullable<typeof node.onConnectionsChange>
|
||||
>[4]
|
||||
)
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
|
||||
|
||||
expect(node.inputs).toHaveLength(inputCount + 1)
|
||||
})
|
||||
|
||||
test('does not grow autogrow inputs when connection metadata is missing', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('restores a configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
|
||||
})
|
||||
|
||||
test('draws configure-time autogrow shim text from the input name', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const shim = node.widgets.find((widget) => widget.name === '0.test1')
|
||||
if (!shim?.draw) throw new Error('Missing shim widget')
|
||||
node.inputs[1].label = undefined
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
save: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
restore: vi.fn()
|
||||
})
|
||||
|
||||
shim.draw(ctx, node, 100, 10, 20)
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
|
||||
})
|
||||
|
||||
test('keeps an existing configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
node.widgets.push({
|
||||
name: node.inputs[1].name,
|
||||
type: 'shim',
|
||||
y: 0,
|
||||
options: {},
|
||||
serialize: false,
|
||||
draw: vi.fn()
|
||||
})
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(
|
||||
node.widgets.filter((widget) => widget.name === '0.test1')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('defers disconnect handling during an input swap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
node.disconnectInput(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('stops cleanup for uneven multi-input autogrow groups', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
node.inputs.pop()
|
||||
|
||||
try {
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
node.inputs[0]
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to group multi-input autogrow inputs'
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores named autogrow input names outside the configured list', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
|
||||
const unknownInput = node.addInput('0.c', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.a',
|
||||
'0.b',
|
||||
'0.c'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores autogrow input names without numeric ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const unknownInput = node.addInput('0.testx', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1',
|
||||
'0.testx'
|
||||
])
|
||||
})
|
||||
|
||||
test('marks optional autogrow inputs as optional after required inputs', () => {
|
||||
const node = testNode()
|
||||
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { mask: ['MASK', {}] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(node.inputs.map((input) => input.type)).toEqual([
|
||||
'IMAGE',
|
||||
'MASK',
|
||||
'IMAGE',
|
||||
'MASK'
|
||||
])
|
||||
})
|
||||
test('Removing connections decreases to min + 1', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
@@ -258,6 +844,42 @@ describe('Autogrow', () => {
|
||||
expect(vid0Link).not.toBeNull()
|
||||
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
|
||||
})
|
||||
|
||||
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
connectInput(node, 2, graph)
|
||||
await nextTick()
|
||||
expect(node.inputs).toHaveLength(6)
|
||||
|
||||
const removedWidgetNames = ['0.image2', '0.mask2']
|
||||
const onRemove = vi.fn()
|
||||
for (const widget of node.widgets.filter((widget) =>
|
||||
removedWidgetNames.includes(widget.name)
|
||||
)) {
|
||||
widget.onRemove = onRemove
|
||||
}
|
||||
|
||||
node.disconnectInput(2)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(onRemove).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('Can deserialize a complex node', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
@@ -73,8 +72,8 @@ describe('MatchType during configure', () => {
|
||||
const link2Id = switchNode.inputs[1].link!
|
||||
|
||||
const outputTypeBefore = switchNode.outputs[0].type
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
try {
|
||||
@@ -93,8 +92,8 @@ describe('MatchType during configure', () => {
|
||||
expect(graph.links[link2Id]).toBeDefined()
|
||||
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
|
||||
} finally {
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
;(
|
||||
app as unknown as { configuringGraphLevel: number }
|
||||
).configuringGraphLevel = 0
|
||||
}
|
||||
})
|
||||
@@ -127,4 +126,45 @@ describe('MatchType during configure', () => {
|
||||
expect(switchNode.inputs[1].link).not.toBeNull()
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('keeps compatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
target.onConnectionsChange = vi.fn()
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
vi.mocked(target.onConnectionsChange).mockClear()
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('IMAGE')
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
expect(target.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
true,
|
||||
expect.anything(),
|
||||
target.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('disconnects incompatible downstream links after output type recalculation', () => {
|
||||
const graph = new LGraph()
|
||||
const switchNode = createMatchTypeNode(graph)
|
||||
const target = new LGraphNode('target')
|
||||
target.addInput('image', 'IMAGE')
|
||||
graph.add(target)
|
||||
const source = createSourceNode(graph, 'LATENT')
|
||||
|
||||
switchNode.connect(0, target, 0)
|
||||
expect(target.inputs[0].link).not.toBeNull()
|
||||
|
||||
source.connect(0, switchNode, 0)
|
||||
|
||||
expect(switchNode.outputs[0].type).toBe('LATENT')
|
||||
expect(target.inputs[0].link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
const appMock = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
emitAfterChange: vi.fn(),
|
||||
emitBeforeChange: vi.fn(),
|
||||
selected_nodes: {}
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
registerNodeDef: vi.fn(),
|
||||
rootGraph: {
|
||||
convertToSubgraph: vi.fn(),
|
||||
extra: {},
|
||||
getNodeById: vi.fn(),
|
||||
links: {},
|
||||
nodes: [],
|
||||
remove: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const widgetStoreMock = vi.hoisted(() => ({
|
||||
inputIsWidget: vi.fn((spec: unknown[]) =>
|
||||
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => widgetStoreMock
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
|
||||
function makeNode(type: string): ComfyNode {
|
||||
@@ -26,6 +58,46 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
function extension(): ComfyExtension {
|
||||
const groupExtension = appMock.registerExtension.mock.calls.find(
|
||||
([registered]) => registered.name === 'Comfy.GroupNode'
|
||||
)?.[0]
|
||||
if (!groupExtension) throw new Error('GroupNode extension was not registered')
|
||||
return groupExtension as ComfyExtension
|
||||
}
|
||||
|
||||
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
const groupExtension = extension()
|
||||
if (!groupExtension.addCustomNodeDefs) {
|
||||
throw new Error('GroupNode extension does not implement addCustomNodeDefs')
|
||||
}
|
||||
groupExtension.addCustomNodeDefs(defs, appMock as unknown as ComfyApp)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appMock.registerNodeDef.mockReset()
|
||||
widgetStoreMock.inputIsWidget.mockClear()
|
||||
LiteGraph.registered_node_types = {}
|
||||
addCustomNodeDefs({})
|
||||
})
|
||||
|
||||
describe('replaceLegacySeparators', () => {
|
||||
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
|
||||
const nodes = [makeNode('workflow/My Group')]
|
||||
@@ -104,4 +176,390 @@ describe('GroupNodeConfig.getLinks', () => {
|
||||
const config = configFrom([], [[0, 1, 'IMAGE']])
|
||||
expect(config.externalFrom[0][1]).toBe('IMAGE')
|
||||
})
|
||||
|
||||
it('ignores external links without a type and accumulates multiple slots', () => {
|
||||
const config = configFrom(
|
||||
[],
|
||||
[
|
||||
[0, 1, null as unknown as string],
|
||||
[0, 2, 'LATENT'],
|
||||
[0, 3, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.getNodeDef', () => {
|
||||
const imageNodeDef = makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}],
|
||||
mode: [['fast', 'slow'], {}]
|
||||
},
|
||||
optional: {
|
||||
strength: ['FLOAT', { default: 1 }]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
addCustomNodeDefs({ ImageNode: imageNodeDef })
|
||||
})
|
||||
|
||||
it('returns registered definitions for normal node types', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
|
||||
imageNodeDef
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for nodes without an index or a known type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ type: 'UnknownNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips unlinked primitive nodes', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'PrimitiveNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives primitive node type from the outgoing link type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'PrimitiveNode' },
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toMatchObject({
|
||||
input: { required: { value: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null when primitive combo target spec is not primitive', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'PrimitiveNode',
|
||||
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
|
||||
input: { required: { value: [null, {}] } },
|
||||
output: [null]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for reroutes used only inside the group', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode' },
|
||||
{ index: 1, type: 'Reroute' },
|
||||
{ index: 2, type: 'ImageNode' }
|
||||
],
|
||||
links: [
|
||||
[0, 0, 1, 0, 1, 'IMAGE'],
|
||||
[1, 0, 2, 0, 2, 'IMAGE']
|
||||
] as SerialisedLLinkArray[],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
|
||||
})
|
||||
|
||||
it('derives reroute type from outgoing target inputs', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Reroute' },
|
||||
{
|
||||
index: 1,
|
||||
type: 'ImageNode',
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
}
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives reroute type from incoming output metadata', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
|
||||
{ index: 1, type: 'Reroute' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
|
||||
external: [[1, 0, 'LATENT']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
|
||||
output: ['LATENT']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives pipe reroute type from external metadata when links omit it', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'Reroute' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'MASK']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { MASK: ['MASK', { forceInput: true }] } },
|
||||
output: ['MASK']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig input and output mapping', () => {
|
||||
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [node],
|
||||
links: [],
|
||||
external: [],
|
||||
config: {
|
||||
0: {
|
||||
input: {
|
||||
hidden: { visible: false },
|
||||
renamed: { name: 'Custom Name' }
|
||||
},
|
||||
output: {
|
||||
1: { name: 'Custom Output' },
|
||||
2: { visible: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
config.nodeDef = makeNodeDef({
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: []
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
it('renames duplicate inputs and adds seed control metadata', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'Sampler',
|
||||
title: 'Sampler A',
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }]
|
||||
})
|
||||
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
|
||||
const result = config.getInputConfig(
|
||||
{ index: 0, type: 'Sampler', title: 'Sampler A' },
|
||||
'seed',
|
||||
seenInputs,
|
||||
['INT', {}]
|
||||
)
|
||||
|
||||
expect(result.name).toBe('Sampler A 1 seed')
|
||||
expect(result.config).toEqual([
|
||||
'INT',
|
||||
{ control_after_generate: 'Sampler A control_after_generate' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps image upload widget aliases through converted widget names', () => {
|
||||
const config = configWithNode({ index: 0, type: 'LoadImage' })
|
||||
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
|
||||
|
||||
expect(
|
||||
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
|
||||
'IMAGEUPLOAD',
|
||||
{ widget: 'customImage' }
|
||||
])
|
||||
).toMatchObject({
|
||||
name: 'Custom Name',
|
||||
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splits widget inputs, socket inputs, and converted widget slots', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
})
|
||||
|
||||
const result = config.processWidgetInputs(
|
||||
{
|
||||
mode: ['COMBO', {}],
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
['mode', 'image'],
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.slots).toEqual(['image'])
|
||||
expect(result.converted.get(0)).toBe('mode')
|
||||
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
|
||||
})
|
||||
|
||||
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'InputNode'
|
||||
})
|
||||
const inputMap: Record<number, number> = {}
|
||||
config.processInputSlots(
|
||||
{
|
||||
image: ['IMAGE', {}],
|
||||
hidden: ['LATENT', {}]
|
||||
},
|
||||
{ index: 0, type: 'InputNode' },
|
||||
['image', 'hidden'],
|
||||
{},
|
||||
inputMap,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
|
||||
expect(inputMap).toEqual({ 0: 0 })
|
||||
})
|
||||
|
||||
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'OutputNode',
|
||||
title: 'Output A',
|
||||
outputs: [{ name: 'image', label: 'Rendered' }]
|
||||
})
|
||||
config.linksFrom[0] = {
|
||||
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
|
||||
}
|
||||
config.processNodeOutputs(
|
||||
{ index: 0, type: 'OutputNode', title: 'Output A' },
|
||||
{ Rendered: 1 },
|
||||
{
|
||||
input: { required: {} },
|
||||
output: ['IMAGE', 'LATENT', 'MASK'],
|
||||
output_name: ['image', 'latent', 'mask'],
|
||||
output_is_list: [false, true, false]
|
||||
}
|
||||
)
|
||||
|
||||
expect(config.outputVisibility).toEqual([false, true, false])
|
||||
expect(config.nodeDef?.output).toEqual(['LATENT'])
|
||||
expect(config.nodeDef?.output_is_list).toEqual([true])
|
||||
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.registerFromWorkflow', () => {
|
||||
it('adds missing type actions and skips registration for incomplete groups', async () => {
|
||||
const groupNodes: Record<string, GroupNodeWorkflowData> = {
|
||||
Broken: {
|
||||
nodes: [{ index: 0, type: 'MissingNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
}
|
||||
}
|
||||
const missingNodeTypes: Parameters<
|
||||
typeof GroupNodeConfig.registerFromWorkflow
|
||||
>[1] = []
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
|
||||
|
||||
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
|
||||
expect(missingNodeTypes).toHaveLength(2)
|
||||
expect(missingNodeTypes[0]).toMatchObject({
|
||||
type: 'MissingNode',
|
||||
hint: " (In group node 'workflow>Broken')"
|
||||
})
|
||||
|
||||
const action = missingNodeTypes[1]
|
||||
if (typeof action === 'string') {
|
||||
throw new Error('Expected an action entry for the broken group node')
|
||||
}
|
||||
const target = document.createElement('button')
|
||||
const { callback } = action.action as {
|
||||
callback: (event: MouseEvent) => void
|
||||
}
|
||||
const event = new MouseEvent('click')
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
callback(event)
|
||||
expect(groupNodes.Broken).toBeUndefined()
|
||||
expect(target.textContent).toBe('Removed')
|
||||
expect(target.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('registers complete group node types and stores their generated node defs', async () => {
|
||||
addCustomNodeDefs({
|
||||
ImageNode: makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
})
|
||||
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
Complete: {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
|
||||
'workflow>Complete',
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot,
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { SlotType } from '@/lib/litegraph/src/draw'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
ReadOnlyRect
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
LinkDirection,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
|
||||
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
|
||||
|
||||
type MockCanvasContext = CanvasRenderingContext2D & {
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
clip: ReturnType<typeof vi.fn>
|
||||
closePath: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
fillText: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
rect: ReturnType<typeof vi.fn>
|
||||
restore: ReturnType<typeof vi.fn>
|
||||
save: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createContext(): MockCanvasContext {
|
||||
return fromPartial<MockCanvasContext>({
|
||||
fillStyle: '#initial-fill',
|
||||
strokeStyle: '#initial-stroke',
|
||||
lineWidth: 7,
|
||||
textAlign: 'start',
|
||||
arc: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
save: vi.fn(),
|
||||
stroke: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function createColors(): DefaultConnectionColors {
|
||||
return {
|
||||
getConnectedColor: vi.fn((type) => `connected-${type}`),
|
||||
getDisconnectedColor: vi.fn((type) => `disconnected-${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(): LGraphNode {
|
||||
return {
|
||||
pos: [100, 200],
|
||||
_collapsed_width: 80
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
describe('NodeSlot', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'Path2D',
|
||||
class {
|
||||
arc = vi.fn()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('inputAsSerialisable', () => {
|
||||
it('removes _data from serialized slot', () => {
|
||||
const slot: INodeOutputSlot = {
|
||||
@@ -74,4 +146,328 @@ describe('NodeSlot', () => {
|
||||
expect(serialized.widget).not.toHaveProperty('options')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('draws an input label on the right and restores canvas styles', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
label: 'Input label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors(), highlight: true })
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 5, 0, Math.PI * 2)
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Input label', 25, 20)
|
||||
expect(ctx.fillStyle).toBe('#initial-fill')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
expect(ctx.lineWidth).toBe(7)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
})
|
||||
|
||||
it('draws output labels on the left and strokes output slots', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
localized_name: 'Localized output',
|
||||
type: 'FLOAT',
|
||||
links: [toLinkId(1)],
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('Localized output', 5, 20)
|
||||
expect(ctx.textAlign).toBe('start')
|
||||
expect(ctx.strokeStyle).toBe('#initial-stroke')
|
||||
})
|
||||
|
||||
it('draws event, box, arrow, grid, and low-quality slot shapes', () => {
|
||||
const colorContext = createColors()
|
||||
const node = createNode()
|
||||
const eventCtx = createContext()
|
||||
const boxCtx = createContext()
|
||||
const arrowCtx = createContext()
|
||||
const gridCtx = createContext()
|
||||
const lowQualityCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(eventCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'box',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.BOX,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(boxCtx, { colorContext })
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'arrow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(arrowCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'grid',
|
||||
type: SlotType.Array,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(gridCtx, { colorContext })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'low',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
).draw(lowQualityCtx, { colorContext, lowQuality: true })
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(boxCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
|
||||
expect(arrowCtx.moveTo).toHaveBeenCalledWith(23, 15.5)
|
||||
expect(gridCtx.rect).toHaveBeenCalledTimes(9)
|
||||
expect(lowQualityCtx.rect).toHaveBeenCalledWith(11, 11, 8, 8)
|
||||
expect(lowQualityCtx.fillText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws hollow and multi-type slots', () => {
|
||||
const colorContext = createColors()
|
||||
const hollowCtx = createContext()
|
||||
const multiCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'hollow',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.HollowCircle,
|
||||
link: null,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(hollowCtx, { colorContext, highlight: true })
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'multi',
|
||||
type: 'A,B,C,D,E',
|
||||
link: toLinkId(1),
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
).draw(multiCtx, { colorContext })
|
||||
|
||||
expect(hollowCtx.clip).toHaveBeenCalledWith(expect.any(Object), 'evenodd')
|
||||
expect(
|
||||
vi
|
||||
.mocked(colorContext.getConnectedColor)
|
||||
.mock.calls.some(([type]) => type === 'A')
|
||||
).toBe(true)
|
||||
expect(multiCtx.fill.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(multiCtx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides widget input labels and draws error rings', () => {
|
||||
const ctx = createContext()
|
||||
const slot = new NodeInputSlot(
|
||||
{
|
||||
name: 'widget-input',
|
||||
label: 'Hidden label',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
widget: { name: 'widget' },
|
||||
hasErrors: true,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
slot.draw(ctx, { colorContext: createColors() })
|
||||
|
||||
expect(ctx.fillText).not.toHaveBeenCalled()
|
||||
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 12, 0, Math.PI * 2)
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places directional labels above vertical slots', () => {
|
||||
const rightCtx = createContext()
|
||||
const leftCtx = createContext()
|
||||
const node = createNode()
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'up',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
dir: LinkDirection.UP,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'down',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
dir: LinkDirection.DOWN,
|
||||
boundingRect: [110, 210, 10, 10]
|
||||
},
|
||||
node
|
||||
)
|
||||
|
||||
input.draw(rightCtx, { colorContext: createColors() })
|
||||
output.draw(leftCtx, { colorContext: createColors() })
|
||||
|
||||
expect(rightCtx.fillText).toHaveBeenCalledWith('up', 15, 5)
|
||||
expect(leftCtx.fillText).toHaveBeenCalledWith('down', 15, 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapsed rendering', () => {
|
||||
it('draws collapsed input and output arrows in their own directions', () => {
|
||||
const inputCtx = createContext()
|
||||
const outputCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(inputCtx)
|
||||
new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
shape: RenderShape.ARROW,
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(outputCtx)
|
||||
|
||||
expect(inputCtx.moveTo).toHaveBeenCalledWith(8, -15)
|
||||
expect(inputCtx.lineTo).toHaveBeenCalledWith(-4, -19)
|
||||
expect(outputCtx.moveTo).toHaveBeenCalledWith(86, -15)
|
||||
expect(outputCtx.lineTo).toHaveBeenCalledWith(74, -19)
|
||||
})
|
||||
|
||||
it('draws collapsed event and circle slots', () => {
|
||||
const eventCtx = createContext()
|
||||
const circleCtx = createContext()
|
||||
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'event',
|
||||
type: SlotType.Event,
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(eventCtx)
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: 'circle',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
).drawCollapsed(circleCtx)
|
||||
|
||||
expect(eventCtx.rect).toHaveBeenCalledWith(-6.5, -19, 14, 8)
|
||||
expect(circleCtx.arc).toHaveBeenCalledWith(0, -15, 4, 0, Math.PI * 2)
|
||||
expect(circleCtx.fillStyle).toBe('#initial-fill')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization and validation', () => {
|
||||
it('serializes slot fields without the node reference', () => {
|
||||
const slot = new NodeOutputSlot(
|
||||
{
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4,
|
||||
boundingRect: [1, 2, 3, 4]
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(slot.toJSON()).toEqual({
|
||||
name: 'out',
|
||||
type: 'FLOAT',
|
||||
label: 'Output',
|
||||
color_on: '#fff',
|
||||
color_off: '#000',
|
||||
shape: RenderShape.BOX,
|
||||
dir: LinkDirection.RIGHT,
|
||||
localized_name: 'Localized',
|
||||
pos: [1, 2],
|
||||
boundingRect: [1, 2, 3, 4],
|
||||
links: [toLinkId(3)],
|
||||
slot_index: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('validates input and output targets by slot direction', () => {
|
||||
const input = new NodeInputSlot(
|
||||
{
|
||||
name: 'input',
|
||||
type: 'FLOAT',
|
||||
link: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
const output = new NodeOutputSlot(
|
||||
{
|
||||
name: 'output',
|
||||
type: 'FLOAT',
|
||||
links: null,
|
||||
boundingRect
|
||||
},
|
||||
createNode()
|
||||
)
|
||||
|
||||
expect(input.isValidTarget(output)).toBe(true)
|
||||
expect(output.isValidTarget(input)).toBe(true)
|
||||
expect(input.isValidTarget(input)).toBe(false)
|
||||
expect(output.isValidTarget(output)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
ExecutableNodeDTO,
|
||||
LGraph,
|
||||
LGraphEventMode,
|
||||
LLink,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
@@ -24,6 +26,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Creation', () => {
|
||||
it('should throw when the node has no graph', () => {
|
||||
const node = new LGraphNode('Detached')
|
||||
|
||||
expect(() => new ExecutableNodeDTO(node, [], new Map(), undefined)).toThrow(
|
||||
'Attempted to access LGraph reference that was null or undefined.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should create DTO from regular node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -207,6 +217,74 @@ describe('ExecutableNodeDTO Input Resolution', () => {
|
||||
const resolved = dto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should throw when resolving a repeated input path', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Looped')
|
||||
node.id = toNodeId(8)
|
||||
node.title = 'Loop title'
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
|
||||
).toThrow('Circular reference detected while resolving input 0')
|
||||
})
|
||||
|
||||
it('should report repeated root inputs without title or path details', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('')
|
||||
node.id = toNodeId(8)
|
||||
node.title = ''
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
|
||||
).toThrow('Circular reference detected while resolving input 0 of node 8')
|
||||
})
|
||||
|
||||
it('should throw when an input points at a missing link', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Target')
|
||||
node.addInput('in', 'IMAGE')
|
||||
node.inputs[0].link = toLinkId(99)
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
|
||||
})
|
||||
|
||||
it('should throw when an input link points at a missing source node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Target')
|
||||
node.id = toNodeId(2)
|
||||
node.addInput('in', 'IMAGE')
|
||||
graph.add(node)
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', '404', 0, '2', 0)
|
||||
graph.links.set(link.id, link)
|
||||
node.inputs[0].link = link.id
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No input node found')
|
||||
})
|
||||
|
||||
it('should throw when an input source has no DTO', () => {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'IMAGE')
|
||||
graph.add(source)
|
||||
const target = new LGraphNode('Target')
|
||||
target.addInput('in', 'IMAGE')
|
||||
graph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const dto = new ExecutableNodeDTO(target, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
@@ -257,6 +335,34 @@ describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('should throw when resolving a repeated output path', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Looped')
|
||||
node.id = toNodeId(9)
|
||||
node.title = 'Loop title'
|
||||
node.addOutput('out', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
|
||||
).toThrow('Circular reference detected while resolving output 0')
|
||||
})
|
||||
|
||||
it('should report repeated root outputs without title or path details', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('')
|
||||
node.id = toNodeId(9)
|
||||
node.title = ''
|
||||
node.addOutput('out', 'IMAGE')
|
||||
graph.add(node)
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
|
||||
expect(() =>
|
||||
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
|
||||
).toThrow('Circular reference detected while resolving output 0 of node 9')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Muted node output resolution', () => {
|
||||
@@ -368,6 +474,135 @@ describe('Bypass node output resolution', () => {
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use the first input when bypassing an any-type output', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('fallback', 'IMAGE')
|
||||
bypassedNode.addOutput('first', 'IMAGE')
|
||||
bypassedNode.addOutput('second', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(1, '*', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use the same slot when bypassing an empty-type output', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('image', 'IMAGE')
|
||||
bypassedNode.addOutput('out', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, '', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should use an exact matching input when bypassing different slot types', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('string', 'STRING')
|
||||
bypassedNode.addInput('image', 'IMAGE')
|
||||
bypassedNode.addOutput('latent', 'LATENT')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 1)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
|
||||
it('should return undefined when no bypass input matches', () => {
|
||||
const graph = new LGraph()
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('string', 'STRING')
|
||||
bypassedNode.addOutput('out', 'LATENT')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
const dto = new ExecutableNodeDTO(bypassedNode, [], new Map(), undefined)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No input types match'),
|
||||
dto
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALWAYS mode node output resolution', () => {
|
||||
@@ -483,6 +718,100 @@ describe('Virtual node resolveVirtualOutput', () => {
|
||||
expect(resolved).toBeUndefined()
|
||||
expect(spy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should resolve through a virtual input link', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.addOutput('out', 'IMAGE')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const passthroughNode = new LGraphNode('Passthrough')
|
||||
passthroughNode.addInput('in', 'IMAGE')
|
||||
graph.add(passthroughNode)
|
||||
sourceNode.connect(0, passthroughNode, 0)
|
||||
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue(
|
||||
fromPartial<LLink>({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: passthroughNode })
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const sourceDto = new ExecutableNodeDTO(
|
||||
sourceNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(sourceDto.id, sourceDto)
|
||||
const passthroughDto = new ExecutableNodeDTO(
|
||||
passthroughNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(passthroughDto.id, passthroughDto)
|
||||
const virtualDto = new ExecutableNodeDTO(
|
||||
virtualNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
|
||||
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved?.node).toBe(sourceDto)
|
||||
})
|
||||
|
||||
it('should throw when a virtual input link has no parent node', () => {
|
||||
const graph = new LGraph()
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue(
|
||||
fromPartial<LLink>({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: undefined })
|
||||
})
|
||||
)
|
||||
|
||||
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'Virtual node failed to resolve parent'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when a virtual input link parent has no DTO', () => {
|
||||
const graph = new LGraph()
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
graph.add(sourceNode)
|
||||
const virtualNode = new LGraphNode('Virtual Get')
|
||||
virtualNode.addOutput('out', 'IMAGE')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.resolveVirtualOutput = () => undefined
|
||||
graph.add(virtualNode)
|
||||
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue(
|
||||
fromPartial<LLink>({
|
||||
target_slot: 0,
|
||||
resolve: () => ({ inputNode: sourceNode })
|
||||
})
|
||||
)
|
||||
|
||||
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No input node DTO found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Properties', () => {
|
||||
@@ -588,6 +917,23 @@ describe('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Integration', () => {
|
||||
it('should delegate getInnerNodes for subgraph nodes', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const executableNodes = new Map()
|
||||
const dto = new ExecutableNodeDTO(
|
||||
subgraphNode,
|
||||
[],
|
||||
executableNodes,
|
||||
undefined
|
||||
)
|
||||
|
||||
const innerNodes = dto.getInnerNodes()
|
||||
|
||||
expect(innerNodes).toHaveLength(2)
|
||||
expect(innerNodes[0]).toBeInstanceOf(ExecutableNodeDTO)
|
||||
})
|
||||
|
||||
it('should work with SubgraphNode flattening', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 3 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -660,6 +1006,65 @@ describe('ExecutableNodeDTO Integration', () => {
|
||||
expect(Number(dto.node.id)).toBe(55) // Original node ID preserved
|
||||
expect(Number(dto.subgraphNode?.id)).toBe(99) // Subgraph context
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output slot is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No output found for flattened id'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined when a subgraph output has no inner link', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue(
|
||||
undefined
|
||||
)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output link has no inner node', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
|
||||
outputNode: undefined,
|
||||
link: new LLink(toLinkId(1), 'IMAGE', '1', 0, '2', 0)
|
||||
} as never)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No output node found'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when a subgraph output inner node has no DTO', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const innerNode = subgraph.nodes[0]
|
||||
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
|
||||
outputNode: innerNode,
|
||||
link: new LLink(toLinkId(1), 'IMAGE', String(innerNode.id), 0, '2', 0)
|
||||
} as never)
|
||||
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
|
||||
|
||||
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
|
||||
'No inner node DTO found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Scale Testing', () => {
|
||||
|
||||
278
src/lib/litegraph/src/subgraph/SubgraphIONodeBase.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
type MenuConfig = {
|
||||
title?: string
|
||||
callback?: (item: { content: string; value: string }) => void
|
||||
}
|
||||
|
||||
const { contextMenus, MockContextMenu } = vi.hoisted(() => {
|
||||
const contextMenus: Array<{
|
||||
options: unknown[]
|
||||
config: MenuConfig
|
||||
}> = []
|
||||
|
||||
class MockContextMenu {
|
||||
constructor(options: unknown[], config: MenuConfig) {
|
||||
contextMenus.push({ options, config })
|
||||
}
|
||||
}
|
||||
|
||||
return { contextMenus, MockContextMenu }
|
||||
})
|
||||
|
||||
type TestSlot = SubgraphInput & {
|
||||
arrange: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
measure: ReturnType<typeof vi.fn>
|
||||
onPointerMove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
class TestIONode extends SubgraphIONodeBase<SubgraphInput> {
|
||||
readonly id = 'subgraph-io' as NodeId
|
||||
readonly emptySlot: SubgraphInput
|
||||
readonly slots: SubgraphInput[]
|
||||
readonly renameSlot = vi.fn()
|
||||
readonly removeSlot = vi.fn()
|
||||
|
||||
constructor(
|
||||
subgraph: Subgraph,
|
||||
slots: SubgraphInput[],
|
||||
emptySlot: SubgraphInput
|
||||
) {
|
||||
super(subgraph)
|
||||
this.slots = slots
|
||||
this.emptySlot = emptySlot
|
||||
}
|
||||
|
||||
get allSlots(): SubgraphInput[] {
|
||||
return [...this.slots, this.emptySlot]
|
||||
}
|
||||
|
||||
get slotAnchorX(): number {
|
||||
return this.pos[0] + this.size[0] - SubgraphIONodeBase.roundedRadius
|
||||
}
|
||||
|
||||
onPointerDown(): void {}
|
||||
|
||||
openMenu(slot: SubgraphInput, event: CanvasPointerEvent): void {
|
||||
this.showSlotContextMenu(slot, event)
|
||||
}
|
||||
|
||||
renameByDoubleClick(slot: SubgraphInput, event: CanvasPointerEvent): void {
|
||||
this.handleSlotDoubleClick(slot, event)
|
||||
}
|
||||
|
||||
drawProtected(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
colorContext: DefaultConnectionColors,
|
||||
fromSlot?: SubgraphInput,
|
||||
editorAlpha?: number
|
||||
): void {
|
||||
ctx.lineWidth = 99
|
||||
ctx.strokeStyle = 'red'
|
||||
ctx.fillStyle = 'blue'
|
||||
ctx.font = '20px serif'
|
||||
ctx.textBaseline = 'top'
|
||||
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function createSlot(
|
||||
name: string,
|
||||
rect: [number, number, number, number],
|
||||
links: number[] = []
|
||||
): TestSlot {
|
||||
const slot = {
|
||||
name,
|
||||
displayName: `${name} label`,
|
||||
linkIds: links,
|
||||
boundingRect: new Rectangle(...rect),
|
||||
isPointerOver: false,
|
||||
measure: vi.fn(() => [rect[2], rect[3]]),
|
||||
arrange: vi.fn((nextRect: [number, number, number, number]) => {
|
||||
slot.boundingRect.set(nextRect)
|
||||
}),
|
||||
onPointerMove: vi.fn((event: CanvasPointerEvent) => {
|
||||
slot.isPointerOver = slot.boundingRect.containsXy(
|
||||
event.canvasX,
|
||||
event.canvasY
|
||||
)
|
||||
}),
|
||||
disconnect: vi.fn(),
|
||||
draw: vi.fn()
|
||||
}
|
||||
return fromPartial<TestSlot>(slot)
|
||||
}
|
||||
|
||||
function createSubgraph() {
|
||||
const prompt = vi.fn(
|
||||
(_title: string, _value: string, callback: (value: string) => void) =>
|
||||
callback('renamed')
|
||||
)
|
||||
return {
|
||||
prompt,
|
||||
subgraph: fromPartial<Subgraph>({
|
||||
setDirtyCanvas: vi.fn(),
|
||||
canvasAction: vi.fn(
|
||||
(callback: (canvas: { prompt: typeof prompt }) => void) =>
|
||||
callback({ prompt })
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createNode() {
|
||||
const filled = createSlot('value', [20, 30, 80, 20], [1])
|
||||
const empty = createSlot('', [20, 60, 80, 20])
|
||||
const { subgraph, prompt } = createSubgraph()
|
||||
const node = new TestIONode(subgraph, [filled], empty)
|
||||
node.configure({
|
||||
id: 'subgraph-io',
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
return { node, filled, empty, subgraph, prompt }
|
||||
}
|
||||
|
||||
function eventAt(x: number, y: number): CanvasPointerEvent {
|
||||
return { canvasX: x, canvasY: y } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
contextMenus.length = 0
|
||||
Object.assign(LiteGraph, { ContextMenu: MockContextMenu })
|
||||
})
|
||||
|
||||
describe('SubgraphIONodeBase', () => {
|
||||
it('moves, snaps, hit-tests, and serializes node bounds', () => {
|
||||
const { node } = createNode()
|
||||
|
||||
node.move(5, -10)
|
||||
|
||||
expect(Array.from(node.pos)).toEqual([15, 10])
|
||||
expect(node.containsPoint([20, 20])).toBe(true)
|
||||
expect(node.asSerialisable()).toEqual({
|
||||
id: 'subgraph-io',
|
||||
bounding: [15, 10, 100, 80],
|
||||
pinned: undefined
|
||||
})
|
||||
|
||||
node.pinned = true
|
||||
expect(node.snapToGrid(10)).toBe(false)
|
||||
expect(node.asSerialisable().pinned).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks pointer entry, slot hover, and pointer leave', () => {
|
||||
const { node, filled } = createNode()
|
||||
|
||||
const overResult = node.onPointerMove(eventAt(25, 35))
|
||||
|
||||
expect(overResult & CanvasItem.SubgraphIoNode).toBeTruthy()
|
||||
expect(overResult & CanvasItem.SubgraphIoSlot).toBeTruthy()
|
||||
expect(node.isPointerOver).toBe(true)
|
||||
expect(filled.isPointerOver).toBe(true)
|
||||
|
||||
const outResult = node.onPointerMove(eventAt(500, 500))
|
||||
|
||||
expect(outResult).toBe(CanvasItem.Nothing)
|
||||
expect(node.isPointerOver).toBe(false)
|
||||
expect(filled.isPointerOver).toBe(false)
|
||||
})
|
||||
|
||||
it('finds slots, arranges them, and restores drawing context state', () => {
|
||||
const { node, filled } = createNode()
|
||||
const ctx = {
|
||||
lineWidth: 1,
|
||||
strokeStyle: 'black',
|
||||
fillStyle: 'white',
|
||||
font: '12px sans-serif',
|
||||
textBaseline: 'middle'
|
||||
} as CanvasRenderingContext2D
|
||||
|
||||
node.arrange()
|
||||
node.draw(ctx, {} as DefaultConnectionColors, filled)
|
||||
|
||||
expect(node.getSlotInPosition(100, 40)).toBe(filled)
|
||||
expect(node.getSlotInPosition(500, 500)).toBeUndefined()
|
||||
expect(filled.arrange).toHaveBeenCalled()
|
||||
expect(node.size[0]).toBeGreaterThanOrEqual(108)
|
||||
expect(ctx.lineWidth).toBe(1)
|
||||
expect(ctx.strokeStyle).toBe('black')
|
||||
expect(ctx.fillStyle).toBe('white')
|
||||
expect(ctx.font).toBe('12px sans-serif')
|
||||
expect(ctx.textBaseline).toBe('middle')
|
||||
expect(filled.draw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ctx, fromSlot: filled })
|
||||
)
|
||||
})
|
||||
|
||||
it('prompts for non-empty slot rename on double click', () => {
|
||||
const { node, filled, empty, prompt } = createNode()
|
||||
|
||||
node.renameByDoubleClick(empty, eventAt(0, 0))
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
|
||||
node.renameByDoubleClick(filled, eventAt(20, 30))
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith(
|
||||
'Slot name',
|
||||
'value label',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
|
||||
})
|
||||
|
||||
it('opens slot context menu actions for connected non-empty slots', () => {
|
||||
const { node, filled, subgraph } = createNode()
|
||||
|
||||
node.openMenu(filled, eventAt(20, 30))
|
||||
|
||||
expect(contextMenus).toHaveLength(1)
|
||||
expect(contextMenus[0].config.title).toBe('value')
|
||||
expect(contextMenus[0].options).toMatchObject([
|
||||
{ value: 'disconnect' },
|
||||
{ value: 'rename' },
|
||||
null,
|
||||
{ value: 'remove', className: 'danger' }
|
||||
])
|
||||
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Disconnect Links',
|
||||
value: 'disconnect'
|
||||
})
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Rename Slot',
|
||||
value: 'rename'
|
||||
})
|
||||
contextMenus[0].config.callback?.({
|
||||
content: 'Remove Slot',
|
||||
value: 'remove'
|
||||
})
|
||||
|
||||
expect(filled.disconnect).toHaveBeenCalled()
|
||||
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
|
||||
expect(node.removeSlot).toHaveBeenCalledWith(filled)
|
||||
expect(subgraph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not open a context menu for the empty slot', () => {
|
||||
const { node, empty } = createNode()
|
||||
|
||||
node.openMenu(empty, eventAt(20, 60))
|
||||
|
||||
expect(contextMenus).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
273
src/lib/litegraph/src/subgraph/SubgraphInputNode.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function eventAt(x: number, y: number, button = 0): CanvasPointerEvent {
|
||||
return { canvasX: x, canvasY: y, button } as CanvasPointerEvent
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return fromPartial<CanvasRenderingContext2D>({
|
||||
getTransform: vi.fn(() => new DOMMatrix()),
|
||||
translate: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
textBaseline: 'alphabetic',
|
||||
globalAlpha: 1
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubgraphInputNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes input slots plus the empty slot and computes its anchor', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.inputNode.configure({
|
||||
id: subgraph.inputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
|
||||
expect(subgraph.inputNode.slots).toBe(subgraph.inputs)
|
||||
expect(subgraph.inputNode.allSlots).toEqual([
|
||||
subgraph.inputs[0],
|
||||
subgraph.inputNode.emptySlot
|
||||
])
|
||||
expect(subgraph.inputNode.slotAnchorX).toBe(96)
|
||||
})
|
||||
|
||||
it('sets link connector drag callbacks for left-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const pointer = {} as CanvasPointer
|
||||
const linkConnector = fromPartial<LinkConnector>({
|
||||
dragNewFromSubgraphInput: vi.fn(),
|
||||
dropLinks: vi.fn(),
|
||||
reset: vi.fn()
|
||||
})
|
||||
|
||||
subgraph.inputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
|
||||
|
||||
pointer.onDragStart?.(pointer)
|
||||
pointer.onDragEnd?.(eventAt(40, 45))
|
||||
pointer.finally?.()
|
||||
|
||||
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
subgraph.inputNode,
|
||||
slot
|
||||
)
|
||||
expect(linkConnector.dropLinks).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
expect.objectContaining({ canvasX: 40 })
|
||||
)
|
||||
expect(linkConnector.reset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('opens the slot context menu for right-clicked slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
slot.boundingRect.updateTo([10, 20, 100, 30])
|
||||
const menuSpy = vi.spyOn(
|
||||
subgraph.inputNode as unknown as {
|
||||
showSlotContextMenu(slot: unknown, event: unknown): void
|
||||
},
|
||||
'showSlotContextMenu'
|
||||
)
|
||||
|
||||
subgraph.inputNode.onPointerDown(
|
||||
eventAt(20, 25, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
subgraph.inputNode.onPointerDown(
|
||||
eventAt(500, 500, 2),
|
||||
{} as CanvasPointer,
|
||||
{} as LinkConnector
|
||||
)
|
||||
|
||||
expect(menuSpy).toHaveBeenCalledOnce()
|
||||
expect(menuSpy).toHaveBeenCalledWith(
|
||||
slot,
|
||||
expect.objectContaining({ button: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('renames and removes input slots through the parent subgraph', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
const renameSpy = vi.spyOn(subgraph, 'renameInput')
|
||||
const removeSpy = vi.spyOn(subgraph, 'removeInput')
|
||||
|
||||
subgraph.inputNode.renameSlot(slot, 'preview')
|
||||
subgraph.inputNode.removeSlot(slot)
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
|
||||
expect(removeSpy).toHaveBeenCalledWith(slot)
|
||||
})
|
||||
|
||||
it('delegates connection checks and input-type connections', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const slot = subgraph.inputs[0]
|
||||
const inputSlot = fromPartial<{ index: number; slot: INodeInputSlot }>({
|
||||
index: 0,
|
||||
slot: { name: 'in', type: 'IMAGE' }
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(99)
|
||||
vi.spyOn(targetNode, 'findInputByType').mockReturnValue(inputSlot)
|
||||
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
|
||||
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
|
||||
const inputNode = fromPartial<NodeLike>({
|
||||
canConnectTo: vi.fn(() => true)
|
||||
})
|
||||
|
||||
expect(
|
||||
subgraph.inputNode.canConnectTo(inputNode, inputSlot.slot, slot)
|
||||
).toBe(true)
|
||||
expect(
|
||||
subgraph.inputNode.connectByType(0, targetNode, 'IMAGE', {
|
||||
afterRerouteId: toRerouteId(7)
|
||||
})
|
||||
).toBe(link)
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
inputSlot.slot,
|
||||
targetNode,
|
||||
toRerouteId(7)
|
||||
)
|
||||
|
||||
vi.mocked(targetNode.findInputByType).mockReturnValue(undefined)
|
||||
expect(
|
||||
subgraph.inputNode.connectByType(0, targetNode, 'LATENT')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('finds input slots by name and the first free slot by type', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'used', type: 'IMAGE' },
|
||||
{ name: 'free', type: 'IMAGE' }
|
||||
]
|
||||
})
|
||||
subgraph.inputs[0].linkIds.push(toLinkId(1))
|
||||
|
||||
expect(subgraph.inputNode.findOutputSlot('free')).toBe(subgraph.inputs[1])
|
||||
expect(subgraph.inputNode.findOutputByType('IMAGE')).toBe(
|
||||
subgraph.inputs[0]
|
||||
)
|
||||
expect(subgraph.inputNode.findOutputByType('LATENT')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disconnects node inputs and clears floating links', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(99)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const floatingLink = new LLink(
|
||||
toLinkId(9),
|
||||
'IMAGE',
|
||||
subgraph.inputNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
input._floatingLinks = new Set([floatingLink])
|
||||
input.link = toLinkId(3)
|
||||
const removeFloatingLinkSpy = vi.spyOn(subgraph, 'removeFloatingLink')
|
||||
const setDirtyCanvasSpy = vi.spyOn(subgraph, 'setDirtyCanvas')
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(targetNode, input, undefined)
|
||||
|
||||
expect(removeFloatingLinkSpy).toHaveBeenCalledWith(floatingLink)
|
||||
expect(input.link).toBeNull()
|
||||
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('draws the side rail and input slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
subgraph.inputNode.configure({
|
||||
id: subgraph.inputNode.id,
|
||||
bounding: [10, 20, 100, 80],
|
||||
pinned: false
|
||||
})
|
||||
const ctx = createCanvasContext()
|
||||
const drawSlotsSpy = vi.spyOn(
|
||||
subgraph.inputNode as unknown as {
|
||||
drawSlots(
|
||||
ctx: unknown,
|
||||
colorContext: unknown,
|
||||
fromSlot: unknown,
|
||||
editorAlpha: unknown
|
||||
): void
|
||||
},
|
||||
'drawSlots'
|
||||
)
|
||||
|
||||
subgraph.inputNode.drawProtected(
|
||||
ctx,
|
||||
fromPartial<DefaultConnectionColors>({
|
||||
getConnectedColor: vi.fn(() => '#fff'),
|
||||
getDisconnectedColor: vi.fn(() => '#000')
|
||||
}),
|
||||
subgraph.inputs[0],
|
||||
0.5
|
||||
)
|
||||
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
|
||||
expect(ctx.beginPath).toHaveBeenCalled()
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.setTransform).toHaveBeenCalled()
|
||||
expect(drawSlotsSpy).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
getConnectedColor: expect.any(Function),
|
||||
getDisconnectedColor: expect.any(Function)
|
||||
}),
|
||||
subgraph.inputs[0],
|
||||
0.5
|
||||
)
|
||||
})
|
||||
})
|
||||
225
src/lib/litegraph/src/subgraph/SubgraphInputSlot.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createWidget(
|
||||
overrides: Partial<Pick<IBaseWidget, 'name' | 'type' | 'options'>> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
name: overrides.name ?? 'strength',
|
||||
type: overrides.type ?? 'FLOAT',
|
||||
options: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
step2: 0.01,
|
||||
precision: 2,
|
||||
...overrides.options
|
||||
}
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe('SubgraphInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('connects subgraph inputs to node inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
const connectionSpy = vi.fn()
|
||||
targetNode.onConnectionsChange = connectionSpy
|
||||
|
||||
const link = subgraph.inputs[0].connect(input, targetNode, toRerouteId(5))
|
||||
|
||||
expect(link).toBeInstanceOf(LLink)
|
||||
expect(link?.origin_id).toBe(subgraph.inputNode.id)
|
||||
expect(link?.target_id).toBe(targetNode.id)
|
||||
expect(link?.parentId).toBe(toRerouteId(5))
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
|
||||
expect(input.link).toBe(link?.id)
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: true,
|
||||
linkId: link?.id
|
||||
})
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
true,
|
||||
link,
|
||||
input
|
||||
)
|
||||
expect(afterChangeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not connect when the target node blocks the input', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
targetNode.onConnectInput = vi.fn(() => false)
|
||||
|
||||
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects widget inputs that do not match the promoted widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
const input = targetNode.addInput('strength', 'FLOAT')
|
||||
const currentWidget = createWidget()
|
||||
const otherWidget = createWidget({ options: { min: 1 } })
|
||||
input.widget = { name: otherWidget.name }
|
||||
targetNode.widgets = [otherWidget]
|
||||
subgraph.inputs[0]._widget = currentWidget
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Target input has invalid widget.',
|
||||
input,
|
||||
targetNode
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks connected widgets and clears them on disconnect', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('strength', 'FLOAT')
|
||||
const widget = createWidget()
|
||||
input.widget = { name: widget.name }
|
||||
targetNode.widgets = [widget]
|
||||
const connectedSpy = vi.fn()
|
||||
const disconnectedSpy = vi.fn()
|
||||
subgraph.inputs[0].events.addEventListener('input-connected', connectedSpy)
|
||||
subgraph.inputs[0].events.addEventListener(
|
||||
'input-disconnected',
|
||||
disconnectedSpy
|
||||
)
|
||||
|
||||
const link = subgraph.inputs[0].connect(input, targetNode)
|
||||
|
||||
expect(subgraph.inputs[0]._widget).toBe(widget)
|
||||
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
|
||||
expect(connectedSpy).toHaveBeenCalledOnce()
|
||||
|
||||
subgraph.inputs[0].disconnect()
|
||||
|
||||
expect(subgraph.inputs[0]._widget).toBeUndefined()
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([])
|
||||
expect(disconnectedSpy).toHaveBeenCalledTimes(2)
|
||||
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('arranges and labels from the right edge', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
|
||||
input.arrange([140, 30, 120, 40])
|
||||
|
||||
expect(Array.from(input.boundingRect)).toEqual([20, 30, 120, 40])
|
||||
expect(input.pos).toEqual([120, 50])
|
||||
expect(input.labelPos).toEqual([20, 50])
|
||||
})
|
||||
|
||||
it('validates node inputs and subgraph outputs as targets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'source', type: 'IMAGE' }],
|
||||
outputs: [{ name: 'preview', type: 'IMAGE' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
const imageInput = { name: 'image', type: 'IMAGE', link: null }
|
||||
const latentInput = { name: 'latent', type: 'LATENT', link: null }
|
||||
const imageOutput = fromPartial<INodeOutputSlot>({
|
||||
name: 'image',
|
||||
type: 'IMAGE',
|
||||
links: []
|
||||
})
|
||||
|
||||
expect(input.isValidTarget(imageInput as INodeInputSlot)).toBe(true)
|
||||
expect(input.isValidTarget(latentInput as INodeInputSlot)).toBe(false)
|
||||
expect(input.isValidTarget(imageOutput)).toBe(false)
|
||||
expect(input.isValidTarget(subgraph.outputs[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches widget options by type and numeric constraints', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'strength', type: 'FLOAT' }]
|
||||
})
|
||||
const input = subgraph.inputs[0]
|
||||
input._widget = createWidget()
|
||||
|
||||
expect(input.matchesWidget(createWidget())).toBe(true)
|
||||
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(false)
|
||||
expect(input.matchesWidget(createWidget({ options: { max: 2 } }))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
input._widget = undefined
|
||||
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('disconnects node inputs and removes link references', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
})
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.id = toNodeId(10)
|
||||
subgraph.add(targetNode)
|
||||
const input = targetNode.addInput('image', 'IMAGE')
|
||||
const link = subgraph.inputs[0].connect(input, targetNode)
|
||||
const triggerSpy = vi.spyOn(subgraph, 'trigger')
|
||||
const connectionSpy = vi.fn()
|
||||
targetNode.onConnectionsChange = connectionSpy
|
||||
|
||||
subgraph.inputNode._disconnectNodeInput(targetNode, input, link)
|
||||
|
||||
expect(input.link).toBeNull()
|
||||
expect(subgraph.inputs[0].linkIds).toEqual([])
|
||||
expect(connectionSpy).toHaveBeenCalledWith(
|
||||
NodeSlotType.INPUT,
|
||||
0,
|
||||
false,
|
||||
link,
|
||||
subgraph.inputs[0]
|
||||
)
|
||||
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
|
||||
nodeId: targetNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: link?.id
|
||||
})
|
||||
})
|
||||
})
|
||||