Compare commits

..

11 Commits

Author SHA1 Message Date
huang47
815f325d78 test: replace draw-transcript and default-state change detectors 2026-07-06 12:11:05 -07:00
huang47
1cb19373e0 test: address review feedback on assertions and prop typing 2026-07-03 15:19:50 -07:00
huang47
f0bd83e756 test: replace residual double assertions with fromPartial and typed seams 2026-07-03 14:09:41 -07:00
GitHub Action
b623fc78fc [automated] Apply ESLint and Oxfmt fixes 2026-07-03 17:00:42 +00:00
huang47
ea8b7fbf79 test: replace fromAny/double-casts with fromPartial and fix type errors
- Replace all fromAny<T, unknown>() calls with fromPartial<T>() where the
  partial type constraint is satisfiable
- Replace as unknown as T patterns for TSTypeReference targets with
  fromPartial<T>() calls; use eslint-disable where the target type cannot
  accept a partial (LGraphNode constructor conflict, ReadonlySet undefined)
- Use as unknown as { TypeLiteral } for vi.spyOn on protected methods
  (TSTypeLiteral outer type does not trigger the double-assertion rule)
- Use bracket access (app as unknown as { configuringGraphLevel })
  for private field mutation in tests (TSTypeLiteral outer)
- Replace vi.mock('vue-i18n') with real createI18n instances
- Use real Response instances in comfyHubService tests
- Fix textBaseline: '' to 'alphabetic' in CanvasRenderingContext2D mocks
- Remove pathToRootGraph from Subgraph partials (property does not exist)
- Cast LoadedComfyWorkflow to ComfyWorkflowClass to allow null changeTracker
2026-07-03 09:54:34 -07:00
huang47
caa535e906 test: use getAllByRole instead of container.querySelectorAll for images
Avoids raw DOM access and the accompanying eslint-disable in the
comparison-image assertion.
2026-07-02 14:29:44 -07:00
huang47
3a9470018f test: restore pre-existing comments and assertion untouched by coverage work
Undoes drive-by edits to promotionUtils.test.ts and ComfyHubThumbnailStep.test.ts
that removed existing comments and refactored the comparison-image assertion,
none of which were required by the new coverage tests.
2026-07-02 14:27:36 -07:00
huang47
08da6fae72 test: cover workflow and subgraph stores 2026-07-02 13:22:58 -07:00
Benjamin Lu
2ec2a0e091 feat: attribute payment intent through paywall, checkout, and top-up telemetry (#13363)
## Summary

Answers "why did this user want to pay?" by capturing the triggering
product moment at every paywall/upsell entry point and carrying it
through checkout and success telemetry.

## Changes

- **What**:
- Widen `SubscriptionDialogReason` from 4 coarse values to 13 grounded
intent sources (`subscribe_to_run`, `upgrade_to_add_credits`,
`invite_member_upsell`, `settings_billing_panel`, etc.)
- Fire `app:subscription_required_modal_opened` from
`useSubscriptionDialog` (the choke point all dialog variants pass
through) — the workspace/unified path previously emitted nothing; remove
the now-duplicate emitters in `useSubscription` and
`usePricingTableUrlLoader`
- Add `payment_intent_source` to
`BeginCheckoutMetadata`/`SubscriptionSuccessMetadata`, threaded via the
existing `reason` prop: dialog → `PricingTable` →
`performSubscriptionCheckout` → pending-attempt record, so legacy
`app:monthly_subscription_succeeded` carries intent alongside
`checkout_attempt_id`
- Fire `begin_checkout` on the workspace checkout path
(`useSubscriptionCheckout`, personal + team confirm) and the team
deep-link util — both previously emitted nothing; `tier` widened to
`TierKey | 'team'`
- Implement `trackBeginCheckout` in `PostHogTelemetryProvider` (was
GTM/host-only, so `begin_checkout` never reached PostHog)
- Thread `showSubscriptionDialog(options)` through the billing-context
adapters and pass a reason at ~14 call sites; add `source` to
`app:add_api_credit_button_clicked`

## Review Focus

- `modal_opened` now fires once per dialog actually shown, so a
free-tier user clicking Upgrade emits two events (free-tier dialog, then
pricing table) where the legacy path emitted one
- Intent is threaded explicitly via props/params rather than shared
state; `useSubscriptionCheckout` gained an optional second parameter
2026-07-02 03:11:21 +00:00
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.

## Changes

- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.

## Review Focus

Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.

## Notes from the #13289 review (left as-is here, open to discussion)

Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
jaeone94
9e5fb67b76 Show app mode run validation warning (#12557)
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.

## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.

The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.

## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`

## Screenshots

<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
2026-07-01 15:24:45 +09:00
129 changed files with 12927 additions and 1863 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET = -144
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
>
<slot />
</li>

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,6 +34,10 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -92,13 +96,19 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,6 +172,9 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -0,0 +1,106 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
)
.toBe(1)
})

View File

@@ -3,6 +3,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="seeErrors"
@click="viewErrorsInGraph"
>
{{
appMode
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}

View File

@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
}
</script>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
@@ -75,9 +76,10 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
*/
showSubscriptionDialog: () => void
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
}
export interface BillingState {

View File

@@ -7,6 +7,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
}
return {

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
}
return {

View File

@@ -1,217 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = useGroupMenuOptions().getGroupShapeOptions(
group({ nodes: [node] }),
bump
)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
useGroupMenuOptions()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().getGroupModeOptions(
group({ nodes: undefined }),
vi.fn()
)
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = useGroupMenuOptions().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [node] }),
bump
)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -1,294 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -1,175 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = useNodeCustomization()
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = useNodeCustomization()
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => actions
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -1,238 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToCopy',
detail: 'g.selectItemsToCopy',
life: 3000
})
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'g.copied',
detail: 'g.itemsCopiedToClipboard',
life: 2000
})
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDuplicate',
detail: 'g.selectItemsToDuplicate',
life: 3000
})
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToDelete',
detail: 'g.selectItemsToDelete',
life: 3000
})
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: 'g.nothingToRename',
detail: 'g.selectItemsToRename',
life: 3000
})
})
})

View File

@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -22,9 +17,7 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -103,16 +96,6 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return (
typedNode?.type === 'Load3D' || typedNode?.type === 'Load3DAnimation'
)
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -152,21 +135,6 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -247,13 +215,6 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -1,6 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -14,7 +12,6 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -126,47 +123,6 @@ function createMockNode(
})
}
async function flushMacrotask(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
async function resolveDisplayPrice(
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): Promise<string> {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const startRevision = pricingRevision.value
// Wait on pricingRevision (bumped when async evaluation settles) instead of a
// fixed sleep. A cache hit schedules nothing, so the bounded poll falls through.
getNodeDisplayPrice(node, widgetOverrides)
for (let attempt = 0; attempt < 50; attempt++) {
if (pricingRevision.value !== startRevision) break
await flushMacrotask()
}
return getNodeDisplayPrice(node, widgetOverrides)
}
function createStoredNodeDef(
name: string,
price_badge?: PriceBadge
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
price_badge
} satisfies ComfyNodeDef
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -233,32 +189,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -292,19 +222,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -321,51 +238,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -596,42 +468,6 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -759,86 +595,6 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -987,24 +743,6 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should reuse the cached empty label after runtime failures', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestCachedRuntimeErrorNode',
priceBadge('$lookup(undefined, "key")')
)
expect(await resolveDisplayPrice(node)).toBe('')
const revisionAfterFailure = pricingRevision.value
// A second read of the same signature must reuse the cached empty label
// without scheduling another evaluation (no revision bump).
expect(getNodeDisplayPrice(node)).toBe('')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(getNodeDisplayPrice(node)).toBe('')
expect(pricingRevision.value).toBe(revisionAfterFailure)
})
it('should return empty string for invalid PricingResult type', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -1230,21 +968,8 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
@@ -1274,14 +999,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1300,22 +1017,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1323,11 +1024,6 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1494,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1592,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}

View File

@@ -0,0 +1,105 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

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

View 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 { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
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 {
return createMockLGraphNode({
inputs: [],
isSubgraphNode: () => true,
getSlotFromWidget: vi.fn(),
...overrides
})
}
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)
})
})

View File

@@ -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'])
})
})

View 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([])
})
})

View File

@@ -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,15 @@ type TestAutogrowNode = LGraphNode & {
const { addNodeInput } = useLitegraphService()
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
function mockConfiguringGraph() {
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
}
function nextTick() {
return new Promise<void>((r) => requestAnimationFrame(() => r()))
}
@@ -56,6 +71,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 +148,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: LGraphNode = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets![0]
node.widgets = undefined
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 +499,244 @@ 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, node.inputs[0])
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 }
mockConfiguringGraph()
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 }
mockConfiguringGraph()
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()
})
mockConfiguringGraph()
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 +833,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()

View File

@@ -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'
@@ -54,6 +53,7 @@ function createSourceNode(graph: LGraph, type: string) {
describe('MatchType during configure', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
@@ -73,30 +73,22 @@ describe('MatchType during configure', () => {
const link2Id = switchNode.inputs[1].link!
const outputTypeBefore = switchNode.outputs[0].type
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
try {
const link1 = graph.links[link1Id]
switchNode.onConnectionsChange?.(
LiteGraph.INPUT,
0,
true,
link1,
switchNode.inputs[0]
)
const link1 = graph.links[link1Id]
switchNode.onConnectionsChange?.(
LiteGraph.INPUT,
0,
true,
link1,
switchNode.inputs[0]
)
expect(switchNode.inputs[0].link).toBe(link1Id)
expect(switchNode.inputs[1].link).toBe(link2Id)
expect(graph.links[link1Id]).toBeDefined()
expect(graph.links[link2Id]).toBeDefined()
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
} finally {
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 0
}
expect(switchNode.inputs[0].link).toBe(link1Id)
expect(switchNode.inputs[1].link).toBe(link2Id)
expect(graph.links[link1Id]).toBeDefined()
expect(graph.links[link2Id]).toBeDefined()
expect(switchNode.outputs[0].type).toBe(outputTypeBefore)
})
test('performs type recalculation during normal operation', () => {
@@ -127,4 +119,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()
})
})

View File

@@ -1,16 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
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 +59,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, fromPartial<ComfyApp>(appMock))
}
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')]
@@ -49,7 +122,7 @@ describe('replaceLegacySeparators', () => {
describe('GroupNodeConfig.getLinks', () => {
function configFrom(
links: SerialisedLLinkArray[],
external: (number | string)[][] = []
external: (number | string | null)[][] = []
) {
const nodeData: GroupNodeWorkflowData = {
nodes: [
@@ -104,4 +177,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],
[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'
})
)
})
})

View File

@@ -62,7 +62,7 @@ interface GroupNodeConfigEntry {
}
export interface GroupNodeWorkflowData {
external: (number | string)[][]
external: (number | string | null)[][]
links: SerialisedLLinkArray[]
nodes: {
index?: number

View File

@@ -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,341 @@ 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).toHaveBeenCalled()
expect(eventCtx.arc).not.toHaveBeenCalled()
expect(boxCtx.rect).toHaveBeenCalled()
expect(boxCtx.arc).not.toHaveBeenCalled()
expect(arrowCtx.moveTo).toHaveBeenCalled()
expect(arrowCtx.lineTo).toHaveBeenCalled()
expect(arrowCtx.rect).not.toHaveBeenCalled()
expect(arrowCtx.arc).not.toHaveBeenCalled()
expect(gridCtx.rect.mock.calls.length).toBeGreaterThan(1)
expect(gridCtx.arc).not.toHaveBeenCalled()
expect(lowQualityCtx.rect).toHaveBeenCalled()
expect(lowQualityCtx.arc).not.toHaveBeenCalled()
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 on their own sides', () => {
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.lineTo).toHaveBeenCalled()
expect(outputCtx.lineTo).toHaveBeenCalled()
expect(inputCtx.arc).not.toHaveBeenCalled()
expect(outputCtx.arc).not.toHaveBeenCalled()
const [inputArrowX] = inputCtx.moveTo.mock.calls[0] ?? []
const [outputArrowX] = outputCtx.moveTo.mock.calls[0] ?? []
expect(outputArrowX).toBeGreaterThan(inputArrowX)
})
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).toHaveBeenCalled()
expect(eventCtx.arc).not.toHaveBeenCalled()
expect(circleCtx.arc).toHaveBeenCalled()
expect(circleCtx.rect).not.toHaveBeenCalled()
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)
})
})
})

View File

@@ -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', () => {

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

View File

@@ -0,0 +1,274 @@
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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
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
}
type MenuConfig = { title?: string; event?: CanvasPointerEvent }
const originalContextMenu = LiteGraph.ContextMenu
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()
Object.assign(LiteGraph, { ContextMenu: originalContextMenu })
})
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 contextMenus: MenuConfig[] = []
Object.assign(LiteGraph, {
ContextMenu: class {
constructor(_options: unknown[], config: MenuConfig) {
contextMenus.push(config)
}
}
})
subgraph.inputNode.onPointerDown(
eventAt(20, 25, 2),
{} as CanvasPointer,
{} as LinkConnector
)
subgraph.inputNode.onPointerDown(
eventAt(500, 500, 2),
{} as CanvasPointer,
{} as LinkConnector
)
expect(contextMenus).toHaveLength(1)
expect(contextMenus[0]).toEqual(
expect.objectContaining({
title: slot.name,
event: 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 slotDrawSpy = vi.spyOn(subgraph.inputs[0], 'draw')
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(slotDrawSpy).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
fromSlot: subgraph.inputs[0],
editorAlpha: 0.5,
colorContext: expect.objectContaining({
getConnectedColor: expect.any(Function),
getDisconnectedColor: expect.any(Function)
})
})
)
})
})

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

View File

@@ -6,7 +6,7 @@
*/
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import {
@@ -17,9 +17,13 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -33,6 +37,10 @@ beforeEach(() => {
resetSubgraphFixtureState()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SubgraphNode Construction', () => {
it('should create a SubgraphNode from a subgraph definition', () => {
const subgraph = createTestSubgraph({
@@ -100,6 +108,18 @@ describe('SubgraphNode Construction', () => {
expect(subgraphNode.widgets).toEqual([])
})
it('warns when external code assigns widgets directly', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.widgets = []
expect(warn).toHaveBeenCalledWith(
'Cannot manually set widgets on SubgraphNode; use the promotion system.'
)
})
subgraphTest(
'should synchronize slots with subgraph definition',
({ subgraphWithNode }) => {
@@ -220,6 +240,38 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('throws when input rename events reference a missing slot', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
createTestSubgraphNode(subgraph)
expect(() =>
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 99,
oldName: 'input',
newName: 'missing'
})
).toThrow('Subgraph input not found')
})
it('throws when output rename events reference a missing slot', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'number' }]
})
createTestSubgraphNode(subgraph)
expect(() =>
subgraph.events.dispatch('renaming-output', {
output: subgraph.outputs[0],
index: 99,
oldName: 'output',
newName: 'missing'
})
).toThrow('Subgraph output not found')
})
it('represents promoted host widgets by input widgetId and WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -362,6 +414,41 @@ describe('SubgraphNode Synchronization', () => {
})
})
it('falls back projected widget fields when WidgetState is missing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widget = subgraphNode.widgets[0]
const id = promotedInput.widgetId
if (!id) throw new Error('Missing widgetId')
if (!widget) throw new Error('Missing projected widget')
useWidgetValueStore().deleteWidget(id)
expect(widget.name).toBe('text')
expect(widget.label).toBe('text')
expect(widget.y).toBe(0)
expect(widget.type).toBe('text')
expect(widget.options).toEqual({})
expect(widget.value).toBeUndefined()
expect(() => {
widget.label = 'Label'
widget.y = 12
widget.callback?.('updated')
}).not.toThrow()
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -443,6 +530,111 @@ describe('SubgraphNode Synchronization', () => {
'My Seed'
)
})
it('keeps rename behavior when widget state has been removed', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widgetId = promotedInput.widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().deleteWidget(widgetId)
subgraph.renameInput(subgraph.inputs[0], 'Renamed Text')
expect(promotedInput.label).toBe('Renamed Text')
expect(useWidgetValueStore().getWidget(widgetId)).toBeUndefined()
})
it('rebinds promoted widgets when subgraph input objects are recreated', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
interiorNode.id = toNodeId(5)
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const originalSlot = subgraphNode.inputs[0]._subgraphSlot
const originalWidgetId = subgraphNode.inputs[0].widgetId
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
expect(subgraphNode.inputs).toHaveLength(1)
expect(subgraphNode.inputs[0]._subgraphSlot).toBe(subgraph.inputs[0])
expect(subgraphNode.inputs[0]._subgraphSlot).not.toBe(originalSlot)
expect(subgraphNode.inputs[0].widgetId).toBe(originalWidgetId)
expect(subgraphNode.widgets[0]).toMatchObject({
name: 'text',
value: 'initial'
})
})
it('stores DOM widget metadata from custom promoted host widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'dom', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
const interiorWidget = interiorNode.addWidget(
'text',
'value',
'initial',
() => {}
)
Object.assign(interiorWidget, { isDOMWidget: true })
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostWidget = fromPartial<IBaseWidget>({
name: 'host',
type: 'text',
value: 'host value',
options: {},
y: 0
})
class HostWidgetSubgraphNode extends SubgraphNode {
protected override createPromotedHostWidget() {
return hostWidget
}
}
const subgraphNode = new HostWidgetSubgraphNode(
subgraph.rootGraph,
subgraph,
fromPartial<ExportedSubgraphInstance>({
id: 10,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
properties: {}
})
)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
expect(subgraphNode.widgets).toEqual([hostWidget])
expect(useWidgetValueStore().getWidget(widgetId)).toMatchObject({
isDOMWidget: true
})
})
})
describe('SubgraphNode widget name collision on rename', () => {
@@ -658,6 +850,31 @@ describe('SubgraphNode Lifecycle', () => {
})
describe('SubgraphNode Basic Functionality', () => {
it('opens subgraphs from the title button and delegates other buttons', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const canvas = fromPartial<
Parameters<SubgraphNode['onTitleButtonClick']>[1]
>({
openSubgraph: vi.fn()
})
const fallback = vi
.spyOn(LGraphNode.prototype, 'onTitleButtonClick')
.mockImplementation(() => undefined)
subgraphNode.onTitleButtonClick(
fromPartial({ name: 'enter_subgraph' }),
canvas
)
subgraphNode.onTitleButtonClick(fromPartial({ name: 'other' }), canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(fallback).toHaveBeenCalledWith(
fromPartial({ name: 'other' }),
canvas
)
})
it('should inherit input types correctly', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -687,6 +904,157 @@ describe('SubgraphNode Basic Functionality', () => {
expect(subgraphNode.outputs[1].type).toBe('string')
expect(subgraphNode.outputs[2].type).toBe('*')
})
it('delegates title box drawing to a custom handler', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const onDrawTitleBox = vi.fn()
subgraphNode.onDrawTitleBox = onDrawTitleBox
const ctx = fromPartial<CanvasRenderingContext2D>({})
subgraphNode.drawTitleBox(ctx, {
scale: 2,
low_quality: false,
title_height: 30,
box_size: 12
})
expect(onDrawTitleBox).toHaveBeenCalledWith(
ctx,
30,
subgraphNode.renderingSize,
2
)
})
it('draws the default title box with and without the bitmap icon', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const ctx = fromPartial<CanvasRenderingContext2D>({
save: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
drawImage: vi.fn(),
restore: vi.fn()
})
subgraphNode.drawTitleBox(ctx, { scale: 1 })
subgraphNode.drawTitleBox(ctx, { scale: 1, low_quality: true })
expect(ctx.roundRect).toHaveBeenCalledWith(6, -24.5, 22, 20, 5)
expect(ctx.drawImage).toHaveBeenCalledTimes(1)
expect(ctx.restore).toHaveBeenCalledTimes(2)
})
it('returns undefined when a widgetId does not match a promoted input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
expect(
subgraphNode.getSlotFromWidget(
fromPartial<IBaseWidget>({
name: 'missing',
type: 'text',
value: '',
widgetId: 'missing-widget' as WidgetId
})
)
).toBeUndefined()
})
it('returns null for missing inner input links', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(subgraphNode.getInputLink(0)).toBeNull()
})
it('returns a translated input link for connected subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.id = toNodeId(9)
inner.addOutput('image', 'IMAGE')
subgraph.add(inner)
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
const link = subgraphNode.getInputLink(0)
expect(link?.origin_id).toBe(toNodeId(`${subgraphNode.id}:${inner.id}`))
expect(link?.origin_slot).toBe(0)
})
it('returns empty resolved input links when the subgraph input is isolated', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([])
})
it('returns resolved input links when the subgraph input is connected', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.id = toNodeId(9)
const input = inner.addInput('image', 'IMAGE')
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([
expect.objectContaining({
input,
inputNode: inner
})
])
})
it('returns resolved output links when the subgraph output is connected', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.addOutput('image', 'IMAGE')
subgraph.add(inner)
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
expect(subgraphNode.resolveSubgraphOutputLink(0)?.outputNode).toBe(inner)
})
it('returns a consistent slot shape only when all inner shapes match', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const slot = subgraph.inputs[0]
expect(subgraphNode.getSlotShape(slot, fromPartial({ shape: 4 }))).toBe(4)
const node = new LGraphNode('ShapeTarget')
const rounded = node.addInput('rounded', 'IMAGE')
const boxed = node.addInput('boxed', 'IMAGE')
rounded.shape = 4
boxed.shape = 3
subgraph.add(node)
slot.connect(rounded, node)
expect(subgraphNode.getSlotShape(slot, boxed)).toBeUndefined()
})
})
describe('SubgraphNode Execution', () => {
@@ -776,6 +1144,27 @@ describe('SubgraphNode Execution', () => {
expect(() => subgraph.add(subgraphNode)).toThrow()
})
it('throws a recursion error when traversal revisits the same subgraph node', () => {
const subgraph = createTestSubgraph({ name: '' })
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.title = 'Recursive Host'
expect(() =>
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
).toThrow('Circular reference detected')
})
it('describes unnamed recursive subgraph nodes', () => {
const subgraph = createTestSubgraph()
subgraph.name = ''
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.title = ''
expect(() =>
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
).toThrow("node 1 of subgraph 'Unnamed Subgraph'")
})
it('should resolve cross-boundary links', () => {
// This test verifies that links can cross subgraph boundaries
// Currently this is a basic test - full cross-boundary linking
@@ -801,6 +1190,171 @@ describe('SubgraphNode Execution', () => {
})
})
describe('SubgraphNode preview exposure hydration', () => {
it('hydrates explicit preview exposure properties', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
subgraphNode.configure({
...subgraphNode.serialize(),
properties: {
previewExposures: [
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$preview'
}
]
}
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([
{
name: 'preview',
sourceNodeId: toNodeId(12),
sourcePreviewName: '$$preview'
}
])
})
it('clears exposures when an explicit empty property is serialized', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
store.addExposure(subgraphNode.rootGraph.id, String(subgraphNode.id), {
sourceNodeId: '12',
sourcePreviewName: '$$preview'
})
subgraphNode.configure({
...subgraphNode.serialize(),
properties: { previewExposures: [] }
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([])
})
it('hydrates legacy locator exposures when no explicit property exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
const legacyLocator = createNodeLocatorId(null, subgraphNode.id)
store.addExposure(subgraphNode.rootGraph.id, legacyLocator, {
sourceNodeId: '12',
sourcePreviewName: '$$legacy'
})
subgraphNode.configure({
...subgraphNode.serialize(),
properties: {}
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([
expect.objectContaining({
sourceNodeId: toNodeId(12),
sourcePreviewName: '$$legacy'
})
])
})
})
describe('SubgraphNode serialization', () => {
it('serializes promoted widget values and valid quarantine entries', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().setValue(widgetId, 42)
subgraphNode.properties.proxyWidgetErrorQuarantine = [
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 7
}
]
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toEqual([42])
expect(serialized.properties?.proxyWidgetErrorQuarantine).toEqual([
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 7
}
])
})
it('uses quarantined host values before serialized widget values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
subgraphNode.configure({
...subgraphNode.serialize(),
widgets_values: [11],
properties: {
proxyWidgetErrorQuarantine: [
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 55
}
]
}
} as ExportedSubgraphInstance)
expect(useWidgetValueStore().getWidget(widgetId)?.value).toBe(55)
})
it('omits widget values when promoted widget state is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().getWidget(widgetId)!.value = undefined
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
})
describe('SubgraphNode Edge Cases', () => {
it('should handle deep nesting', () => {
// Create a simpler deep nesting test that works with current implementation
@@ -951,6 +1505,26 @@ describe('SubgraphNode Cleanup', () => {
expect(abortSpy1).toHaveBeenCalledTimes(1)
expect(abortSpy2).toHaveBeenCalledTimes(1)
})
it('removes promoted widgets even when an input listener is absent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const onRemove = vi.fn()
subgraphNode.inputs[0]._widget = fromPartial<IBaseWidget>({
name: 'input',
type: 'number',
options: {},
y: 0,
onRemove
})
delete subgraphNode.inputs[0]._listenerController
subgraphNode.onRemoved()
expect(onRemove).toHaveBeenCalledOnce()
})
})
describe('SubgraphNode duplicate input pruning (#9977)', () => {
@@ -1076,6 +1650,49 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
expect(node.inputs).toHaveLength(2)
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
})
it('rebinds duplicate serialized inputs by signature and then by name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'same', type: 'STRING' },
{ name: 'same', type: 'STRING' },
{ name: 'loose', type: 'INT' }
]
})
const node = new SubgraphNode(
subgraph.rootGraph,
subgraph,
fromPartial<ExportedSubgraphInstance>({
id: 1,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
inputs: [
{ name: 'same', type: 'STRING', link: null },
{ name: 'same', type: 'STRING', link: null },
{ name: 'loose', type: 'FLOAT', link: null },
{ name: 'missing', type: 'BOOLEAN', link: null }
],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
})
)
expect(node.inputs.map((input) => input.name)).toEqual([
'same',
'same',
'loose'
])
expect(node.inputs.map((input) => input._subgraphSlot)).toEqual([
subgraph.inputs[0],
subgraph.inputs[1],
subgraph.inputs[2]
])
})
})
describe('SubgraphNode label propagation', () => {

View File

@@ -0,0 +1,246 @@
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,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
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
}
type MenuConfig = { title?: string; event?: CanvasPointerEvent }
const originalContextMenu = LiteGraph.ContextMenu
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('SubgraphOutputNode', () => {
beforeEach(() => {
vi.restoreAllMocks()
Object.assign(LiteGraph, { ContextMenu: originalContextMenu })
})
it('exposes output slots plus the empty slot and computes its anchor', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.outputNode.configure({
id: subgraph.outputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
expect(subgraph.outputNode.slots).toBe(subgraph.outputs)
expect(subgraph.outputNode.allSlots).toEqual([
subgraph.outputs[0],
subgraph.outputNode.emptySlot
])
expect(subgraph.outputNode.slotAnchorX).toBe(24)
})
it('sets link connector drag callbacks for left-clicked slots', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const pointer = {} as CanvasPointer
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphOutput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
subgraph.outputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
pointer.onDragStart?.(pointer)
pointer.onDragEnd?.(eventAt(40, 45))
pointer.finally?.()
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
subgraph,
subgraph.outputNode,
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({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const contextMenus: MenuConfig[] = []
Object.assign(LiteGraph, {
ContextMenu: class {
constructor(_options: unknown[], config: MenuConfig) {
contextMenus.push(config)
}
}
})
subgraph.outputNode.onPointerDown(
eventAt(20, 25, 2),
{} as CanvasPointer,
{} as LinkConnector
)
subgraph.outputNode.onPointerDown(
eventAt(500, 500, 2),
{} as CanvasPointer,
{} as LinkConnector
)
expect(contextMenus).toHaveLength(1)
expect(contextMenus[0]).toEqual(
expect.objectContaining({
title: slot.name,
event: expect.objectContaining({ button: 2 })
})
)
})
it('renames and removes output slots through the parent subgraph', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
const renameSpy = vi.spyOn(subgraph, 'renameOutput')
const removeSpy = vi.spyOn(subgraph, 'removeOutput')
subgraph.outputNode.renameSlot(slot, 'preview')
subgraph.outputNode.removeSlot(slot)
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
expect(removeSpy).toHaveBeenCalledWith(slot)
})
it('delegates connection checks and output-type connections', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
const outputSlot = fromPartial<{ index: number; slot: INodeOutputSlot }>({
index: 0,
slot: { name: 'out', type: 'IMAGE' }
})
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(99)
vi.spyOn(targetNode, 'findOutputByType').mockReturnValue(outputSlot)
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
const outputNode = fromPartial<NodeLike>({
canConnectTo: vi.fn(() => true)
})
expect(
subgraph.outputNode.canConnectTo(outputNode, slot, outputSlot.slot)
).toBe(true)
expect(
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'IMAGE', {
afterRerouteId: toRerouteId(7)
})
).toBe(link)
expect(connectSpy).toHaveBeenCalledWith(
outputSlot.slot,
targetNode,
toRerouteId(7)
)
vi.mocked(targetNode.findOutputByType).mockReturnValue(undefined)
expect(
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'LATENT')
).toBeUndefined()
})
it('finds the first free output slot of a matching type', () => {
const subgraph = createTestSubgraph({
outputs: [
{ name: 'used', type: 'IMAGE' },
{ name: 'free', type: 'IMAGE' }
]
})
subgraph.outputs[0].linkIds.push(toLinkId(1))
expect(subgraph.outputNode.findInputByType('IMAGE')).toBe(
subgraph.outputs[0]
)
expect(subgraph.outputNode.findInputByType('LATENT')).toBeUndefined()
})
it('draws the side rail and output slots', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.outputNode.configure({
id: subgraph.outputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
const ctx = createCanvasContext()
const slotDrawSpy = vi.spyOn(subgraph.outputs[0], 'draw')
subgraph.outputNode.drawProtected(
ctx,
fromPartial<DefaultConnectionColors>({
getConnectedColor: vi.fn(() => '#fff'),
getDisconnectedColor: vi.fn(() => '#000')
}),
subgraph.outputs[0],
0.5
)
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
expect(ctx.beginPath).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.setTransform).toHaveBeenCalled()
expect(slotDrawSpy).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
fromSlot: subgraph.outputs[0],
editorAlpha: 0.5,
colorContext: expect.objectContaining({
getConnectedColor: expect.any(Function),
getDisconnectedColor: expect.any(Function)
})
})
)
})
})

View File

@@ -0,0 +1,168 @@
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 { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
describe('SubgraphOutput', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('connects node outputs to subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
sourceNode.id = toNodeId(10)
subgraph.add(sourceNode)
const output = sourceNode.addOutput('image', 'IMAGE')
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
const connectionSpy = vi.fn()
sourceNode.onConnectionsChange = connectionSpy
const link = subgraph.outputs[0].connect(output, sourceNode, toRerouteId(5))
expect(link).toBeInstanceOf(LLink)
expect(link?.origin_id).toBe(sourceNode.id)
expect(link?.target_id).toBe(subgraph.outputNode.id)
expect(link?.parentId).toBe(toRerouteId(5))
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
expect(output.links).toEqual([link?.id])
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBe(link)
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.OUTPUT,
0,
true,
link,
output
)
expect(afterChangeSpy).toHaveBeenCalled()
})
it('does not connect incompatible or blocked node outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
const latentOutput = sourceNode.addOutput('latent', 'LATENT')
expect(
subgraph.outputs[0].connect(latentOutput, sourceNode)
).toBeUndefined()
const imageOutput = sourceNode.addOutput('image', 'IMAGE')
sourceNode.onConnectOutput = vi.fn(() => false)
expect(subgraph.outputs[0].connect(imageOutput, sourceNode)).toBeUndefined()
})
it('throws when the output slot is not owned by the node', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
const foreignOutput = { name: 'image', type: 'IMAGE' } as INodeOutputSlot
expect(() =>
subgraph.outputs[0].connect(foreignOutput, sourceNode)
).toThrow('Slot is not an output of the given node')
})
it('disconnects existing links before accepting a replacement', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const firstNode = new LGraphNode('First')
firstNode.id = toNodeId(10)
subgraph.add(firstNode)
const firstOutput = firstNode.addOutput('image', 'IMAGE')
const firstLink = subgraph.outputs[0].connect(firstOutput, firstNode)
const secondNode = new LGraphNode('Second')
secondNode.id = toNodeId(11)
subgraph.add(secondNode)
const secondOutput = secondNode.addOutput('image', 'IMAGE')
const beforeChangeSpy = vi.spyOn(subgraph, 'beforeChange')
const secondLink = subgraph.outputs[0].connect(secondOutput, secondNode)
expect(beforeChangeSpy).toHaveBeenCalled()
expect(firstOutput.links).not.toContain(firstLink?.id)
expect(subgraph.outputs[0].linkIds).toEqual([secondLink?.id])
expect(secondOutput.links).toEqual([secondLink?.id])
})
it('arranges and labels from the left edge', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const output = subgraph.outputs[0]
output.arrange([20, 30, 120, 40])
expect(Array.from(output.boundingRect)).toEqual([20, 30, 120, 40])
expect(output.pos).toEqual([40, 50])
expect(output.labelPos).toEqual([60, 50])
})
it('validates output slots and subgraph inputs as targets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'source', type: 'IMAGE' }],
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const output = subgraph.outputs[0]
const imageOutput = fromPartial<INodeOutputSlot>({
name: 'image',
type: 'IMAGE',
links: []
})
const latentOutput = fromPartial<INodeOutputSlot>({
name: 'latent',
type: 'LATENT',
links: []
})
const imageInput = { name: 'image', type: 'IMAGE', link: null }
expect(output.isValidTarget(imageOutput)).toBe(true)
expect(output.isValidTarget(latentOutput)).toBe(false)
expect(output.isValidTarget(imageInput as INodeInputSlot)).toBe(false)
expect(output.isValidTarget(subgraph.inputs[0])).toBe(true)
})
it('disconnects links and notifies output nodes', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
sourceNode.id = toNodeId(10)
subgraph.add(sourceNode)
const output = sourceNode.addOutput('image', 'IMAGE')
const link = subgraph.outputs[0].connect(output, sourceNode)
const removeLinkSpy = vi.spyOn(subgraph, 'removeLink')
const connectionSpy = vi.fn()
sourceNode.onConnectionsChange = connectionSpy
subgraph.outputs[0].disconnect()
expect(removeLinkSpy).toHaveBeenCalledWith(link?.id)
expect(output.links).not.toContain(link?.id)
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.OUTPUT,
0,
false,
link,
subgraph.outputs[0]
)
expect(subgraph.outputs[0].linkIds).toEqual([])
})
})

View File

@@ -2,11 +2,18 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ExportedSubgraph } from '../types/serialisation'
import type { LGraphState } from '@/lib/litegraph/src/LGraph'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { topologicalSortSubgraphs } from './subgraphDeduplication'
import type { ExportedSubgraph, ISerialisedNode } from '../types/serialisation'
import {
deduplicateSubgraphNodeIds,
topologicalSortSubgraphs
} from './subgraphDeduplication'
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
return {
@@ -32,6 +39,196 @@ function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
} as ExportedSubgraph
}
describe('deduplicateSubgraphNodeIds', () => {
it('remaps duplicate IDs in nodes, links, promoted widgets, and root proxy widgets', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = makeSubgraph('inner')
subgraph.nodes = [
{
id: 1,
type: 'Source',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
},
{
id: 2,
type: 'Target',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
subgraph.links = [
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: '*'
}
]
subgraph.widgets = [
{
id: 1,
name: 'text'
}
]
const rootNodes: ISerialisedNode[] = [
{
id: 10,
type: 'inner',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {
proxyWidgets: [[1, 'text'], 'not-an-entry']
}
},
{
id: 11,
type: 'Other',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {
proxyWidgets: [[1, 'text']]
}
}
]
const state: LGraphState = {
lastNodeId: 2,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
const result = deduplicateSubgraphNodeIds(
[subgraph],
new Set([1]),
state,
rootNodes
)
expect(result.subgraphs[0].nodes?.[0].id).toBe(3)
expect(result.subgraphs[0].links?.[0]).toMatchObject({
origin_id: 3,
target_id: 2
})
expect(result.subgraphs[0].widgets?.[0].id).toBe(3)
expect(result.rootNodes?.[0].properties?.proxyWidgets).toEqual([
['3', 'text'],
'not-an-entry'
])
expect(result.rootNodes?.[1].properties?.proxyWidgets).toEqual([
[1, 'text']
])
expect(subgraph.nodes?.[0].id).toBe(1)
expect(rootNodes[0].properties?.proxyWidgets).toEqual([
[1, 'text'],
'not-an-entry'
])
expect(state.lastNodeId).toBe(3)
expect(warn).toHaveBeenCalledWith(
'LiteGraph: duplicate subgraph node ID 1 remapped to 3'
)
warn.mockRestore()
})
it('tracks numeric IDs without root nodes and ignores non-numeric IDs', () => {
const subgraph = makeSubgraph('ids')
subgraph.nodes = [
{
id: '9',
type: 'NumericString',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
},
{
id: 'alpha',
type: 'NamedNode',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
const state: LGraphState = {
lastNodeId: 1,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
const result = deduplicateSubgraphNodeIds([subgraph], new Set(), state)
expect(result.rootNodes).toBeUndefined()
expect(result.subgraphs[0].nodes?.map((node) => node.id)).toEqual([
'9',
'alpha'
])
expect(state.lastNodeId).toBe(9)
})
it('throws when the numeric node ID space is exhausted', () => {
const subgraph = makeSubgraph('full')
subgraph.nodes = [
{
id: 1,
type: 'Duplicate',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
const state: LGraphState = {
lastNodeId: 100_000_000,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
expect(() =>
deduplicateSubgraphNodeIds([subgraph], new Set([1]), state)
).toThrow('Node ID space exhausted')
})
})
describe('topologicalSortSubgraphs', () => {
it('returns original order when there are no dependencies', () => {
const a = makeSubgraph('a')
@@ -77,4 +274,11 @@ describe('topologicalSortSubgraphs', () => {
it('returns original order for empty array', () => {
expect(topologicalSortSubgraphs([])).toEqual([])
})
it('returns original order when dependencies contain a cycle', () => {
const a = makeSubgraph('a', ['b'])
const b = makeSubgraph('b', ['a'])
expect(topologicalSortSubgraphs([a, b])).toEqual([a, b])
})
})

View File

@@ -1,26 +1,60 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphGroup,
findUsedSubgraphIds,
getDirectSubgraphIds
getDirectSubgraphIds,
LGraphNode,
LLink,
Reroute
} from '@/lib/litegraph/src/litegraph'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { UUID } from '@/lib/litegraph/src/litegraph'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
import {
getBoundaryLinks,
groupResolvedByOutput,
isNodeSlot,
isSubgraphInput,
isSubgraphOutput,
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
reorderSubgraphInputs,
splitPositionables
} from './subgraphUtils'
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.restoreAllMocks()
})
function makeNode(title: string): LGraphNode {
const node = new LGraphNode(title)
node.addInput('in', 'STRING')
node.addOutput('out', 'STRING')
return node
}
describe('getDirectSubgraphIds', () => {
it('should return empty set for graph with no subgraph nodes', () => {
const graph = new LGraph()
@@ -144,5 +178,444 @@ describe('subgraphUtils', () => {
expect(result.has(subgraph1.id)).toBe(true)
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
})
it('does not revisit subgraphs that were already discovered', () => {
const rootGraph = new LGraph()
const shared = createTestSubgraph({ name: 'Shared' })
const nestedParent = createTestSubgraph({ name: 'Nested parent' })
rootGraph.add(createTestSubgraphNode(shared))
rootGraph.add(createTestSubgraphNode(nestedParent))
nestedParent.add(createTestSubgraphNode(shared))
const result = findUsedSubgraphIds(
rootGraph,
new Map([
[shared.id, shared],
[nestedParent.id, nestedParent]
])
)
expect([...result]).toEqual([shared.id, nestedParent.id])
})
})
describe('splitPositionables', () => {
it('places each known positionable type into its own set', () => {
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
const node = new LGraphNode('Node')
const group = new LGraphGroup('Group')
const reroute = new Reroute(toRerouteId(1), new LGraph())
const unknown = fromPartial<Positionable>({ boundingRect: [0, 0, 1, 1] })
const result = splitPositionables([
node,
group,
reroute,
subgraph.inputNode,
subgraph.outputNode,
unknown
])
expect(result.nodes.has(node)).toBe(true)
expect(result.groups.has(group)).toBe(true)
expect(result.reroutes.has(reroute)).toBe(true)
expect(result.subgraphInputNodes.has(subgraph.inputNode)).toBe(true)
expect(result.subgraphOutputNodes.has(subgraph.outputNode)).toBe(true)
expect(result.unknown.has(unknown)).toBe(true)
})
})
describe('getBoundaryLinks', () => {
it('classifies selected node links by internal and boundary direction', () => {
const graph = new LGraph()
const source = makeNode('Source')
const selected = makeNode('Selected')
const selectedTarget = makeNode('Selected target')
const externalTarget = makeNode('External target')
graph.add(source)
graph.add(selected)
graph.add(selectedTarget)
graph.add(externalTarget)
const boundaryInput = source.connect(0, selected, 0)!
const internal = selected.connect(0, selectedTarget, 0)!
const boundaryOutput = selected.connect(0, externalTarget, 0)!
const result = getBoundaryLinks(
graph,
new Set([selected, selectedTarget])
)
expect(result.boundaryInputLinks).toEqual([boundaryInput])
expect(result.internalLinks).toEqual([internal])
expect(result.boundaryOutputLinks).toEqual([boundaryOutput])
expect(result.boundaryLinks).toEqual([])
expect(result.boundaryFloatingLinks).toEqual([])
})
it('ignores unresolved input links and warns with the missing id', () => {
const graph = new LGraph()
const node = makeNode('Node')
graph.add(node)
node.inputs[0].link = toLinkId(404)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = getBoundaryLinks(graph, new Set([node]))
expect(result.internalLinks).toEqual([])
expect(warn).toHaveBeenCalledWith('Failed to resolve link ID [404]')
})
it('treats reroutes with outside participants as boundary links', () => {
const graph = new LGraph()
const source = makeNode('Source')
const target = makeNode('Target')
graph.add(source)
graph.add(target)
const link = source.connect(0, target, 0)!
const reroute = new Reroute(toRerouteId(1), graph, [10, 10], undefined, [
link.id
])
link.parentId = reroute.id
graph.reroutes.set(reroute.id, reroute)
const result = getBoundaryLinks(graph, new Set([reroute]))
expect(result.boundaryLinks).toEqual([link])
})
it('handles unlinked nodes, groups, subgraph-input links, and floating links', () => {
const graph = new LGraph()
const selected = makeNode('Selected')
const group = new LGraphGroup('Group')
graph.add(selected)
const subgraphInputLink = new LLink(
toLinkId(80),
'STRING',
SUBGRAPH_INPUT_ID,
0,
selected.id,
0
)
graph.links.set(subgraphInputLink.id, subgraphInputLink)
selected.inputs[0].link = subgraphInputLink.id
const floatingLink = new LLink(toLinkId(81), 'STRING', 1, 0, 2, 0)
const outsideReroute = new Reroute(toRerouteId(8), graph, [0, 0])
floatingLink.parentId = outsideReroute.id
graph.reroutes.set(outsideReroute.id, outsideReroute)
selected.outputs[0]._floatingLinks = new Set([floatingLink])
const result = getBoundaryLinks(graph, new Set([selected, group]))
expect(result.boundaryInputLinks).toEqual([subgraphInputLink])
expect(result.boundaryFloatingLinks).toEqual([floatingLink])
expect(result.boundaryOutputLinks).toEqual([])
})
})
describe('multiClone', () => {
it('falls back to cloned serialized data when a node type cannot be created', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('Fallback')
node.type = 'missing/type'
node.properties = { nested: { value: 1 } }
const result = multiClone([node])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ type: 'missing/type' })
expect(result[0].properties).toEqual({ nested: { value: 1 } })
expect(result[0].properties).not.toBe(node.properties)
expect(warn).toHaveBeenCalledWith('Failed to create node', 'missing/type')
})
})
describe('groupResolvedByOutput', () => {
it('groups connections by subgraph input before regular output', () => {
const subgraph = createTestSubgraph({ inputCount: 1 })
const output = { name: 'out' }
const first = {
subgraphInput: subgraph.inputs[0],
output,
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
} as ResolvedConnection
const second = {
subgraphInput: subgraph.inputs[0],
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
} as ResolvedConnection
const result = groupResolvedByOutput([first, second])
expect(result.get(subgraph.inputs[0])).toEqual([first, second])
expect(result.has(output)).toBe(false)
})
it('keeps unresolved output connections in separate groups', () => {
const first = {
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
} as ResolvedConnection
const second = {
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
} as ResolvedConnection
const result = groupResolvedByOutput([first, second])
expect(result.size).toBe(2)
expect([...result.values()]).toEqual([[first], [second]])
})
})
describe('mapSubgraphInputsAndLinks', () => {
it('creates unique input metadata and rewrites link origins', () => {
const targetInput = makeNode('Target').inputs[0]
targetInput.localized_name = 'Prompt'
targetInput.label = 'Prompt label'
const link = new LLink(toLinkId(1), 'STRING', 10, 0, 20, 0)
const connection = {
link,
input: targetInput
} as ResolvedConnection
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks([connection], links, new Map())
expect(inputs).toHaveLength(1)
expect(inputs[0]).toMatchObject({
name: 'in',
localized_name: 'Prompt',
label: 'Prompt label',
type: 'STRING',
linkIds: [toLinkId(1)]
})
expect(links[0]).toMatchObject({
origin_id: SUBGRAPH_INPUT_ID,
origin_slot: 0,
target_id: 20,
target_slot: 0
})
})
it('restores the original link parent while mapping reroutes', () => {
const targetInput = makeNode('Target').inputs[0]
const link = new LLink(
toLinkId(1),
'STRING',
10,
0,
20,
0,
toRerouteId(2)
)
const first = new Reroute(
toRerouteId(1),
new LGraph(),
undefined,
toRerouteId(99)
)
const second = new Reroute(
toRerouteId(2),
new LGraph(),
undefined,
first.id
)
const links: SerialisableLLink[] = []
mapSubgraphInputsAndLinks(
[{ link, input: targetInput } as ResolvedConnection],
links,
new Map([
[first.id, first],
[second.id, second]
])
)
expect(link.parentId).toBe(toRerouteId(99))
expect(links[0].parentId).toBe(second.id)
expect(first.parentId).toBeUndefined()
expect(second.parentId).toBe(first.id)
})
it('skips unresolved input connections and uniquifies duplicate names', () => {
const firstInput = makeNode('First').inputs[0]
firstInput.localized_name = 'Prompt'
const secondInput = makeNode('Second').inputs[0]
secondInput.localized_name = 'Prompt'
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks(
[
{ link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0) },
{
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0),
input: firstInput
},
{
link: new LLink(toLinkId(3), 'STRING', 1, 0, 4, 0),
input: secondInput
}
] as ResolvedConnection[],
links,
new Map()
)
expect(inputs.map((input) => input.name)).toEqual(['in', 'in_1'])
expect(inputs.map((input) => input.localized_name)).toEqual([
'Prompt',
'Prompt_1'
])
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
})
})
describe('mapSubgraphOutputsAndLinks', () => {
it('creates unique output metadata and rewrites link targets', () => {
const output = makeNode('Source').outputs[0]
output.type = 'IMAGE'
output.localized_name = 'Image'
output.label = 'Image label'
const link = new LLink(toLinkId(1), 'IMAGE', 10, 0, 20, 0)
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks(
[{ link, output } as ResolvedConnection],
links,
new Map()
)
expect(outputs).toHaveLength(1)
expect(outputs[0]).toMatchObject({
name: 'out',
localized_name: 'Image',
label: 'Image label',
type: 'IMAGE',
linkIds: [toLinkId(1)]
})
expect(links[0]).toMatchObject({
origin_id: 10,
origin_slot: 0,
target_id: SUBGRAPH_OUTPUT_ID,
target_slot: 0
})
})
it('skips unresolved output connections and uniquifies duplicate names', () => {
const firstOutput = makeNode('First').outputs[0]
firstOutput.localized_name = 'Image'
const secondOutput = makeNode('Second').outputs[0]
secondOutput.localized_name = 'Image'
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks(
[
{ link: new LLink(toLinkId(1), 'IMAGE', 1, 0, 2, 0) },
{
link: new LLink(toLinkId(2), 'IMAGE', 1, 0, 3, 0),
output: firstOutput
},
{
link: new LLink(toLinkId(3), 'IMAGE', 1, 0, 4, 0),
output: secondOutput
}
] as ResolvedConnection[],
links,
new Map()
)
expect(outputs.map((output) => output.name)).toEqual(['out', 'out_1'])
expect(outputs.map((output) => output.localized_name)).toEqual([
'Image',
'Image_1'
])
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
})
})
describe('reorderSubgraphInputs', () => {
it('returns when the host has no subgraph', () => {
expect(() =>
reorderSubgraphInputs(
fromPartial<Parameters<typeof reorderSubgraphInputs>[0]>({}),
[]
)
).not.toThrow()
})
it('logs and leaves inputs unchanged for invalid permutations', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
reorderSubgraphInputs(host, [1, 1])
expect(subgraph.inputs.map((input) => input.name)).toEqual([
'first',
'second'
])
expect(error).toHaveBeenCalledWith(
'reorderSubgraphInputs: orderedIndices must be a permutation of 0..1',
[1, 1]
)
})
it('dispatches reorder details when the input order changes', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
reorderSubgraphInputs(host, [1, 0])
expect(dispatch).toHaveBeenCalledWith('inputs-reordered', {
subgraph,
oldOrder: expect.any(Array),
newOrder: expect.any(Array)
})
expect(subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first'
])
})
it('does not dispatch when the input order is unchanged', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
subgraph.inputs[0].linkIds.push(toLinkId(404))
host.inputs[0].link = toLinkId(405)
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
reorderSubgraphInputs(host, [0, 1])
expect(dispatch).not.toHaveBeenCalled()
})
})
describe('slot type guards', () => {
it('identifies subgraph slots and node slots', () => {
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
const node = makeNode('Node')
expect(isSubgraphInput(subgraph.inputs[0])).toBe(true)
expect(isSubgraphInput(subgraph.outputs[0])).toBe(false)
expect(isSubgraphOutput(subgraph.outputs[0])).toBe(true)
expect(isSubgraphOutput(node.outputs[0])).toBe(false)
expect(isNodeSlot(node.inputs[0])).toBe(true)
expect(isNodeSlot(node.outputs[0])).toBe(true)
expect(isNodeSlot(null)).toBe(false)
expect(isNodeSlot({})).toBe(false)
})
})
})

View File

@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { LGraphNode } from '../LGraphNode'
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
function nodeFixture(
title: string,
pos: [number, number],
size: [number, number]
): LGraphNode {
const graphNode = {
title,
pos,
size,
setPos: vi.fn((x: number, y: number) => {
graphNode.pos = [x, y]
})
}
return createMockLGraphNode(graphNode)
}
describe('arrange utilities', () => {
it('returns null when no boundary node is available', () => {
expect(getBoundaryNodes([])).toBeNull()
expect(getBoundaryNodes(undefined)).toBeNull()
})
it('finds the furthest node in each direction', () => {
const top = nodeFixture('top', [10, -10], [20, 20])
const right = nodeFixture('right', [100, 0], [50, 20])
const bottom = nodeFixture('bottom', [0, 80], [20, 60])
const left = nodeFixture('left', [-20, 0], [10, 10])
expect(getBoundaryNodes([top, right, bottom, left])).toEqual({
top,
right,
bottom,
left
})
})
it('does not distribute zero or one node', () => {
expect(distributeNodes([])).toEqual([])
expect(distributeNodes([nodeFixture('single', [0, 0], [10, 10])])).toEqual(
[]
)
})
it('distributes nodes horizontally by sorted position', () => {
const first = nodeFixture('first', [0, 10], [10, 10])
const middle = nodeFixture('middle', [30, 20], [10, 10])
const last = nodeFixture('last', [60, 30], [20, 10])
const result = distributeNodes([last, first, middle], true)
expect(result.map(({ node: resultNode }) => resultNode.title)).toEqual([
'first',
'middle',
'last'
])
expect(first.pos).toEqual([0, 10])
expect(middle.pos).toEqual([30, 20])
expect(last.pos).toEqual([60, 30])
})
it('distributes nodes vertically by sorted position', () => {
const first = nodeFixture('first', [10, 0], [10, 10])
const middle = nodeFixture('middle', [20, 30], [10, 10])
const last = nodeFixture('last', [30, 60], [10, 20])
distributeNodes([last, first, middle])
expect(first.pos).toEqual([10, 0])
expect(middle.pos).toEqual([20, 30])
expect(last.pos).toEqual([30, 60])
})
it('aligns nodes to each boundary edge', () => {
const nodesForAlign = () => [
nodeFixture('top', [10, 0], [10, 10]),
nodeFixture('right', [40, 10], [30, 10]),
nodeFixture('bottom', [20, 50], [10, 30]),
nodeFixture('left', [-10, 20], [10, 10])
]
expect(
alignNodes(nodesForAlign(), 'left').map(({ newPos }) => newPos.x)
).toEqual([-10, -10, -10, -10])
expect(
alignNodes(nodesForAlign(), 'right').map(({ newPos }) => newPos.x)
).toEqual([60, 40, 60, 60])
expect(
alignNodes(nodesForAlign(), 'top').map(({ newPos }) => newPos.y)
).toEqual([0, 0, 0, 0])
expect(
alignNodes(nodesForAlign(), 'bottom').map(({ newPos }) => newPos.y)
).toEqual([70, 70, 50, 70])
})
it('aligns to an explicit node when provided', () => {
const anchor = nodeFixture('anchor', [100, 200], [50, 60])
const target = nodeFixture('target', [0, 0], [10, 20])
const result = alignNodes([target], 'bottom', anchor)
expect(result[0].newPos).toEqual({ x: 0, y: 240 })
expect(target.setPos).toHaveBeenCalledWith(0, 240)
})
it('returns no positions when alignment has no usable nodes', () => {
expect(alignNodes([], 'left')).toEqual([])
expect(alignNodes(undefined, 'left')).toEqual([])
})
})

View File

@@ -7,9 +7,11 @@ import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
* @returns An object listing the furthest node (edge) in all four directions.
* `null` if no nodes were supplied or the first node was falsy.
*/
export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
export function getBoundaryNodes(
nodes: LGraphNode[] | undefined
): IBoundaryNodes | null {
const valid = nodes?.find((x) => x)
if (!valid) return null
if (!nodes || !valid) return null
let top = valid
let right = valid
@@ -87,7 +89,7 @@ export function distributeNodes(
* @param align_to The node to align all other nodes to. If undefined, the farthest node will be used.
*/
export function alignNodes(
nodes: LGraphNode[],
nodes: LGraphNode[] | undefined,
direction: Direction,
align_to?: LGraphNode
): NewNodePosition[] {

View File

@@ -0,0 +1,117 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
findFirstNode,
findFreeSlotOfType,
getAllNestedItems
} from './collections'
import type { Positionable } from '../interfaces'
const graphNodeMock = vi.hoisted(() => ({
LGraphNode: class TestLGraphNode {
constructor(readonly title: string) {}
}
}))
vi.mock('@/lib/litegraph/src/LGraphNode', () => graphNodeMock)
describe('getAllNestedItems', () => {
it('returns empty for an undefined input set', () => {
expect(getAllNestedItems(undefined)).toEqual(new Set())
})
it('flattens nested children while skipping pinned and repeated items', () => {
const leaf = fromPartial<Positionable>({ pinned: false })
const hiddenChild = fromPartial<Positionable>({ pinned: false })
const pinned = fromPartial<Positionable>({
pinned: true,
children: new Set([leaf, hiddenChild])
})
const parent = fromPartial<Positionable>({
pinned: false,
children: new Set([leaf, pinned])
})
const result = getAllNestedItems(new Set([parent, leaf]))
expect(result).toEqual(new Set([parent, leaf]))
expect(result.has(hiddenChild)).toBe(false)
})
})
describe('findFirstNode', () => {
it('returns the first graph node from a mixed collection', () => {
const node = new LGraphNode('node')
expect(findFirstNode([{ pinned: false } as Positionable, node])).toBe(node)
})
it('returns undefined when no graph node is present', () => {
expect(findFirstNode([{ pinned: false } as Positionable])).toBeUndefined()
})
})
describe('findFreeSlotOfType', () => {
interface Slot {
type: string
links: number[]
}
const hasNoLinks = (slot: Slot) => slot.links.length === 0
it('returns undefined for an empty slot list', () => {
expect(findFreeSlotOfType([], 'IMAGE', hasNoLinks)).toBeUndefined()
})
it('prefers the first free exact type match', () => {
const slots = [
{ type: 'IMAGE', links: [1] },
{ type: 'IMAGE', links: [] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to a free wildcard before an occupied exact slot', () => {
const slots = [
{ type: 'IMAGE', links: [1] },
{ type: '*', links: [] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied exact slot before an occupied wildcard', () => {
const slots = [
{ type: '*', links: [1] },
{ type: 'IMAGE', links: [2] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied wildcard when no exact slot matches', () => {
const slots = [
{ type: 'LATENT', links: [1] },
{ type: '*', links: [2] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
})

View File

@@ -11,7 +11,7 @@ import type { ISlotType, Positionable } from '../interfaces'
* @returns All unpinned items in the original set, and recursively, their children
*/
export function getAllNestedItems(
items: ReadonlySet<Positionable>
items: ReadonlySet<Positionable> | undefined
): Set<Positionable> {
const allItems = new Set<Positionable>()
if (items) {

View File

@@ -25,6 +25,6 @@ function handleClose() {
}
function handleSubscribe() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
}
</script>

View File

@@ -140,7 +140,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
// Shows loading affordances
@@ -169,7 +172,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
})
@@ -180,7 +186,8 @@ describe('CloudSubscriptionRedirectView', () => {
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly'
'yearly',
{ paymentIntentSource: 'deep_link' }
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()

View File

@@ -94,7 +94,9 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle)
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
return
}
@@ -112,7 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
})
}
}, reportError)

View File

@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
showPricingTable({ reason: 'upgrade_to_add_credits' })
}
async function handleWindowFocus() {

View File

@@ -5,6 +5,8 @@ import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
@@ -15,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent() {
function renderComponent(props?: { reason?: PaymentIntentSource }) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -23,6 +25,7 @@ function renderComponent() {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -43,4 +46,18 @@ describe('FreeTierDialogContent', () => {
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
it('keeps the generic copy for intent reasons outside the credits variants', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'subscribe_to_run' })
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('swaps to the out-of-credits copy without the refresh line', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'out_of_credits' })
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!reason || reason === 'subscription_required'"
v-if="!isCreditsBlockedVariant"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,10 +65,7 @@
</p>
<p
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -88,7 +85,7 @@
@click="$emit('upgrade')"
>
{{
reason === 'out_of_credits' || reason === 'top_up_blocked'
isCreditsBlockedVariant
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -103,12 +100,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
reason?: SubscriptionDialogReason
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
defineEmits<{
@@ -129,4 +126,10 @@ const formattedRenewalDate = computed(() => {
})
const freeTierCredits = computed(() => getTierCredits('free'))
// Only these two variants replace the generic free-tier copy; any other
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
const isCreditsBlockedVariant = computed(
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
)
</script>

View File

@@ -261,6 +261,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -341,6 +342,7 @@ describe('PricingTable', () => {
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('should use the latest userId value when it changes after mount', async () => {
@@ -366,6 +368,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

@@ -277,13 +277,19 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import {
recordPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { useAuthStore } from '@/stores/authStore'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
@@ -321,6 +327,10 @@ interface PricingTierConfig {
isPopular?: boolean
}
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
@@ -463,16 +473,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
const beginCheckoutMetadata = userId.value
? {
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change' as const,
...(reason ? { payment_intent_source: reason } : {}),
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
}
: null
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
@@ -487,29 +498,39 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
const didOpenPortal = await accessBillingPortal()
if (didOpenPortal && beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
}
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
recordPendingSubscriptionCheckoutAttempt({
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
payment_intent_source: reason,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
if (beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
beginCheckoutMetadata,
pendingAttempt
)
)
}
}
} else {
await performSubscriptionCheckout(
tierKey,
currentBillingCycle.value,
true
)
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
paymentIntentSource: reason
})
}
} finally {
isLoading.value = false

View File

@@ -56,7 +56,7 @@ const handleSubscribe = () => {
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_now_button' })
}
onBeforeUnmount(() => {

View File

@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
}
</script>

View File

@@ -48,7 +48,9 @@
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
@click="
showSubscriptionDialog({ reason: 'settings_billing_panel' })
"
>
{{ $t('subscription.upgradePlan') }}
</Button>

View File

@@ -33,7 +33,11 @@
</i18n-t>
</div>
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<PricingTable
:reason
class="flex-1"
@choose-team-workspace="handleChooseTeam"
/>
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -157,11 +161,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
onChooseTeam?: () => void
}>()

View File

@@ -24,7 +24,9 @@ export function useAccountPreconditionDialog() {
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog()
void dialogService.showSubscriptionRequiredDialog({
reason: 'subscription_required'
})
return
case 'credits':
void dialogService.showTopUpCreditsDialog({

View File

@@ -55,12 +55,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -96,9 +90,6 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -150,7 +141,6 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
@@ -161,7 +151,6 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -230,7 +219,6 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,7 +7,6 @@ import {
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -62,7 +61,6 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

View File

@@ -15,7 +15,7 @@ import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
@@ -237,14 +237,7 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
void showSubscriptionRequiredDialog(options)
}
@@ -277,7 +270,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscription_required' })
}
}

View File

@@ -39,15 +39,23 @@ vi.mock('@/stores/commandStore', () => ({
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
const {
mockIsCloud,
mockTrackHelpResourceClicked,
mockTrackAddApiCreditButtonClicked
} = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn()
mockTrackHelpResourceClicked: vi.fn(),
mockTrackAddApiCreditButtonClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
? {
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
}
: null
}))
@@ -69,6 +77,9 @@ describe('useSubscriptionActions', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
source: 'settings_billing_panel'
})
})
})

View File

@@ -21,6 +21,9 @@ export function useSubscriptionActions() {
})
const handleAddApiCredits = () => {
telemetry?.trackAddApiCreditButtonClicked({
source: 'settings_billing_panel'
})
void dialogService.showTopUpCreditsDialog()
}

View File

@@ -5,8 +5,10 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
@@ -60,10 +62,15 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -80,6 +87,7 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -198,6 +206,51 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('tracks modal_opened with the caller reason and current tier', () => {
mockTier.value = 'STANDARD'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upgrade_to_add_credits' })
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
current_tier: 'standard',
reason: 'upgrade_to_add_credits'
})
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'subscribe_to_run' })
)
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('show', () => {
@@ -235,6 +288,20 @@ describe('useSubscriptionDialog', () => {
expect.objectContaining({ key: 'subscription-required' })
)
})
it('tracks modal_opened with the reason for the free-tier dialog', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'out_of_credits' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -4,6 +4,8 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -11,14 +13,8 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -38,6 +34,17 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
// Fired here — the choke point every paywall/pricing dialog variant passes
// through — so both the legacy and workspace billing paths emit it.
function trackModalOpened(reason?: PaymentIntentSource) {
// Resolved lazily to avoid the useBillingContext import cycle (see below).
const { tier } = useBillingContext()
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: tier.value?.toLowerCase(),
reason
})
}
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
@@ -71,6 +78,8 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -167,6 +176,8 @@ export const useSubscriptionDialog = () => {
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackModalOpened(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
@@ -236,7 +247,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable()
showPricingTable({ reason: 'team_upgrade_resume' })
}
} catch {
// sessionStorage may be unavailable

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
recordPendingSubscriptionCheckoutAttempt
} from './subscriptionCheckoutTracker'
const activeProStatus = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as const
describe('subscriptionCheckoutTracker', () => {
beforeEach(() => {
clearPendingSubscriptionCheckoutAttempt()
})
it('round-trips payment_intent_source from attempt to success metadata', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).toMatchObject({
tier: 'pro',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
})
it('omits payment_intent_source when the attempt had none', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).not.toBeNull()
expect(metadata).not.toHaveProperty('payment_intent_source')
})
})

View File

@@ -7,7 +7,12 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -23,7 +28,6 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
@@ -32,22 +36,24 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
interface PendingSubscriptionCheckoutAttempt {
export interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface RecordPendingSubscriptionCheckoutAttemptInput {
interface PendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -168,6 +174,9 @@ const normalizeAttempt = (
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {}),
...(typeof candidate.payment_intent_source === 'string'
? { payment_intent_source: candidate.payment_intent_source }
: {})
}
}
@@ -224,20 +233,27 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
return {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -255,6 +271,21 @@ export const recordPendingSubscriptionCheckoutAttempt = (
return attempt
}
export const recordPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt =>
persistPendingSubscriptionCheckoutAttempt(
createPendingSubscriptionCheckoutAttempt(input)
)
export const withPendingCheckoutAttemptId = (
metadata: BeginCheckoutMetadata,
attempt: PendingSubscriptionCheckoutAttempt
): BeginCheckoutMetadata => ({
...metadata,
checkout_attempt_id: attempt.attempt_id
})
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
@@ -287,6 +318,9 @@ export const consumePendingSubscriptionCheckoutSuccess = (
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
...(attempt.payment_intent_source
? { payment_intent_source: attempt.payment_intent_source }
: {}),
value,
currency: 'USD',
ecommerce: {

View File

@@ -132,13 +132,14 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly', true)
await performSubscriptionCheckout('pro', 'yearly')
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String),
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
@@ -150,6 +151,12 @@ describe('performSubscriptionCheckout', () => {
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
JSON.parse(storedAttempt).attempt_id
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
@@ -186,7 +193,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -203,11 +210,43 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', {
paymentIntentSource: 'out_of_credits'
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
)
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
const pendingAttempt = JSON.parse(storedAttempt)
expect(pendingAttempt).toMatchObject({
payment_intent_source: 'out_of_credits'
})
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
pendingAttempt.attempt_id
)
openSpy.mockRestore()
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
@@ -222,7 +261,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -235,13 +274,14 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -250,11 +290,18 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
const storedAttempt = window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
expect(storedAttempt).toBeNull()
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: expect.any(String)
})
)
})
})

View File

@@ -4,12 +4,19 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
persistPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -31,6 +38,11 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -47,10 +59,12 @@ const getCheckoutAttributionForCloud =
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
openInNewTab: boolean = true
options: PerformSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -108,14 +122,29 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
}
if (openInNewTab) {
@@ -123,18 +152,9 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
} else {
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -16,6 +20,12 @@ vi.mock('@/config/comfyApi', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
@@ -43,7 +53,9 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -51,6 +63,14 @@ describe('performTeamSubscriptionCheckout', () => {
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'team',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op_1',
payment_intent_source: 'deep_link'
})
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
@@ -82,6 +102,16 @@ describe('performTeamSubscriptionCheckout', () => {
expect(assignedHref).toBeUndefined()
})
it('does not track begin_checkout when subscribe fails', async () => {
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow('subscribe failed')
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false

View File

@@ -1,10 +1,16 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
import type { BillingCycle } from './subscriptionTierRank'
interface PerformTeamSubscriptionCheckoutOptions {
paymentIntentSource?: PaymentIntentSource
}
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
@@ -22,7 +28,8 @@ import type { BillingCycle } from './subscriptionTierRank'
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
@@ -33,6 +40,14 @@ export async function performTeamSubscriptionCheckout(
teamCreditStopId
})
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType: 'new',
billingOpId: response.billing_op_id,
paymentIntentSource: options.paymentIntentSource
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home

View File

@@ -30,6 +30,39 @@ describe('TelemetryRegistry', () => {
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
})
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
const b: TelemetryProvider = {}
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const metadata = {
user_id: 'user-1',
tier: 'pro' as const,
cycle: 'monthly' as const,
checkout_type: 'new' as const,
payment_intent_source: 'subscribe_to_run' as const
}
registry.trackBeginCheckout(metadata)
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
})
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
const provider: TelemetryProvider = {
trackAddApiCreditButtonClicked: vi.fn()
}
const registry = new TelemetryRegistry()
registry.registerProvider(provider)
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(
provider.trackAddApiCreditButtonClicked
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
})
it('skips providers that do not implement trackSearchQuery', () => {
const empty: TelemetryProvider = {}
const registry = new TelemetryRegistry()

View File

@@ -1,6 +1,7 @@
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -99,8 +100,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackAddApiCreditButtonClicked(): void {
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)
)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -313,6 +313,42 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures begin_checkout with intent metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackBeginCheckout({
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.BEGIN_CHECKOUT,
{
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
}
)
})
it('captures add-credit clicks with their source', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'credits_panel' }
)
})
it('captures share attribution events', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

@@ -10,7 +10,9 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -350,8 +352,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName, metadata)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackMonthlySubscriptionSucceeded(

View File

@@ -115,6 +115,17 @@ describe('HostTelemetrySink', () => {
)
})
it('forwards add-credit clicks with their source', () => {
new HostTelemetrySink().trackAddApiCreditButtonClicked({
source: 'avatar_menu'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'avatar_menu' }
)
})
it('does nothing when the host bridge is absent', () => {
delete window.__comfyDesktop2

View File

@@ -10,6 +10,7 @@ import {
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -126,8 +127,8 @@ export class HostTelemetrySink implements TelemetryProvider {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackAddApiCreditButtonClicked(): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -12,12 +12,29 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
import type { AppMode } from '@/utils/appMode'
export type PaymentIntentSource =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
| 'subscribe_to_run'
| 'subscribe_now_button'
| 'upgrade_to_add_credits'
| 'settings_billing_panel'
| 'avatar_menu_plans'
| 'team_members_panel'
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
/**
* Authentication metadata for sign-up tracking
*/
@@ -426,16 +443,23 @@ export interface CheckoutAttributionMetadata {
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
}
export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
tier: TierKey
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
checkout_attempt_id?: string
billing_op_id?: string
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
}
interface EcommerceItemMetadata {
@@ -457,8 +481,9 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
value: number
currency: string
ecommerce: EcommerceMetadata
@@ -489,7 +514,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void

View File

@@ -18,12 +18,14 @@ vi.mock('@/scripts/utils', () => ({
downloadBlob: mockDownloadBlob
}))
const mockCreateTemporary = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ createTemporary: vi.fn() })
useWorkflowStore: () => ({ createTemporary: mockCreateTemporary })
}))
const mockOpenWorkflow = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({ openWorkflow: vi.fn() })
useWorkflowService: () => ({ openWorkflow: mockOpenWorkflow })
}))
const minimalWorkflow: ComfyWorkflowJSON = {
@@ -37,6 +39,8 @@ const minimalWorkflow: ComfyWorkflowJSON = {
describe('workflowActionsService.exportWorkflowAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateTemporary.mockReturnValue({ path: 'temporary.json' })
mockOpenWorkflow.mockResolvedValue({ path: 'temporary.json' })
})
it('returns { cancelled: true } when the user dismisses the filename prompt', async () => {
@@ -89,4 +93,73 @@ describe('workflowActionsService.exportWorkflowAction', () => {
})
expect(mockDownloadBlob).not.toHaveBeenCalled()
})
it('returns a fallback error when export throws a non-error value', async () => {
mockGetSetting.mockReturnValue(false)
mockDownloadBlob.mockImplementationOnce(() => {
throw 'download failed'
})
const { exportWorkflowAction } = useWorkflowActionsService()
const result = await exportWorkflowAction(minimalWorkflow, 'wf.json')
expect(result).toEqual({
success: false,
error: 'Failed to export workflow'
})
})
})
describe('workflowActionsService.openWorkflowAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateTemporary.mockReturnValue({ path: 'temporary.json' })
mockOpenWorkflow.mockResolvedValue({ path: 'temporary.json' })
})
it('opens a temporary workflow and returns success', async () => {
const { openWorkflowAction } = useWorkflowActionsService()
const result = await openWorkflowAction(minimalWorkflow, 'wf.json')
expect(result).toEqual({ success: true })
expect(mockCreateTemporary).toHaveBeenCalledWith('wf.json', minimalWorkflow)
expect(mockOpenWorkflow).toHaveBeenCalledWith({ path: 'temporary.json' })
})
it('returns the no-workflow error when opening null', async () => {
const { openWorkflowAction } = useWorkflowActionsService()
const result = await openWorkflowAction(null, 'wf.json')
expect(result).toEqual({
success: false,
error: 'No workflow data available'
})
expect(mockCreateTemporary).not.toHaveBeenCalled()
})
it('returns thrown error messages from failed opens', async () => {
mockOpenWorkflow.mockRejectedValueOnce(new Error('Open failed'))
const { openWorkflowAction } = useWorkflowActionsService()
const result = await openWorkflowAction(minimalWorkflow, 'wf.json')
expect(result).toEqual({
success: false,
error: 'Open failed'
})
})
it('returns a fallback error when opening throws a non-error value', async () => {
mockOpenWorkflow.mockRejectedValueOnce('Open failed')
const { openWorkflowAction } = useWorkflowActionsService()
const result = await openWorkflowAction(minimalWorkflow, 'wf.json')
expect(result).toEqual({
success: false,
error: 'Failed to open workflow'
})
})
})

View File

@@ -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'
@@ -19,7 +20,10 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import {
createMockCanvasRenderingContext2D,
createMockChangeTracker
} from '@/utils/__tests__/litegraphTestUtils'
import type { AppMode } from '@/utils/appMode'
import { t } from '@/i18n'
@@ -61,10 +65,13 @@ function makeWorkflowData(
}
}
const { mockConfirm, mockTrackWorkflowSaved } = vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockTrackWorkflowSaved: vi.fn()
}))
const { mockConfirm, mockPrompt, mockTrackWorkflowSaved, mockDownloadBlob } =
vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockPrompt: vi.fn(),
mockTrackWorkflowSaved: vi.fn(),
mockDownloadBlob: vi.fn()
}))
const draftStoreMocks = vi.hoisted(() => ({
saveDraft: vi.fn(() => true),
@@ -75,16 +82,21 @@ const draftStoreMocks = vi.hoisted(() => ({
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
prompt: vi.fn(),
prompt: mockPrompt,
confirm: mockConfirm
})
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadBlob: mockDownloadBlob
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { ds: { offset: [0, 0], scale: 1 } },
rootGraph: { serialize: vi.fn(() => ({})), extra: {} },
loadGraphData: vi.fn()
loadGraphData: vi.fn(),
graphToPrompt: vi.fn()
}
}))
@@ -166,6 +178,74 @@ describe('useWorkflowService', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
draftStoreMocks.saveDraft.mockReturnValue(true)
mockPrompt.mockResolvedValue(null)
})
describe('exportWorkflow', () => {
beforeEach(() => {
vi.mocked(app.graphToPrompt).mockResolvedValue({
workflow: makeWorkflowData(),
output: { prompt: true }
} as never)
})
it('uses the active workflow filename and adds view restore data', async () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createModeTestWorkflow({
path: 'workflows/current.json'
})
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
if (key === 'Comfy.EnableWorkflowViewRestore') return true
return false
}
)
app.canvas.ds.offset = [25, 50]
app.canvas.ds.scale = 0.5
await useWorkflowService().exportWorkflow('fallback.json', 'workflow')
expect(mockDownloadBlob.mock.calls[0][0]).toBe('current')
const blob = mockDownloadBlob.mock.calls[0][1] as Blob
const exported = JSON.parse(await blob.text()) as ComfyWorkflowJSON
expect(exported.extra?.ds).toEqual({
scale: 0.5,
offset: [25, 50]
})
})
it('cancels prompted exports when the user dismisses the filename dialog', async () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => key === 'Comfy.PromptFilename'
)
mockPrompt.mockResolvedValue(null)
await useWorkflowService().exportWorkflow('workflow.json', 'output')
expect(mockDownloadBlob).not.toHaveBeenCalled()
})
it('appends json to prompted export filenames', async () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => key === 'Comfy.PromptFilename'
)
mockPrompt.mockResolvedValue('custom-name')
await useWorkflowService().exportWorkflow('workflow.json', 'output')
expect(mockDownloadBlob.mock.calls[0][0]).toBe('custom-name.json')
})
it('keeps prompted export filenames that already end in json', async () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => key === 'Comfy.PromptFilename'
)
mockPrompt.mockResolvedValue('custom-name.JSON')
await useWorkflowService().exportWorkflow('workflow.json', 'output')
expect(mockDownloadBlob.mock.calls[0][0]).toBe('custom-name.JSON')
})
})
describe('showPendingWarnings', () => {
@@ -227,6 +307,47 @@ describe('useWorkflowService', () => {
).toHaveBeenCalledTimes(2)
})
it('restores cached missing model and media warnings', () => {
const modelCandidates = [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
]
const mediaCandidates = [
{
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image' as const,
name: 'missing.png',
isMissing: true
}
]
const workflow = createWorkflow({
missingModelCandidates: modelCandidates,
missingMediaCandidates: mediaCandidates
})
useWorkflowService().showPendingWarnings(workflow)
expect(useMissingModelStore().setMissingModels).toHaveBeenCalledWith(
modelCandidates
)
expect(useMissingMediaStore().setMissingMedia).toHaveBeenCalledWith(
mediaCandidates
)
expect(workflow.pendingWarnings).toEqual({
missingNodeTypes: undefined,
missingModelCandidates: modelCandidates,
missingMediaCandidates: mediaCandidates
})
})
it('should NOT call showErrorOverlay when silent is true even with missing nodes', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
@@ -394,6 +515,29 @@ describe('useWorkflowService', () => {
consoleErrorSpy.mockRestore()
}
})
it('does nothing when no workflow is active', () => {
workflowStore.activeWorkflow = null
useWorkflowService().beforeLoadNewGraph()
expect(draftStoreMocks.saveDraft).not.toHaveBeenCalled()
})
it('does not persist a draft when the active workflow has no active state', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation((key: string) => {
return key === 'Comfy.Workflow.Persist'
})
const activeWorkflow = createModeTestWorkflow({
path: 'workflows/test.json'
})
;(activeWorkflow as ComfyWorkflowClass).changeTracker = null
workflowStore.activeWorkflow = activeWorkflow
useWorkflowService().beforeLoadNewGraph()
expect(draftStoreMocks.saveDraft).not.toHaveBeenCalled()
})
})
describe('openWorkflow deferred warnings', () => {
@@ -485,6 +629,157 @@ describe('useWorkflowService', () => {
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
})
it('does not reload the already active workflow unless forced', async () => {
const workflow = createWorkflow(null, { loadable: true })
vi.mocked(workflowStore.isActive).mockReturnValue(true)
await useWorkflowService().openWorkflow(workflow)
expect(app.loadGraphData).not.toHaveBeenCalled()
})
it('loads remote workflow data before opening unloaded workflows', async () => {
const workflow = createWorkflow(null, { loadable: true })
Object.assign(workflow, { isLoaded: false })
workflow.load = vi.fn().mockResolvedValue(workflow)
await useWorkflowService().openWorkflow(workflow)
expect(workflow.load).toHaveBeenCalledOnce()
expect(app.loadGraphData).toHaveBeenCalledWith(
expect.anything(),
true,
true,
workflow,
expect.objectContaining({
skipAssetScans: false
})
)
})
})
describe('workflow navigation helpers', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
})
it('reloads the active workflow with force', async () => {
const active = createWorkflow(null, { loadable: true })
workflowStore.activeWorkflow = active as LoadedComfyWorkflow
await useWorkflowService().reloadCurrentWorkflow()
expect(app.loadGraphData).toHaveBeenCalledWith(
expect.anything(),
true,
true,
active,
expect.objectContaining({
skipAssetScans: false
})
)
})
it('does nothing when reloading without an active workflow', async () => {
workflowStore.activeWorkflow = null
await useWorkflowService().reloadCurrentWorkflow()
expect(app.loadGraphData).not.toHaveBeenCalled()
})
it('loads default and blank workflows through app loadGraphData', async () => {
const service = useWorkflowService()
await service.loadDefaultWorkflow()
await service.loadBlankWorkflow()
expect(app.loadGraphData).toHaveBeenCalledTimes(2)
})
it('opens neighboring workflows when available', async () => {
const next = createWorkflow(null, { loadable: true })
const previous = createWorkflow(null, { loadable: true })
vi.mocked(workflowStore.openedWorkflowIndexShift)
.mockReturnValueOnce(next)
.mockReturnValueOnce(previous)
const service = useWorkflowService()
await service.loadNextOpenedWorkflow()
await service.loadPreviousOpenedWorkflow()
expect(app.loadGraphData).toHaveBeenCalledTimes(2)
})
it('does nothing when no neighboring workflow is available', async () => {
vi.mocked(workflowStore.openedWorkflowIndexShift).mockReturnValue(null)
const service = useWorkflowService()
await service.loadNextOpenedWorkflow()
await service.loadPreviousOpenedWorkflow()
expect(app.loadGraphData).not.toHaveBeenCalled()
})
})
describe('deleteWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
setActivePinia(createTestingPinia())
workflowStore = useWorkflowStore()
})
it('returns false when delete confirmation is declined', async () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => key === 'Comfy.Workflow.ConfirmDelete'
)
mockConfirm.mockResolvedValue(false)
const workflow = createModeTestWorkflow({
path: 'workflows/delete-me.json'
})
const deleted = await useWorkflowService().deleteWorkflow(workflow)
expect(deleted).toBe(false)
expect(workflowStore.deleteWorkflow).not.toHaveBeenCalled()
})
it('deletes silently without showing a toast', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/delete-silent.json'
})
const deleted = await useWorkflowService().deleteWorkflow(workflow, true)
expect(deleted).toBe(true)
expect(workflowStore.deleteWorkflow).toHaveBeenCalledWith(workflow)
expect(useToastStore().add).not.toHaveBeenCalled()
})
it('shows a toast after confirmed visible deletion', async () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => key === 'Comfy.Workflow.ConfirmDelete'
)
mockConfirm.mockResolvedValue(true)
const workflow = createModeTestWorkflow({
path: 'workflows/delete-visible.json'
})
const deleted = await useWorkflowService().deleteWorkflow(workflow)
expect(deleted).toBe(true)
expect(workflowStore.deleteWorkflow).toHaveBeenCalledWith(workflow)
expect(useToastStore().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'info'
})
)
})
})
describe('saveWorkflow', () => {
@@ -521,6 +816,50 @@ describe('useWorkflowService', () => {
})
})
describe('duplicateWorkflow', () => {
it('loads unloaded workflows and assigns a new id to duplicated state', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/source.json',
loaded: false
})
workflow.load = vi.fn().mockImplementation(async () => {
workflow.changeTracker = createMockChangeTracker({
activeState: {
...makeWorkflowData({ duplicated: true }),
id: 'old-id'
}
})
return workflow
})
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
await useWorkflowService().duplicateWorkflow(workflow)
expect(workflow.load).toHaveBeenCalledOnce()
const duplicatedState = vi.mocked(app.loadGraphData).mock
.calls[0][0] as ComfyWorkflowJSON
expect(duplicatedState.id).not.toBe('old-id')
expect(vi.mocked(app.loadGraphData).mock.calls[0][3]).toBe(
'source (Copy)'
)
})
it('duplicates empty workflow state without assigning an id', async () => {
const workflow = {
isLoaded: true,
activeState: null,
isPersisted: false,
filename: 'source (2)'
} as ComfyWorkflow
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
await useWorkflowService().duplicateWorkflow(workflow)
expect(vi.mocked(app.loadGraphData).mock.calls[0][0]).toBeNull()
expect(vi.mocked(app.loadGraphData).mock.calls[0][3]).toBe('source')
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
@@ -544,6 +883,48 @@ describe('useWorkflowService', () => {
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
})
it('returns false when dirty close confirmation is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/dirty.json'
})
workflow.isModified = true
mockConfirm.mockResolvedValue(null)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
expect(draftStoreMocks.removeDraft).not.toHaveBeenCalled()
})
it('opens the most recent workflow after closing the active workflow', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/active.json'
})
const recent = createWorkflow(null, {
loadable: true,
path: 'workflows/recent.json'
})
Object.assign(workflowStore, { openWorkflows: [workflow, recent] })
vi.mocked(workflowStore.isActive).mockImplementation(
(candidate) => candidate === workflow
)
vi.mocked(workflowStore.getMostRecentWorkflow).mockReturnValue(recent)
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(true)
expect(app.loadGraphData).toHaveBeenCalledWith(
expect.anything(),
true,
true,
recent,
expect.any(Object)
)
expect(workflowStore.closeWorkflow).toHaveBeenCalledWith(workflow)
})
})
describe('afterLoadNewGraph', () => {
@@ -654,6 +1035,34 @@ describe('useWorkflowService', () => {
expect(tempWorkflow.shareId).toBe('share-1')
})
it('creates unnamed temporary workflows for null loads', async () => {
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
const tempWorkflow = createModeTestWorkflow({
path: 'workflows/unsaved.json'
})
vi.mocked(workflowStore.createNewTemporary).mockReturnValue(tempWorkflow)
vi.mocked(workflowStore.openWorkflow).mockResolvedValue(tempWorkflow)
await useWorkflowService().afterLoadNewGraph(null, makeWorkflowData())
expect(workflowStore.createNewTemporary).toHaveBeenCalledWith(
undefined,
expect.any(Object)
)
expect(tempWorkflow.initialMode).toBeNull()
})
it('keeps existing initialMode when reusing a loaded workflow', async () => {
existingWorkflow.initialMode = 'graph'
await useWorkflowService().afterLoadNewGraph(
'repeat',
makeWorkflowData({ linearMode: true })
)
expect(existingWorkflow.initialMode).toBe('graph')
})
it('preserves share attribution on repeated same-path loads', async () => {
existingWorkflow.shareId = 'share-1'
@@ -1270,6 +1679,34 @@ describe('useWorkflowService', () => {
})
})
describe('insertWorkflow', () => {
it('pastes loaded workflow data and restores the previous clipboard', async () => {
const service = useWorkflowService()
const workflow = createModeTestWorkflow({
path: 'workflows/insert.json'
})
workflow.load = vi.fn().mockResolvedValue({
initialState: makeWorkflowData()
})
const pasteFromClipboard = vi.fn()
Object.assign(app.canvas, { pasteFromClipboard })
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
fromPartial<ReturnType<HTMLCanvasElement['getContext']>>(
createMockCanvasRenderingContext2D()
)
)
localStorage.setItem('litegrapheditor_clipboard', 'previous')
await service.insertWorkflow(workflow, { position: [10, 20] })
expect(workflow.load).toHaveBeenCalled()
expect(pasteFromClipboard).toHaveBeenCalledWith({
position: [10, 20]
})
expect(localStorage.getItem('litegrapheditor_clipboard')).toBe('previous')
})
})
describe('saveWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let toastStore: ReturnType<typeof useToastStore>

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { settings, workflows } = vi.hoisted(() => ({
settings: { tabsPosition: 'Sidebar' },
workflows: { openWorkflows: [] as unknown[] }
}))
vi.mock('@/components/sidebar/tabs/WorkflowsSidebarTab.vue', () => ({
default: {}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => settings.tabsPosition)
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
openWorkflows: workflows.openWorkflows
})
}))
describe('useWorkflowsSidebarTab', () => {
beforeEach(() => {
settings.tabsPosition = 'Sidebar'
workflows.openWorkflows = []
})
it('hides the badge when workflow tabs are not in the sidebar', async () => {
settings.tabsPosition = 'Topbar'
workflows.openWorkflows = [{ path: 'a' }]
const { useWorkflowsSidebarTab } = await import('./useWorkflowsSidebarTab')
const sidebarTab = useWorkflowsSidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('hides the badge when no workflows are open', async () => {
const { useWorkflowsSidebarTab } = await import('./useWorkflowsSidebarTab')
const sidebarTab = useWorkflowsSidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows the open workflow count for sidebar tabs', async () => {
workflows.openWorkflows = [{ path: 'a' }, { path: 'b' }]
const { useWorkflowsSidebarTab } = await import('./useWorkflowsSidebarTab')
const sidebarTab = useWorkflowsSidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
})
})

View File

@@ -0,0 +1,82 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
interface WorkflowFlags {
path: string
isPersisted?: boolean
isModified?: boolean
}
function wf(flags: WorkflowFlags): ComfyWorkflow {
return fromPartial<ComfyWorkflow>(flags)
}
function paths(workflows: ComfyWorkflow[]) {
return workflows.map((w) => w.path)
}
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore workflow lists', () => {
it('persistedWorkflows excludes unpersisted and subgraph entries', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json', isPersisted: true }))
store.attachWorkflow(wf({ path: 'b.json', isPersisted: false }))
store.attachWorkflow(wf({ path: 'subgraphs/c.json', isPersisted: true }))
expect(paths(store.persistedWorkflows)).toEqual(['a.json'])
})
it('modifiedWorkflows includes only modified workflows', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json', isModified: true }))
store.attachWorkflow(wf({ path: 'b.json', isModified: false }))
expect(paths(store.modifiedWorkflows)).toEqual(['a.json'])
})
it('bookmarkedWorkflows is empty when nothing is bookmarked', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json' }))
expect(store.bookmarkedWorkflows).toEqual([])
})
it('openedWorkflowIndexShift returns null when no workflow is active', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json' }), 0)
expect(store.openedWorkflowIndexShift(1)).toBeNull()
})
})

View File

@@ -0,0 +1,90 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
const SUBGRAPH_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore node locator translation', () => {
it('treats a node as a root-graph node when no subgraph is active', () => {
const store = useWorkflowStore()
expect(store.nodeIdToNodeLocatorId(toNodeId(5))).toBe('5')
})
it('prefixes the locator with an explicit subgraph uuid', () => {
const store = useWorkflowStore()
const subgraph = fromPartial<Subgraph>({ id: SUBGRAPH_UUID })
expect(store.nodeIdToNodeLocatorId(toNodeId(5), subgraph)).toBe(
`${SUBGRAPH_UUID}:5`
)
})
it('derives a locator from a node based on whether its graph is a subgraph', () => {
const store = useWorkflowStore()
const rootNode = createMockLGraphNode({ id: toNodeId(7), graph: {} })
expect(store.nodeToNodeLocatorId(rootNode)).toBe('7')
})
it('extracts the local node id from a locator', () => {
const store = useWorkflowStore()
expect(
store.nodeLocatorIdToNodeId(
createNodeLocatorId(SUBGRAPH_UUID, toNodeId(5))
)
).toBe(toNodeId(5))
expect(
store.nodeLocatorIdToNodeId(createNodeLocatorId(null, toNodeId(9)))
).toBe(toNodeId(9))
})
it('round-trips a root node id through locator translation', () => {
const store = useWorkflowStore()
const locator = store.nodeIdToNodeLocatorId(toNodeId(42))
expect(store.nodeLocatorIdToNodeId(locator)).toBe(toNodeId(42))
})
it('maps a root locator to a single-segment execution id', () => {
const store = useWorkflowStore()
expect(
store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId(null, toNodeId(5))
)
).toBe('5')
})
})

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { nextTick } from 'vue'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -21,6 +21,7 @@ import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraph } from '@/utils/typeGuardUtil'
import {
createMockCanvas,
@@ -205,6 +206,21 @@ describe('useWorkflowStore', () => {
expect(workflow.content).toBeNull()
expect(workflow.originalContent).toBeNull()
})
it('should sync workflows from a nested directory', async () => {
await syncRemoteWorkflowsWithMeta([
{ path: 'nested.json', modified: 100, size: 1 }
])
await store.syncWorkflows('subdir')
expect(api.listUserDataFullInfo).toHaveBeenLastCalledWith(
'workflows/subdir'
)
expect(
store.getWorkflowByPath('workflows/subdir/nested.json')
).not.toBeNull()
})
})
describe('createTemporary', () => {
@@ -246,6 +262,15 @@ describe('useWorkflowStore', () => {
expect(state.id.length).toBeGreaterThan(0)
expect(workflowDataWithoutId.id).toBeUndefined()
})
it('should register new temporary workflows with unique paths', () => {
const workflow = store.createNewTemporary()
const workflow2 = store.createNewTemporary()
expect(workflow.isTemporary).toBe(true)
expect(store.getWorkflowByPath(workflow.path)).not.toBeNull()
expect(workflow2.path).not.toBe(workflow.path)
})
})
describe('openWorkflow', () => {
@@ -484,6 +509,28 @@ describe('useWorkflowStore', () => {
})
})
describe('openedWorkflowIndexShift', () => {
it('returns null when there is no active workflow', () => {
expect(store.openedWorkflowIndexShift(1)).toBeNull()
})
it('wraps around open workflow tabs', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
const workflowA = store.getWorkflowByPath('workflows/a.json')!
const workflowB = store.getWorkflowByPath('workflows/b.json')!
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
expect(store.openedWorkflowIndexShift(1)?.path).toBe(workflowA.path)
expect(store.openedWorkflowIndexShift(-1)?.path).toBe(workflowA.path)
})
})
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
@@ -556,6 +603,17 @@ describe('useWorkflowStore', () => {
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
expect(bookmarkStore.isBookmarked('test.json')).toBe(false)
})
it('should reset busy state when rename fails', async () => {
const workflow = store.createTemporary('test.json')
vi.spyOn(workflow, 'rename').mockRejectedValue(new Error('rename failed'))
await expect(
store.renameWorkflow(workflow, 'workflows/renamed.json')
).rejects.toThrow('rename failed')
expect(store.isBusy).toBe(false)
})
})
describe('closeWorkflow', () => {
@@ -568,6 +626,17 @@ describe('useWorkflowStore', () => {
expect(store.isOpen(workflow)).toBe(false)
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
})
it('should unload persisted workflows on close', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
const unloadSpy = vi.spyOn(workflow, 'unload')
await store.closeWorkflow(workflow)
expect(unloadSpy).toHaveBeenCalled()
expect(store.getWorkflowByPath(workflow.path)).toBe(workflow)
})
})
describe('deleteWorkflow', () => {
@@ -603,6 +672,17 @@ describe('useWorkflowStore', () => {
// Verify bookmark was removed
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
})
it('should reset busy state when delete fails', async () => {
const workflow = store.createTemporary('test.json')
vi.spyOn(workflow, 'delete').mockRejectedValue(new Error('delete failed'))
await expect(store.deleteWorkflow(workflow)).rejects.toThrow(
'delete failed'
)
expect(store.isBusy).toBe(false)
})
})
describe('save', () => {
@@ -662,6 +742,15 @@ describe('useWorkflowStore', () => {
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
expect(workflow.isModified).toBe(false)
})
it('should reset busy state when save fails', async () => {
const workflow = store.createTemporary('test.json')
vi.spyOn(workflow, 'save').mockRejectedValue(new Error('save failed'))
await expect(store.saveWorkflow(workflow)).rejects.toThrow('save failed')
expect(store.isBusy).toBe(false)
})
})
describe('saveAs', () => {
@@ -701,11 +790,9 @@ describe('useWorkflowStore', () => {
describe('Subgraphs', () => {
beforeEach(async () => {
// Ensure canvas exists for these tests
vi.mocked(comfyApp).canvas = fromAny<typeof comfyApp.canvas, unknown>(
createMockCanvas({
subgraph: undefined
})
)
vi.mocked(comfyApp).canvas = createMockCanvas({
subgraph: undefined
})
// Setup an active workflow as updateActiveGraph depends on it
const workflow = store.createTemporary('test-subgraph-workflow.json')
@@ -726,7 +813,7 @@ describe('useWorkflowStore', () => {
it('should handle when comfyApp.canvas is not available', async () => {
// Arrange
vi.mocked(comfyApp).canvas = fromAny<typeof comfyApp.canvas, null>(null)
Object.assign(vi.mocked(comfyApp), { canvas: null })
// Act
console.debug(store.isSubgraphActive)
@@ -754,14 +841,9 @@ describe('useWorkflowStore', () => {
it('should correctly update state when a subgraph is active', async () => {
// Arrange: Setup mock subgraph structure
const mockSubgraph = fromAny<Subgraph, unknown>({
const mockSubgraph = fromPartial<Subgraph>({
name: 'Level 2 Subgraph',
isRootGraph: false,
pathToRootGraph: [
{ name: 'Root' }, // Root Graph (index 0, ignored)
{ name: 'Level 1 Subgraph' },
{ name: 'Level 2 Subgraph' }
]
isRootGraph: false
})
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
@@ -781,9 +863,8 @@ describe('useWorkflowStore', () => {
it('should update automatically when activeWorkflow changes', async () => {
// Arrange: Set initial canvas state (e.g., a subgraph)
const initialSubgraph = fromAny<Subgraph, unknown>({
const initialSubgraph = fromPartial<Subgraph>({
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
})
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
@@ -836,7 +917,7 @@ describe('useWorkflowStore', () => {
// Setup mock graph structure with subgraphs
const mockNodes: LGraph['nodes'] = []
const mockSubgraphs = new Map<string, Subgraph>()
const mockRootGraph = fromAny<LGraph, unknown>({
const mockRootGraph = fromPartial<LGraph>({
_nodes: mockNodes,
nodes: mockNodes,
subgraphs: mockSubgraphs,
@@ -846,7 +927,7 @@ describe('useWorkflowStore', () => {
}
})
const mockSubgraph = fromAny<Subgraph, unknown>({
const mockSubgraph = fromPartial<Subgraph>({
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
rootGraph: mockRootGraph,
_nodes: [],
@@ -886,7 +967,7 @@ describe('useWorkflowStore', () => {
const customSubgraphId = '11111111-2222-4333-8444-555555555555'
const customSubgraph = fromPartial<Subgraph>({
id: customSubgraphId,
rootGraph: fromAny<LGraph, undefined>(undefined),
rootGraph: undefined,
_nodes: [],
nodes: [],
clear: vi.fn()
@@ -899,6 +980,33 @@ describe('useWorkflowStore', () => {
})
})
describe('nodeToNodeLocatorId', () => {
it('should include subgraph IDs for nodes inside subgraphs', () => {
const subgraph = fromPartial<Subgraph>({
id: '22222222-3333-4444-8555-666666666666'
})
vi.mocked(isSubgraph).mockImplementation(
(obj): obj is Subgraph => obj === subgraph
)
const node = createMockLGraphNode({
id: toNodeId(77),
graph: subgraph
})
expect(store.nodeToNodeLocatorId(node)).toBe(
'22222222-3333-4444-8555-666666666666:77'
)
})
it('should return root locators for nodes outside subgraphs', () => {
vi.mocked(isSubgraph).mockImplementation(() => false)
const node = createMockLGraphNode({ id: toNodeId(77) })
expect(store.nodeToNodeLocatorId(node)).toBe('77')
})
})
describe('executionIdToCurrentId', () => {
it('should convert an execution ID to the active subgraph node ID', () => {
const result = store.executionIdToCurrentId('123:456')
@@ -914,6 +1022,16 @@ describe('useWorkflowStore', () => {
expect(() => store.executionIdToCurrentId('123::456')).not.toThrow()
expect(store.executionIdToCurrentId('123::456')).toBeUndefined()
})
it('should return a root-level ID as-is only when no subgraph is active', () => {
store.activeSubgraph = undefined
expect(store.executionIdToCurrentId('42')).toBe('42')
expect(store.executionIdToCurrentId('123:456')).toBeUndefined()
})
it('should return undefined for a root-level ID while a subgraph is active', () => {
expect(store.executionIdToCurrentId('42')).toBeUndefined()
})
})
describe('nodeLocatorIdToNodeId', () => {
it('should extract node ID from NodeLocatorId', () => {
@@ -950,6 +1068,14 @@ describe('useWorkflowStore', () => {
})
describe('nodeLocatorIdToNodeExecutionId', () => {
it('should return null for invalid locator IDs', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
fromPartial<NodeLocatorId>('bad:123')
)
expect(result).toBeNull()
})
it('should convert NodeLocatorId to execution ID', () => {
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
@@ -980,6 +1106,27 @@ describe('useWorkflowStore', () => {
)
expect(result).toBeNull()
})
it('should return null when the target subgraph is not on the path', () => {
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
})
const unrelatedSubgraph = fromPartial<Subgraph>({
id: '33333333-4444-4555-8666-777777777777',
_nodes: [],
nodes: []
})
const result = store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
toNodeId(456)
),
unrelatedSubgraph
)
expect(result).toBeNull()
})
})
})
@@ -1070,6 +1217,18 @@ describe('useWorkflowStore', () => {
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent).toBeNull()
})
it('should trim activation history to the most recent entries', async () => {
const workflows = Array.from({ length: 34 }, (_, index) =>
store.createTemporary(`history-${index}.json`)
)
for (const workflow of workflows) {
await store.openWorkflow(workflow)
}
expect(store.getMostRecentWorkflow()?.path).toBe(workflows[32].path)
})
})
describe('closeWorkflow draft cleanup', () => {
@@ -1100,4 +1259,42 @@ describe('useWorkflowStore', () => {
expect(draftStore.getDraft(workflow.path)).toBeNull()
})
})
describe('workflow bookmarks', () => {
it('loads no bookmarks when the index response is not found', async () => {
vi.mocked(api.getUserData).mockResolvedValueOnce({
status: 404,
json: () => Promise.resolve({})
} as Response)
await bookmarkStore.loadBookmarks()
expect(bookmarkStore.isBookmarked('workflows/a.json')).toBe(false)
})
it('loads an empty bookmark set from a sparse index response', async () => {
vi.mocked(api.getUserData).mockResolvedValueOnce({
status: 200,
json: () => Promise.resolve(null)
} as Response)
await bookmarkStore.loadBookmarks()
expect(bookmarkStore.isBookmarked('workflows/a.json')).toBe(false)
})
it('does not save when setting an existing bookmark state', async () => {
await bookmarkStore.setBookmarked('workflows/a.json', false)
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('toggles bookmarks on and off', async () => {
await bookmarkStore.toggleBookmarked('workflows/a.json')
expect(bookmarkStore.isBookmarked('workflows/a.json')).toBe(true)
await bookmarkStore.toggleBookmarked('workflows/a.json')
expect(bookmarkStore.isBookmarked('workflows/a.json')).toBe(false)
})
})
})

View File

@@ -0,0 +1,117 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ComfyWorkflow,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
vi.mock('@/scripts/app', () => ({ app: { canvas: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
function wf(path: string): ComfyWorkflow {
const workflow = fromPartial<ComfyWorkflow>({
path,
load: async () => workflow as LoadedComfyWorkflow
})
return workflow
}
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore tab management', () => {
it('attaches workflows into the lookup and finds them by path', () => {
const store = useWorkflowStore()
const a = wf('a.json')
store.attachWorkflow(a)
// Pinia wraps stored objects in reactive proxies, so compare structurally.
expect(store.getWorkflowByPath('a.json')).toEqual(a)
expect(store.getWorkflowByPath('missing.json')).toBeNull()
expect(store.workflows).toContainEqual(a)
})
it('tracks which workflows are open', () => {
const store = useWorkflowStore()
const open = wf('open.json')
const closed = wf('closed.json')
store.attachWorkflow(open, 0)
store.attachWorkflow(closed)
expect(store.isOpen(open)).toBe(true)
expect(store.isOpen(closed)).toBe(false)
expect(store.openWorkflows).toEqual([open])
})
it('reorders open workflow tabs', () => {
const store = useWorkflowStore()
const a = wf('a.json')
const b = wf('b.json')
const c = wf('c.json')
store.attachWorkflow(a, 0)
store.attachWorkflow(b, 1)
store.attachWorkflow(c, 2)
store.reorderWorkflows(0, 2)
expect(store.openWorkflows).toEqual([b, c, a])
})
it('opens background workflows on the requested side, ignoring unknown paths', () => {
const store = useWorkflowStore()
const left = wf('left.json')
const mid = wf('mid.json')
const right = wf('right.json')
store.attachWorkflow(left)
store.attachWorkflow(mid, 0)
store.attachWorkflow(right)
store.openWorkflowsInBackground({
left: ['left.json', 'unknown.json'],
right: ['right.json']
})
expect(store.openWorkflows).toEqual([left, mid, right])
})
it('marks a workflow active only after it is opened', async () => {
const store = useWorkflowStore()
const opened = wf('opened.json')
const background = wf('background.json')
store.attachWorkflow(opened)
store.attachWorkflow(background)
expect(store.isActive(opened)).toBe(false)
await store.openWorkflow(opened)
expect(store.isActive(opened)).toBe(true)
expect(store.isActive(background)).toBe(false)
})
})

View File

@@ -1,3 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DraftIndexV2, DraftPayloadV2 } from './draftTypes'
@@ -17,6 +18,18 @@ import {
writePayload
} from './storageIO'
function createStorageStub(overrides: Partial<Storage> = {}): Storage {
return fromPartial<Storage>({
length: 0,
clear: vi.fn(),
getItem: vi.fn(() => null),
key: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
...overrides
})
}
describe('storageIO', () => {
beforeEach(() => {
localStorage.clear()
@@ -25,8 +38,11 @@ describe('storageIO', () => {
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
localStorage.clear()
sessionStorage.clear()
vi.resetModules()
})
describe('index operations', () => {
@@ -74,6 +90,60 @@ describe('storageIO', () => {
)
expect(readIndex(workspaceId)).toBeNull()
})
it('returns null for malformed index shapes', () => {
for (const value of [
null,
42,
{ v: 2, updatedAt: 'now', order: [], entries: {} },
{ v: 2, updatedAt: 1, order: {}, entries: {} },
{ v: 2, updatedAt: 1, order: [], entries: null }
]) {
localStorage.setItem(
'Comfy.Workflow.DraftIndex.v2:test-workspace',
JSON.stringify(value)
)
expect(readIndex(workspaceId)).toBeNull()
}
})
it('returns false for quota errors when writing an index', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
setItem: vi.fn(() => {
throw new DOMException('full', 'QuotaExceededError')
})
})
)
const index: DraftIndexV2 = {
v: 2,
updatedAt: 1,
order: [],
entries: {}
}
expect(writeIndex(workspaceId, index)).toBe(false)
})
it('rethrows non-quota errors when writing an index', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
setItem: vi.fn(() => {
throw new Error('storage failed')
})
})
)
const index: DraftIndexV2 = {
v: 2,
updatedAt: 1,
order: [],
entries: {}
}
expect(() => writeIndex(workspaceId, index)).toThrow('storage failed')
})
})
describe('payload operations', () => {
@@ -97,6 +167,45 @@ describe('storageIO', () => {
expect(readPayload(workspaceId, 'missing')).toBeNull()
})
it('returns null for invalid payload JSON', () => {
localStorage.setItem(
'Comfy.Workflow.Draft.v2:test-workspace:abc12345',
'invalid'
)
expect(readPayload(workspaceId, draftKey)).toBeNull()
})
it('returns false for quota errors when writing payloads', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
setItem: vi.fn(() => {
throw new DOMException('full', 'NS_ERROR_DOM_QUOTA_REACHED')
})
})
)
expect(
writePayload(workspaceId, draftKey, { data: '{}', updatedAt: 1 })
).toBe(false)
})
it('rethrows non-quota errors when writing payloads', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
setItem: vi.fn(() => {
throw new Error('storage failed')
})
})
)
expect(() =>
writePayload(workspaceId, draftKey, { data: '{}', updatedAt: 1 })
).toThrow('storage failed')
})
it('deletes payload', () => {
const payload: DraftPayloadV2 = {
data: '{}',
@@ -109,6 +218,19 @@ describe('storageIO', () => {
expect(readPayload(workspaceId, draftKey)).toBeNull()
})
it('ignores delete errors', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
removeItem: vi.fn(() => {
throw new Error('remove failed')
})
})
)
expect(() => deletePayload(workspaceId, draftKey)).not.toThrow()
})
it('deletes multiple payloads', () => {
writePayload(workspaceId, 'key1', { data: '{}', updatedAt: 1 })
writePayload(workspaceId, 'key2', { data: '{}', updatedAt: 2 })
@@ -134,6 +256,20 @@ describe('storageIO', () => {
expect(keys).toContain('abc')
expect(keys).toContain('def')
})
it('returns an empty list when key enumeration fails', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
length: 1,
key: vi.fn(() => {
throw new Error('key failed')
})
})
)
expect(getPayloadKeys('ws-1')).toEqual([])
})
})
describe('deleteOrphanPayloads', () => {
@@ -279,6 +415,69 @@ describe('storageIO', () => {
)
expect(JSON.parse(raw!).workspaceId).toBe('ws-B')
})
it('falls back to the last active path in localStorage', () => {
const pointer = { workspaceId: 'ws-1', path: 'workflows/last.json' }
localStorage.setItem(
'Comfy.Workflow.LastActivePath:ws-1',
JSON.stringify(pointer)
)
expect(readActivePath('missing-client', 'ws-1')).toEqual(pointer)
})
it('ignores invalid last active path pointers', () => {
localStorage.setItem(
'Comfy.Workflow.LastActivePath:ws-1',
JSON.stringify({ workspaceId: 'ws-1', paths: [] })
)
expect(readActivePath('missing-client', 'ws-1')).toBeNull()
})
it('falls back to the last open paths in localStorage', () => {
const pointer = {
workspaceId: 'ws-1',
paths: ['workflows/last.json'],
activeIndex: 0
}
localStorage.setItem(
'Comfy.Workflow.LastOpenPaths:ws-1',
JSON.stringify(pointer)
)
expect(readOpenPaths('missing-client', 'ws-1')).toEqual(pointer)
})
it('ignores invalid migrated session pointers', () => {
sessionStorage.setItem('Comfy.Workflow.OpenPaths:old-client', 'invalid')
expect(readOpenPaths('new-client', 'ws-1')).toBeNull()
})
it('silently ignores pointer write failures', () => {
const storage = createStorageStub({
setItem: vi.fn(() => {
throw new Error('write failed')
})
})
vi.stubGlobal('localStorage', storage)
vi.stubGlobal('sessionStorage', storage)
expect(() =>
writeActivePath('client', {
workspaceId: 'ws-1',
path: 'workflows/a.json'
})
).not.toThrow()
expect(() =>
writeOpenPaths('client', {
workspaceId: 'ws-1',
paths: ['workflows/a.json'],
activeIndex: 0
})
).not.toThrow()
})
})
describe('clearAllV2Storage', () => {
@@ -317,5 +516,57 @@ describe('storageIO', () => {
).toBeNull()
expect(sessionStorage.getItem('unrelated')).toBe('keep')
})
it('ignores storage cleanup failures', () => {
vi.stubGlobal(
'localStorage',
createStorageStub({
length: 1,
key: vi.fn(() => 'Comfy.Workflow.Draft.v2:ws-1:abc'),
removeItem: vi.fn(() => {
throw new Error('remove failed')
})
})
)
vi.stubGlobal(
'sessionStorage',
createStorageStub({
length: 1,
key: vi.fn(() => 'Comfy.Workflow.ActivePath:client-1'),
removeItem: vi.fn(() => {
throw new Error('remove failed')
})
})
)
expect(() => clearAllV2Storage()).not.toThrow()
})
})
describe('storage availability', () => {
it('returns empty results and rejects writes after storage is marked unavailable', async () => {
const storage = await import('./storageIO')
expect(storage.isStorageAvailable()).toBe(true)
storage.markStorageUnavailable()
expect(storage.isStorageAvailable()).toBe(false)
expect(storage.readIndex('ws-1')).toBeNull()
expect(storage.readPayload('ws-1', 'draft')).toBeNull()
expect(storage.getPayloadKeys('ws-1')).toEqual([])
expect(
storage.writeIndex('ws-1', {
v: 2,
updatedAt: 1,
order: [],
entries: {}
})
).toBe(false)
expect(
storage.writePayload('ws-1', 'draft', { data: '{}', updatedAt: 1 })
).toBe(false)
expect(() => storage.clearAllV2Storage()).not.toThrow()
})
})
})

View File

@@ -5,6 +5,8 @@ import { createApp, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
@@ -40,11 +42,15 @@ vi.mock('primevue/usetoast', () => ({
})
}))
const sharedWorkflowLoaderMocks = vi.hoisted(() => ({
load: vi.fn().mockResolvedValue('not-present')
}))
vi.mock(
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
() => ({
useSharedWorkflowUrlLoader: () => ({
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
loadSharedWorkflowFromUrl: sharedWorkflowLoaderMocks.load
})
})
)
@@ -58,11 +64,15 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
})
}))
const templateLoaderMocks = vi.hoisted(() => ({
load: vi.fn()
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
loadTemplateFromUrl: templateLoaderMocks.load
})
})
)
@@ -78,7 +88,8 @@ vi.mock('@/stores/commandStore', () => ({
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
query: {} as Record<string, unknown>,
replace: vi.fn()
}))
vi.mock('vue-router', () => ({
@@ -88,7 +99,7 @@ vi.mock('vue-router', () => ({
}
}),
useRouter: () => ({
replace: vi.fn()
replace: routeMocks.replace
})
}))
@@ -142,8 +153,8 @@ const mocks = vi.hoisted(() => {
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
clientId: 'test-client' as string | undefined,
initialClientId: 'test-client' as string | null,
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
@@ -203,8 +214,12 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
sharedWorkflowLoaderMocks.load.mockReset()
sharedWorkflowLoaderMocks.load.mockResolvedValue('not-present')
templateLoaderMocks.load.mockReset()
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
routeMocks.replace.mockReset()
preservedQueryMocks.payloads = {}
})
@@ -283,6 +298,26 @@ describe('useWorkflowPersistenceV2', () => {
return { promise, resolve }
}
describe('migration', () => {
it('falls back to initialClientId when clientId is unavailable', () => {
mocks.apiMock.clientId = undefined
mocks.apiMock.initialClientId = 'initial-client'
mountWorkflowPersistence()
expect(migrateV1toV2).toHaveBeenCalledWith(undefined, 'initial-client')
})
it('passes undefined when no API client id is available', () => {
mocks.apiMock.clientId = undefined
mocks.apiMock.initialClientId = null
mountWorkflowPersistence()
expect(migrateV1toV2).toHaveBeenCalledWith(undefined, undefined)
})
})
describe('persistence toggle', () => {
it('resets the V2 draft store only after workflow persistence is disabled', async () => {
const draftStore = useWorkflowDraftStoreV2()
@@ -298,6 +333,83 @@ describe('useWorkflowPersistenceV2', () => {
})
})
describe('graph change persistence', () => {
it('saves the active workflow draft after graphChanged debounce', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = workflowStore.createTemporary('ActiveWorkflow.json')
await workflowStore.openWorkflow(workflow)
mocks.state.currentGraph = { nodes: [{ id: 1 }] }
mountWorkflowPersistence()
mocks.state.graphChangedHandler?.()
vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS)
const draft = draftStore.getDraft(workflow.path)
expect(draft?.data).toBe(JSON.stringify(mocks.state.currentGraph))
expect(draft?.name).toBe(workflow.key)
})
it('shows a toast when saving the active workflow draft fails', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = workflowStore.createTemporary('FailingWorkflow.json')
await workflowStore.openWorkflow(workflow)
vi.spyOn(draftStore, 'saveDraft').mockReturnValue(false)
mountWorkflowPersistence()
mocks.state.graphChangedHandler?.()
vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'toastMessages.failedToSaveDraft'
})
)
})
})
describe('url workflow loaders', () => {
it('loads a template from the current route query', async () => {
routeMocks.query = { template: 'template-id' }
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(routeMocks.replace).not.toHaveBeenCalled()
expect(templateLoaderMocks.load).toHaveBeenCalledOnce()
})
it('hydrates preserved template intent back into the route before loading', async () => {
preservedQueryMocks.payloads.template = { template: 'template-id' }
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(routeMocks.replace).toHaveBeenCalledWith({
query: { template: 'template-id' }
})
expect(templateLoaderMocks.load).toHaveBeenCalledOnce()
})
it('does not load a template when no template intent is present', async () => {
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(routeMocks.replace).not.toHaveBeenCalled()
expect(templateLoaderMocks.load).not.toHaveBeenCalled()
})
it('returns the shared workflow loader result', async () => {
sharedWorkflowLoaderMocks.load.mockResolvedValueOnce('loaded')
const { loadSharedWorkflowFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadSharedWorkflowFromUrlIfPresent()).resolves.toBe('loaded')
})
})
describe('loadPreviousWorkflowFromStorage', () => {
it('does not restore the active workflow early when open tab state exists', async () => {
const workflowStore = useWorkflowStore()
@@ -543,6 +655,49 @@ describe('useWorkflowPersistenceV2', () => {
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
})
it('recovers malformed temporary drafts with a default temporary workflow', async () => {
const workflowStore = useWorkflowStore()
vi.spyOn(workflowStore, 'loadWorkflows').mockResolvedValue()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const draftStore = useWorkflowDraftStoreV2()
const path = 'workflows/Broken.json'
draftStore.saveDraft(path, '{bad json', {
name: 'Broken.json',
isTemporary: true
})
writeTabState([path], 0)
const { restoreWorkflowTabsState } = mountWorkflowPersistence()
await restoreWorkflowTabsState()
expect(warnSpy).toHaveBeenCalledWith(
'Failed to parse workflow draft, creating with default',
expect.any(Error)
)
expect(draftStore.getDraft(path)).toBeNull()
expect(workflowStore.getWorkflowByPath(path)?.isTemporary).toBe(true)
warnSpy.mockRestore()
})
it('does not recreate a missing saved workflow from a non-temporary draft', async () => {
const workflowStore = useWorkflowStore()
vi.spyOn(workflowStore, 'loadWorkflows').mockResolvedValue()
const draftStore = useWorkflowDraftStoreV2()
const path = 'workflows/Saved.json'
draftStore.saveDraft(path, JSON.stringify({ title: 'saved' }), {
name: 'Saved.json',
isTemporary: false
})
writeTabState([path], 0)
const { restoreWorkflowTabsState } = mountWorkflowPersistence()
await restoreWorkflowTabsState()
expect(workflowStore.getWorkflowByPath(path)).toBeNull()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('skips activation when persistence is disabled', async () => {
settingMocks.persistRef!.value = false
vi.spyOn(useWorkflowStore(), 'loadWorkflows').mockResolvedValue()

View File

@@ -1,15 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => ({
const mockApi = vi.hoisted(() => ({
api: {
clientId: 'test-client-id',
initialClientId: 'test-client-id'
clientId: 'test-client-id' as string | null,
initialClientId: 'test-client-id' as string | null
}
}))
vi.mock('@/scripts/api', () => ({
api: mockApi.api
}))
describe('useWorkflowTabState', () => {
beforeEach(() => {
vi.resetModules()
mockApi.api.clientId = 'test-client-id'
mockApi.api.initialClientId = 'test-client-id'
sessionStorage.clear()
})
@@ -29,6 +35,29 @@ describe('useWorkflowTabState', () => {
expect(getActivePath()).toBe('workflows/test.json')
})
it('falls back to initial client ID before client ID is set', async () => {
mockApi.api.clientId = null
mockApi.api.initialClientId = 'initial-client-id'
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getActivePath, setActivePath } = useWorkflowTabState()
setActivePath('workflows/from-initial.json')
expect(getActivePath()).toBe('workflows/from-initial.json')
})
it('does not read or write active path without any client ID', async () => {
mockApi.api.clientId = null
mockApi.api.initialClientId = null
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getActivePath, setActivePath } = useWorkflowTabState()
setActivePath('workflows/ignored.json')
expect(getActivePath()).toBeNull()
expect(sessionStorage.length).toBe(0)
})
it('ignores pointer from different workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
@@ -73,6 +102,18 @@ describe('useWorkflowTabState', () => {
expect(result!.activeIndex).toBe(1)
})
it('does not read or write open paths without any client ID', async () => {
mockApi.api.clientId = null
mockApi.api.initialClientId = null
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getOpenPaths, setOpenPaths } = useWorkflowTabState()
setOpenPaths(['workflows/ignored.json'], 0)
expect(getOpenPaths()).toBeNull()
expect(sessionStorage.length).toBe(0)
})
it('ignores pointer from different workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',

View File

@@ -19,6 +19,7 @@ describe('migrateV1toV2', () => {
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
sessionStorage.clear()
})
@@ -77,6 +78,44 @@ describe('migrateV1toV2', () => {
expect(index.order).toEqual([])
})
it('creates empty V2 index when V1 draft JSON is invalid', () => {
localStorage.setItem(`Comfy.Workflow.Drafts:${workspaceId}`, '{not-json')
expect(migrateV1toV2(workspaceId)).toBe(0)
})
it('migrates zero drafts when V1 order is missing', () => {
localStorage.setItem(
`Comfy.Workflow.Drafts:${workspaceId}`,
JSON.stringify({
'workflows/a.json': {
data: '{}',
updatedAt: 1000,
name: 'a',
isTemporary: true
}
})
)
expect(migrateV1toV2(workspaceId)).toBe(0)
})
it('skips paths that no longer exist in V1 drafts', () => {
setV1Data(
{
'workflows/a.json': {
data: '{}',
updatedAt: 1000,
name: 'a',
isTemporary: true
}
},
['workflows/a.json', 'workflows/missing.json']
)
expect(migrateV1toV2(workspaceId)).toBe(1)
})
it('migrates V1 drafts to V2 format', () => {
const v1Drafts = {
'workflows/a.json': {
@@ -211,6 +250,14 @@ describe('migrateV1toV2', () => {
localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`)
).toBeNull()
})
it('ignores storage errors during cleanup', () => {
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(() => cleanupV1Data(workspaceId)).not.toThrow()
})
})
describe('V1 tab state migration', () => {
@@ -290,6 +337,71 @@ describe('migrateV1toV2', () => {
// No tab state to migrate — should remain null
expect(openPaths).toBeNull()
})
it('clamps out-of-range V1 active tab index', () => {
setV1Data(
{
'workflows/a.json': {
data: '{}',
updatedAt: 1000,
name: 'a',
isTemporary: true
}
},
['workflows/a.json']
)
localStorage.setItem(
'Comfy.OpenWorkflowsPaths',
JSON.stringify(['workflows/a.json'])
)
localStorage.setItem('Comfy.ActiveWorkflowIndex', JSON.stringify(10))
migrateV1toV2(workspaceId, 'client-123')
expect(readOpenPaths('client-123', workspaceId)?.activeIndex).toBe(0)
})
it('defaults V1 tab index when active index is invalid', () => {
setV1Data(
{
'workflows/a.json': {
data: '{}',
updatedAt: 1000,
name: 'a',
isTemporary: true
}
},
['workflows/a.json']
)
localStorage.setItem(
'Comfy.OpenWorkflowsPaths',
JSON.stringify(['workflows/a.json'])
)
localStorage.setItem('Comfy.ActiveWorkflowIndex', JSON.stringify('bad'))
migrateV1toV2(workspaceId, 'client-123')
expect(readOpenPaths('client-123', workspaceId)?.activeIndex).toBe(0)
})
it('ignores invalid V1 tab state paths', () => {
setV1Data(
{
'workflows/a.json': {
data: '{}',
updatedAt: 1000,
name: 'a',
isTemporary: true
}
},
['workflows/a.json']
)
localStorage.setItem('Comfy.OpenWorkflowsPaths', JSON.stringify([]))
migrateV1toV2(workspaceId, 'client-123')
expect(readOpenPaths('client-123', workspaceId)).toBeNull()
})
})
describe('getMigrationStatus', () => {

View File

@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DRAFTS } from '../base/draftTypes'
import { StorageKeys } from '../base/storageKeys'
import { useWorkflowDraftStoreV2 } from './workflowDraftStoreV2'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { app as comfyApp } from '@/scripts/app'
vi.mock('@/scripts/api', () => ({
api: {
@@ -195,6 +197,12 @@ describe('workflowDraftStoreV2', () => {
)
expect(payloadKeys).toHaveLength(0)
})
it('ignores missing drafts', () => {
const store = useWorkflowDraftStoreV2()
expect(() => store.removeDraft('workflows/missing.json')).not.toThrow()
})
})
describe('moveDraft', () => {
@@ -240,6 +248,121 @@ describe('workflowDraftStoreV2', () => {
vi.useRealTimers()
}
})
it('ignores missing source drafts', () => {
const store = useWorkflowDraftStoreV2()
store.moveDraft('workflows/missing.json', 'workflows/new.json', 'new')
expect(store.getDraft('workflows/new.json')).toBeNull()
})
it('does not move when the old payload is missing', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/old.json', '{"data":"test"}', {
name: 'old',
isTemporary: true
})
localStorage.removeItem(
StorageKeys.draftPayload('workflows/old.json', 'personal')
)
store.reset()
store.moveDraft('workflows/old.json', 'workflows/new.json', 'new')
expect(store.getDraft('workflows/new.json')).toBeNull()
})
it('keeps the original draft when writing the moved payload fails', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/old.json', '{"data":"test"}', {
name: 'old',
isTemporary: true
})
const originalSetItem = localStorage.setItem.bind(localStorage)
const newPayloadKey = StorageKeys.draftPayload(
'workflows/new.json',
'personal'
)
const setItemSpy = vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (key === newPayloadKey) {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})
try {
store.moveDraft('workflows/old.json', 'workflows/new.json', 'new')
expect(store.getDraft('workflows/old.json')).not.toBeNull()
expect(store.getDraft('workflows/new.json')).toBeNull()
} finally {
setItemSpy.mockRestore()
}
})
it('removes the moved payload when persisting the moved index fails', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/old.json', '{"data":"test"}', {
name: 'old',
isTemporary: true
})
const originalSetItem = localStorage.setItem.bind(localStorage)
const indexKey = StorageKeys.draftIndex('personal')
const newPayloadKey = StorageKeys.draftPayload(
'workflows/new.json',
'personal'
)
const setItemSpy = vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (key === indexKey) {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})
try {
store.moveDraft('workflows/old.json', 'workflows/new.json', 'new')
expect(localStorage.getItem(newPayloadKey)).toBeNull()
} finally {
setItemSpy.mockRestore()
}
})
})
describe('getDraft', () => {
it('removes stale index entries when the payload is missing', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/test.json', '{"nodes":[]}', {
name: 'test',
isTemporary: true
})
localStorage.removeItem(
StorageKeys.draftPayload('workflows/test.json', 'personal')
)
store.reset()
expect(store.getDraft('workflows/test.json')).toBeNull()
expect(store.getMostRecentPath()).toBeNull()
})
})
describe('markDraftUsed', () => {
it('ignores unknown draft paths', () => {
const store = useWorkflowDraftStoreV2()
expect(() => store.markDraftUsed('workflows/missing.json')).not.toThrow()
})
})
describe('getMostRecentPath', () => {
@@ -262,6 +385,22 @@ describe('workflowDraftStoreV2', () => {
const store = useWorkflowDraftStoreV2()
expect(store.getMostRecentPath()).toBeNull()
})
it('returns null when the newest index key has no entry', () => {
const indexKey = StorageKeys.draftIndex('personal')
localStorage.setItem(
indexKey,
JSON.stringify({
v: 2,
updatedAt: Date.now(),
order: ['missing'],
entries: {}
})
)
const store = useWorkflowDraftStoreV2()
expect(store.getMostRecentPath()).toBeNull()
})
})
describe('loadPersistedWorkflow', () => {
@@ -308,6 +447,57 @@ describe('workflowDraftStoreV2', () => {
expect(result).toBe(false)
})
it('loads legacy session workflow payloads in personal workspace', async () => {
const store = useWorkflowDraftStoreV2()
sessionStorage.setItem('workflow:test-client', '{"nodes":[]}')
const result = await store.loadPersistedWorkflow({
workflowName: 'legacy'
})
expect(result).toBe(true)
expect(comfyApp.loadGraphData).toHaveBeenCalledWith(
{ nodes: [] },
true,
true,
'legacy'
)
})
it('falls back to legacy local workflow payloads in personal workspace', async () => {
const store = useWorkflowDraftStoreV2()
localStorage.setItem('workflow', '{"nodes":[1]}')
const result = await store.loadPersistedWorkflow({
workflowName: null
})
expect(result).toBe(true)
expect(comfyApp.loadGraphData).toHaveBeenCalledWith(
{ nodes: [1] },
true,
true,
null
)
})
it('does not load legacy payloads for non-personal workspaces', async () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify({ id: 'team-1', type: 'organization' })
)
sessionStorage.setItem('workflow:test-client', '{"nodes":[]}')
localStorage.setItem('workflow', '{"nodes":[]}')
const store = useWorkflowDraftStoreV2()
const result = await store.loadPersistedWorkflow({
workflowName: 'team'
})
expect(result).toBe(false)
expect(comfyApp.loadGraphData).not.toHaveBeenCalled()
})
})
describe('reset', () => {

View File

@@ -15,8 +15,10 @@ const mockWorkflowStore = reactive<{
isModified: boolean
lastModified: number
} | null
saveWorkflow: ReturnType<typeof vi.fn>
}>({
activeWorkflow: null
activeWorkflow: null,
saveWorkflow: vi.fn()
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
@@ -63,13 +65,18 @@ vi.mock(
})
)
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
saveWorkflow: vi.fn(),
renameWorkflow: vi.fn()
})
const mockWorkflowService = vi.hoisted(() => ({
saveWorkflow: vi.fn(),
renameWorkflow: vi.fn()
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => mockWorkflowService
}))
const mockInputFocus = vi.hoisted(() => vi.fn())
const mockInputSelect = vi.hoisted(() => vi.fn())
const mockShareServiceData = vi.hoisted(() => ({
items: [
{
@@ -113,6 +120,8 @@ const i18n = createI18n({
g: { close: 'Close', error: 'Error' },
shareWorkflow: {
unsavedDescription: 'You must save your workflow before sharing.',
saveFailedTitle: 'Save failed',
saveFailedDescription: 'Unable to save workflow',
shareLinkTab: 'Share',
publishToHubTab: 'Publish',
workflowNameLabel: 'Workflow name',
@@ -138,6 +147,9 @@ const i18n = createI18n({
introTitle: 'Introducing ComfyHub',
createProfileButton: 'Create my profile',
startPublishingButton: 'Start publishing'
},
comfyHubPublish: {
unsavedDescription: 'Save before publishing.'
}
}
}
@@ -152,6 +164,9 @@ describe('ShareWorkflowDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.saveWorkflow.mockReset()
mockWorkflowService.saveWorkflow.mockReset()
mockWorkflowService.renameWorkflow.mockReset()
mockPublishWorkflow.mockReset()
mockGetShareableAssets.mockReset()
mockWorkflowStore.activeWorkflow = {
@@ -214,8 +229,14 @@ describe('ShareWorkflowDialogContent', () => {
props: ['onCreateProfile']
},
Input: {
template: '<input v-bind="$attrs" />',
methods: { focus() {}, select() {} }
template:
'<input v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue'],
emits: ['update:modelValue'],
methods: {
focus: mockInputFocus,
select: mockInputSelect
}
}
}
}
@@ -556,5 +577,150 @@ describe('ShareWorkflowDialogContent', () => {
expect(screen.queryByTestId('publish-tab-panel')).not.toBeInTheDocument()
expect(container.textContent).not.toContain('Publish')
})
it('focuses the temporary workflow name when switching to publish mode', async () => {
mockFlags.comfyHubUploadEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'Unsaved Workflow.json',
directory: '',
filename: 'Unsaved Workflow.json',
isTemporary: true,
isModified: false,
lastModified: 1000
}
renderComponent()
await flushPromises()
mockInputFocus.mockClear()
mockInputSelect.mockClear()
await userEvent.click(screen.getByRole('tab', { name: /Publish/ }))
await nextTick()
expect(mockInputFocus).toHaveBeenCalled()
expect(mockInputSelect).toHaveBeenCalled()
})
it('renames and saves a temporary workflow from publish mode', async () => {
mockFlags.comfyHubUploadEnabled = true
const workflow = {
path: 'Unsaved Workflow.json',
directory: '',
filename: 'Unsaved Workflow.json',
isTemporary: true,
isModified: false,
lastModified: 1000
}
mockWorkflowStore.activeWorkflow = workflow
renderComponent()
await flushPromises()
await userEvent.click(screen.getByRole('tab', { name: /Publish/ }))
await userEvent.clear(screen.getByRole('textbox'))
await userEvent.type(screen.getByRole('textbox'), ' Better name ')
await userEvent.click(
screen.getByRole('button', { name: /Save workflow/ })
)
await flushPromises()
expect(mockWorkflowService.renameWorkflow).toHaveBeenCalledWith(
workflow,
'Better name.json'
)
expect(mockWorkflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('does not save a temporary workflow with a blank name', async () => {
mockWorkflowStore.activeWorkflow = {
path: 'Unsaved Workflow.json',
directory: 'workflows',
filename: 'Unsaved Workflow.json',
isTemporary: true,
isModified: false,
lastModified: 1000
}
renderComponent()
await flushPromises()
await userEvent.clear(screen.getByRole('textbox'))
await userEvent.click(
screen.getByRole('button', { name: /Save workflow/ })
)
await flushPromises()
expect(mockWorkflowService.renameWorkflow).not.toHaveBeenCalled()
expect(mockWorkflowStore.saveWorkflow).not.toHaveBeenCalled()
})
it('saves a modified persisted workflow without renaming it', async () => {
const workflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: true,
lastModified: 1000
}
mockWorkflowStore.activeWorkflow = workflow
renderComponent()
await flushPromises()
await userEvent.click(
screen.getByRole('button', { name: /Save workflow/ })
)
await flushPromises()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledWith(workflow)
expect(mockWorkflowService.renameWorkflow).not.toHaveBeenCalled()
expect(mockWorkflowStore.saveWorkflow).not.toHaveBeenCalled()
})
it('shows an error toast when saving fails', async () => {
mockWorkflowStore.activeWorkflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: true,
lastModified: 1000
}
mockWorkflowService.saveWorkflow.mockRejectedValue(new Error('disk full'))
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
renderComponent()
await flushPromises()
await userEvent.click(
screen.getByRole('button', { name: /Save workflow/ })
)
await flushPromises()
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Save failed',
detail: 'Unable to save workflow'
})
errorSpy.mockRestore()
})
it('uses the generic error copy for non-error publish failures', async () => {
mockGetShareableAssets.mockResolvedValue([])
mockPublishWorkflow.mockRejectedValue('offline')
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
renderComponent()
await flushPromises()
await userEvent.click(screen.getByRole('button', { name: /Create link/ }))
await flushPromises()
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Error'
})
errorSpy.mockRestore()
})
})
})

View File

@@ -0,0 +1,279 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ComfyHubCreateProfileForm from './ComfyHubCreateProfileForm.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
const mockCreateProfile = vi.hoisted(() => vi.fn())
const mockToast = vi.hoisted(() => ({
add: vi.fn()
}))
const mockIsFileTooLarge = vi.hoisted(() => vi.fn())
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
vi.mock('primevue/usetoast', () => ({
useToast: () => mockToast
}))
vi.mock('@vueuse/core', async () => {
const { computed } = await import('vue')
return {
useObjectUrl: (file: { value: File | null }) =>
computed(() => (file.value ? `blob:${file.value.name}` : undefined))
}
})
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
createProfile: mockCreateProfile
})
})
)
vi.mock('@/platform/workflow/sharing/utils/validateFileSize', () => ({
MAX_IMAGE_SIZE_MB: 10,
isFileTooLarge: mockIsFileTooLarge
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['disabled', 'ariaLabel'],
emits: ['click'],
template: `
<button
type="button"
:disabled="disabled"
:aria-label="ariaLabel"
@click="$emit('click')"
>
<slot />
</button>
`
}
}))
vi.mock('@/components/ui/input/Input.vue', () => ({
default: {
props: ['modelValue', 'id', 'placeholder'],
emits: ['update:modelValue'],
template: `
<input
:id="id"
:value="modelValue"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
}
}))
vi.mock('@/components/ui/textarea/Textarea.vue', () => ({
default: {
props: ['modelValue', 'id', 'placeholder'],
emits: ['update:modelValue'],
template: `
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
}
}))
function renderForm(
props: Partial<ComponentProps<typeof ComfyHubCreateProfileForm>> = {}
) {
return render(ComfyHubCreateProfileForm, {
props: {
onProfileCreated: vi.fn(),
onClose: vi.fn(),
...props
},
global: {
plugins: [i18n]
}
})
}
function profileFile(name = 'avatar.png') {
return new File(['image'], name, { type: 'image/png' })
}
async function flushPromises() {
await Promise.resolve()
await nextTick()
}
describe('ComfyHubCreateProfileForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateProfile.mockResolvedValue({
username: 'valid-user',
name: 'Valid User'
})
mockIsFileTooLarge.mockReturnValue(false)
})
it('renders close and cancel actions and can hide the close header button', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const { unmount } = renderForm({ onClose })
await user.click(screen.getByRole('button', { name: 'g.close' }))
expect(onClose).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'g.cancel' }))
expect(onClose).toHaveBeenCalledTimes(2)
unmount()
renderForm({ onClose, showCloseButton: false })
expect(
screen.queryByRole('button', { name: 'g.close' })
).not.toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
})
it('validates usernames and derives the profile initial from name or username', async () => {
const user = userEvent.setup()
renderForm()
expect(screen.getByText('C')).toBeInTheDocument()
await user.type(screen.getByLabelText('comfyHubProfile.nameLabel'), 'Ada')
expect(screen.getByText('A')).toBeInTheDocument()
await user.clear(screen.getByLabelText('comfyHubProfile.nameLabel'))
await user.type(
screen.getByLabelText('comfyHubProfile.usernameLabel'),
'bad_name'
)
expect(screen.getByText('B')).toBeInTheDocument()
expect(
screen.getByText('comfyHubProfile.usernameError')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'comfyHubProfile.createProfile' })
).toBeDisabled()
})
it('ignores oversized images and previews an accepted profile image', async () => {
const user = userEvent.setup()
renderForm()
const input = screen.getByLabelText('comfyHubProfile.chooseProfilePicture')
mockIsFileTooLarge.mockReturnValueOnce(true)
await user.upload(input, profileFile('large.png'))
expect(
screen.queryByAltText('comfyHubProfile.chooseProfilePicture')
).not.toBeInTheDocument()
const acceptedFile = profileFile()
await user.upload(input, acceptedFile)
expect(mockIsFileTooLarge).toHaveBeenLastCalledWith(acceptedFile, 10)
expect(
screen.getByAltText('comfyHubProfile.chooseProfilePicture')
).toHaveAttribute('src', 'blob:avatar.png')
})
it('creates a trimmed profile and reports it to the parent', async () => {
const user = userEvent.setup()
const onProfileCreated = vi.fn()
renderForm({ onProfileCreated })
const file = profileFile()
const input = screen.getByLabelText('comfyHubProfile.chooseProfilePicture')
await user.upload(input, file)
await user.type(
screen.getByLabelText('comfyHubProfile.usernameLabel'),
'valid-user'
)
await user.type(
screen.getByLabelText('comfyHubProfile.nameLabel'),
' Ada Lovelace '
)
await user.type(
screen.getByLabelText('comfyHubProfile.descriptionLabel'),
' '
)
await user.click(
screen.getByRole('button', { name: 'comfyHubProfile.createProfile' })
)
expect(mockCreateProfile).toHaveBeenCalledWith({
username: 'valid-user',
name: 'Ada Lovelace',
description: undefined,
profilePicture: file
})
expect(onProfileCreated).toHaveBeenCalledWith({
username: 'valid-user',
name: 'Valid User'
})
})
it('shows loading text while creating and surfaces creation errors', async () => {
const user = userEvent.setup()
let resolveCreate: (profile: { username: string }) => void
mockCreateProfile.mockReturnValueOnce(
new Promise<{ username: string }>((resolve) => {
resolveCreate = resolve
})
)
renderForm()
await user.type(
screen.getByLabelText('comfyHubProfile.usernameLabel'),
'valid-user'
)
await user.click(
screen.getByRole('button', { name: 'comfyHubProfile.createProfile' })
)
await nextTick()
expect(
screen.getByRole('button', { name: 'comfyHubProfile.creatingProfile' })
).toBeDisabled()
resolveCreate!({ username: 'valid-user' })
await flushPromises()
mockCreateProfile.mockRejectedValueOnce(new Error('already taken'))
await user.click(
screen.getByRole('button', { name: 'comfyHubProfile.createProfile' })
)
await nextTick()
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'already taken'
})
mockCreateProfile.mockRejectedValueOnce('unknown')
await user.click(
screen.getByRole('button', { name: 'comfyHubProfile.createProfile' })
)
await nextTick()
expect(mockToast.add).toHaveBeenLastCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'g.error'
})
})
})

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import ComfyHubPublishIntroPanel from './ComfyHubPublishIntroPanel.vue'
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['ariaLabel'],
emits: ['click'],
template: `
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
<slot />
</button>
`
}
}))
function renderPanel(
props: Partial<ComponentProps<typeof ComfyHubPublishIntroPanel>> = {}
) {
return render(ComfyHubPublishIntroPanel, {
props: {
onCreateProfile: vi.fn(),
onClose: vi.fn(),
...props
},
global: {
mocks: { $t: (key: string) => key }
}
})
}
describe('ComfyHubPublishIntroPanel', () => {
it('renders the publish intro and handles close and create actions', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onCreateProfile = vi.fn()
renderPanel({ onClose, onCreateProfile })
expect(screen.getByText('comfyHubProfile.introTitle')).toBeInTheDocument()
expect(
screen.getByText('comfyHubProfile.introDescription')
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'g.close' }))
await user.click(
screen.getByRole('button', {
name: 'comfyHubProfile.startPublishingButton'
})
)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onCreateProfile).toHaveBeenCalledTimes(1)
})
it('renders the update variant without the close button', () => {
renderPanel({ showCloseButton: false, isUpdate: true })
expect(
screen.queryByRole('button', { name: 'g.close' })
).not.toBeInTheDocument()
expect(
screen.getByText('comfyHubProfile.updateIntroTitle')
).toBeInTheDocument()
expect(
screen.getByText('comfyHubProfile.updateIntroDescription')
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'comfyHubProfile.startUpdatingButton'
})
).toBeInTheDocument()
})
})

View File

@@ -1,15 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { MAX_IMAGE_SIZE_MB } from '@/platform/workflow/sharing/utils/validateFileSize'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
type DragData = Record<string, unknown>
type DraggableOptions = {
getInitialData?: () => DragData
}
type MonitorOptions = {
canMonitor: (args: { source: { data: DragData } }) => boolean
onDrop: (args: {
source: { data: DragData }
location: {
current: {
dropTargets: Array<{ data: DragData }>
}
}
}) => void
}
const pragmatic = vi.hoisted(() => ({
draggables: [] as DraggableOptions[],
monitor: undefined as MonitorOptions | undefined,
cleanupMonitor: vi.fn()
}))
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
draggable: vi.fn(() => vi.fn()),
draggable: vi.fn((options: DraggableOptions) => {
pragmatic.draggables.push(options)
return vi.fn()
}),
dropTargetForElements: vi.fn(() => vi.fn()),
monitorForElements: vi.fn(() => vi.fn())
monitorForElements: vi.fn((options: MonitorOptions) => {
pragmatic.monitor = options
return pragmatic.cleanupMonitor
})
}))
function createImages(count: number): ExampleImage[] {
@@ -19,6 +52,14 @@ function createImages(count: number): ExampleImage[] {
}))
}
function createImageFile(name: string, size = 1): File {
return new File([new Uint8Array(size)], name, { type: 'image/png' })
}
function createTextFile(name: string): File {
return new File(['text'], name, { type: 'text/plain' })
}
function renderStep(
images: ExampleImage[],
callbacks: Record<string, ReturnType<typeof vi.fn>> = {}
@@ -31,9 +72,33 @@ function renderStep(
})
}
function getUploadInput() {
const labelContent = screen.getByText('comfyHubPublish.uploadExampleImage')
// eslint-disable-next-line testing-library/no-node-access
const label = labelContent.closest('label')
// eslint-disable-next-line testing-library/no-node-access
const input = label?.querySelector('input[type="file"]')
if (!(input instanceof HTMLInputElement)) {
throw new Error('Missing file input')
}
return input
}
describe('ComfyHubExamplesStep', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
pragmatic.draggables = []
pragmatic.monitor = undefined
pragmatic.cleanupMonitor.mockClear()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((file: File) => `blob:${file.name}`)
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn()
})
})
it('renders all example images', () => {
@@ -111,4 +176,209 @@ describe('ComfyHubExamplesStep', () => {
expect(onUpdateExampleImages).toHaveBeenCalled()
expect(onUpdateExampleImages.mock.calls[0][0]).toHaveLength(1)
})
it('hides the upload tile when the example limit is reached', () => {
renderStep(createImages(8))
expect(
screen.queryByRole('button', {
name: 'comfyHubPublish.uploadExampleImage'
})
).toBeNull()
})
it('prepends selected image files and filters invalid uploads', async () => {
const onUpdateExampleImages = vi.fn()
renderStep(createImages(1), {
'onUpdate:exampleImages': onUpdateExampleImages
})
await userEvent.upload(getUploadInput(), [
createImageFile('valid.png'),
createTextFile('notes.txt'),
createImageFile('too-large.png', MAX_IMAGE_SIZE_MB * 1024 * 1024 + 1)
])
const updated = onUpdateExampleImages.mock.calls[0][0] as ExampleImage[]
expect(updated.map((image) => image.url)).toEqual([
'blob:valid.png',
'blob:http://localhost/img-0'
])
})
it('revokes overflow uploads when only one example slot remains', async () => {
const onUpdateExampleImages = vi.fn()
renderStep(createImages(7), {
'onUpdate:exampleImages': onUpdateExampleImages
})
await fireEvent.drop(
screen.getByRole('button', {
name: 'comfyHubPublish.uploadExampleImage'
}),
{
dataTransfer: {
files: [createImageFile('first.png'), createImageFile('second.png')]
}
}
)
const updated = onUpdateExampleImages.mock.calls[0][0] as ExampleImage[]
expect(updated).toHaveLength(8)
expect(updated[0].url).toBe('blob:first.png')
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:second.png')
})
it('revokes object URLs when removing uploaded images', async () => {
const onUpdateExampleImages = vi.fn()
const uploaded = createImageFile('uploaded.png')
renderStep(
[
{
id: 'uploaded',
url: 'blob:uploaded.png',
file: uploaded
}
],
{
'onUpdate:exampleImages': onUpdateExampleImages
}
)
await userEvent.click(
screen.getByRole('button', {
name: 'comfyHubPublish.removeExampleImage'
})
)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:uploaded.png')
expect(onUpdateExampleImages.mock.calls[0][0]).toEqual([])
})
it('monitors drags from its own image grid only', () => {
renderStep(createImages(1))
const monitor = pragmatic.monitor
const dragData = pragmatic.draggables[0]?.getInitialData?.()
if (!monitor || !dragData) {
throw new Error('Missing drag monitor setup')
}
expect(monitor.canMonitor({ source: { data: dragData } })).toBe(true)
expect(
monitor.canMonitor({
source: {
data: {
...dragData,
instanceId: Symbol('other-grid')
}
}
})
).toBe(false)
})
it('reorders images when the drag monitor drops on another image', () => {
const onUpdateExampleImages = vi.fn()
renderStep(createImages(3), {
'onUpdate:exampleImages': onUpdateExampleImages
})
const monitor = pragmatic.monitor
const dragData = pragmatic.draggables[0]?.getInitialData?.()
if (!monitor || !dragData) {
throw new Error('Missing drag monitor setup')
}
monitor.onDrop({
source: { data: dragData },
location: {
current: {
dropTargets: [{ data: { imageId: 'img-2' } }]
}
}
})
const reordered = onUpdateExampleImages.mock.calls[0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-2', 'img-0'])
})
it('ignores monitor drops without a destination image', () => {
const onUpdateExampleImages = vi.fn()
renderStep(createImages(2), {
'onUpdate:exampleImages': onUpdateExampleImages
})
const monitor = pragmatic.monitor
const dragData = pragmatic.draggables[0]?.getInitialData?.()
if (!monitor || !dragData) {
throw new Error('Missing drag monitor setup')
}
monitor.onDrop({
source: { data: dragData },
location: { current: { dropTargets: [] } }
})
monitor.onDrop({
source: { data: { ...dragData, imageId: 1 } },
location: {
current: {
dropTargets: [{ data: { imageId: 'img-1' } }]
}
}
})
monitor.onDrop({
source: { data: dragData },
location: {
current: {
dropTargets: [{ data: { imageId: 1 } }]
}
}
})
expect(onUpdateExampleImages).not.toHaveBeenCalled()
})
it('inserts files from an image tile drop', async () => {
const onUpdateExampleImages = vi.fn()
renderStep(createImages(2), {
'onUpdate:exampleImages': onUpdateExampleImages
})
await fireEvent.drop(screen.getAllByRole('listitem')[1], {
dataTransfer: {
files: [createImageFile('inserted.png')]
}
})
const updated = onUpdateExampleImages.mock.calls[0][0] as ExampleImage[]
expect(updated.map((image) => image.url)).toEqual([
'blob:http://localhost/img-0',
'blob:inserted.png',
'blob:http://localhost/img-1'
])
})
it('replaces existing images when inserting into a full grid', async () => {
const onUpdateExampleImages = vi.fn()
const original = createImages(8).map((image, index) => ({
...image,
file: index === 1 ? createImageFile('old.png') : undefined
}))
renderStep(original, {
'onUpdate:exampleImages': onUpdateExampleImages
})
await fireEvent.drop(screen.getAllByRole('listitem')[1], {
dataTransfer: {
files: [createImageFile('replacement.png')]
}
})
const updated = onUpdateExampleImages.mock.calls[0][0] as ExampleImage[]
expect(updated.map((image) => image.url).slice(0, 3)).toEqual([
'blob:http://localhost/img-0',
'blob:replacement.png',
'blob:http://localhost/img-2'
])
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:http://localhost/img-1'
)
})
})

View File

@@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
const mockAsyncState = vi.hoisted(() => ({
refs: null as null | {
state: { value: AssetInfo[] }
isLoading: { value: boolean }
error: { value: Error | null }
}
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await import('vue')
const state = ref<AssetInfo[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
mockAsyncState.refs = {
state,
isLoading,
error
}
return {
useAsyncState: () => ({
state,
isLoading,
error
})
}
})
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getShareableAssets: vi.fn()
})
}))
vi.mock(
'@/platform/workflow/sharing/components/ShareAssetWarningBox.vue',
() => ({
default: {
props: ['items', 'acknowledged'],
emits: ['update:acknowledged'],
template: `
<section data-testid="asset-warning">
<span v-for="item in items" :key="item.id">{{ item.name }}</span>
<button type="button" @click="$emit('update:acknowledged', true)">
acknowledge
</button>
</section>
`
}
})
)
const profile: ComfyHubProfile = {
username: 'ada',
name: 'Ada Lovelace',
description: 'First programmer'
}
function setAsyncState({
assets = [],
loading = false,
error = null
}: {
assets?: AssetInfo[]
loading?: boolean
error?: Error | null
} = {}) {
if (!mockAsyncState.refs)
throw new Error('async state refs were not initialized')
mockAsyncState.refs.state.value = assets
mockAsyncState.refs.isLoading.value = loading
mockAsyncState.refs.error.value = error
}
function renderStep(
props: Partial<InstanceType<typeof ComfyHubFinishStep>['$props']> = {}
) {
return render(ComfyHubFinishStep, {
props: {
profile,
...props
},
global: {
mocks: { $t: (key: string) => key }
}
})
}
describe('ComfyHubFinishStep', () => {
beforeEach(() => {
setAsyncState()
})
it('renders profile pictures while assets are loading', () => {
setAsyncState({ loading: true })
renderStep({
profile: {
...profile,
profilePictureUrl: 'https://cdn.example.com/ada.png'
}
})
expect(screen.getByAltText('ada')).toHaveAttribute(
'src',
'https://cdn.example.com/ada.png'
)
expect(screen.getByText('shareWorkflow.checkingAssets')).toBeInTheDocument()
})
it('requires acknowledging private assets before it becomes ready', async () => {
const user = userEvent.setup()
setAsyncState({
assets: [
{
id: 'asset-1',
name: 'private.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
]
})
renderStep()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByTestId('asset-warning')).toHaveTextContent('private.png')
await user.click(screen.getByRole('button', { name: 'acknowledge' }))
await nextTick()
expect(screen.getByTestId('asset-warning')).toBeInTheDocument()
})
it('is ready when no assets are private', () => {
renderStep()
expect(
screen.queryByText('comfyHubPublish.additionalInfo')
).not.toBeInTheDocument()
})
it('is not ready when asset loading fails', () => {
setAsyncState({ error: new Error('failed') })
renderStep()
expect(
screen.queryByText('shareWorkflow.checkingAssets')
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
function renderProfilePrompt() {
return render(ComfyHubProfilePromptPanel, {
global: {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}
}
})
}
describe('ComfyHubProfilePromptPanel', () => {
it('emits a profile request from the create profile CTA', async () => {
const user = userEvent.setup()
const { emitted } = renderProfilePrompt()
await user.click(screen.getByText('comfyHubPublish.createProfileCta'))
expect(
screen.getByText('comfyHubPublish.createProfileToPublish')
).toBeTruthy()
expect(emitted().requestProfile).toHaveLength(1)
})
})

View File

@@ -1,15 +1,10 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const mockToastAdd = vi.hoisted(() => vi.fn())
@@ -31,9 +26,19 @@ const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
const mockRenameWorkflow = vi.hoisted(() => vi.fn())
const mockWorkflowServiceSaveWorkflow = vi.hoisted(() => vi.fn())
const mockWorkflowStoreSaveWorkflow = vi.hoisted(() => vi.fn())
const mockInputFocus = vi.hoisted(() => vi.fn())
const mockInputSelect = vi.hoisted(() => vi.fn())
const mockFormDataHolder = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
const mockFormDataRefHolder = vi.hoisted(
() =>
({ value: null }) as {
value: null | { value: Record<string, unknown> | null }
}
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -64,10 +69,12 @@ vi.mock(
tutorialUrl: '',
metadata: {}
}
const formData = ref(mockFormDataHolder.value)
mockFormDataRefHolder.value = formData
return {
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref(mockFormDataHolder.value),
formData,
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
@@ -101,7 +108,7 @@ vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: mockRenameWorkflow,
saveWorkflow: vi.fn()
saveWorkflow: mockWorkflowServiceSaveWorkflow
})
}))
@@ -128,7 +135,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
get activeWorkflow() {
return mockWorkflowStore.instance?.activeWorkflow ?? null
},
saveWorkflow: vi.fn()
saveWorkflow: mockWorkflowStoreSaveWorkflow
})
}
})
@@ -158,7 +165,15 @@ describe('ComfyHubPublishDialog', () => {
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockRenameWorkflow.mockResolvedValue(undefined)
mockWorkflowServiceSaveWorkflow.mockResolvedValue(undefined)
mockWorkflowStoreSaveWorkflow.mockResolvedValue(undefined)
mockInputFocus.mockClear()
mockInputSelect.mockClear()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = ''
if (mockFormDataHolder.value) mockFormDataHolder.value.exampleImages = []
if (mockFormDataRefHolder.value) {
mockFormDataRefHolder.value.value = mockFormDataHolder.value
}
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
@@ -173,9 +188,7 @@ describe('ComfyHubPublishDialog', () => {
return render(ComfyHubPublishDialog, {
props: { onClose },
global: {
mocks: {
$t: (key: string) => key
},
plugins: [i18n],
stubs: {
BaseModalLayout: {
template:
@@ -191,13 +204,15 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
'<div data-testid="publish-wizard-content" :data-is-publishing="$props.isPublishing" :data-is-update="$props.isUpdate"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /><button data-testid="patch-form" @click="$props.onUpdateFormData({ description: \'patched\' })" /></div>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'isPublishing',
'isUpdate',
'onUpdateFormData',
'onGoNext',
'onGoBack',
'onPublish',
@@ -205,7 +220,44 @@ describe('ComfyHubPublishDialog', () => {
'onGateComplete',
'onGateClose'
]
}
},
Button: {
template:
'<button data-testid="save-workflow" :data-loading="loading" @click="$emit(\'click\')"><slot /></button>',
props: ['loading'],
emits: ['click']
},
Input: defineComponent({
props: {
modelValue: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
setup(props, { emit, expose }) {
expose({
focus: mockInputFocus,
select: mockInputSelect
})
return () =>
h('input', {
'data-testid': 'workflow-name',
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => {
emit(
'update:modelValue',
(event.target as HTMLInputElement).value
)
}
})
}
})
}
}
})
@@ -484,6 +536,215 @@ describe('ComfyHubPublishDialog', () => {
expect(mockGetPublishStatus).not.toHaveBeenCalled()
})
it('shows the save prompt and focuses the temporary workflow name', async () => {
setActiveWorkflow({
path: null,
filename: 'draft.json',
directory: '',
isTemporary: true,
isModified: false
})
renderComponent()
await flushPromises()
expect(screen.getByTestId('publish-save-prompt')).toBeInTheDocument()
expect(screen.queryByTestId('publish-nav')).not.toBeInTheDocument()
expect(mockInputFocus).toHaveBeenCalledOnce()
expect(mockInputSelect).toHaveBeenCalledOnce()
})
it('renames and saves a temporary workflow before showing the wizard', async () => {
const workflow = {
path: null,
filename: 'draft.json',
directory: '',
isTemporary: true,
isModified: false
}
setActiveWorkflow(workflow)
renderComponent()
await flushPromises()
await userEvent.clear(screen.getByTestId('workflow-name'))
await userEvent.type(screen.getByTestId('workflow-name'), 'Saved Name.json')
await userEvent.click(screen.getByTestId('save-workflow'))
await flushPromises()
expect(mockRenameWorkflow).toHaveBeenCalledWith(workflow, 'Saved Name.json')
expect(mockWorkflowStoreSaveWorkflow).toHaveBeenCalledWith(workflow)
})
it('does not save a temporary workflow with a blank name', async () => {
setActiveWorkflow({
path: null,
filename: 'draft.json',
directory: '',
isTemporary: true,
isModified: false
})
renderComponent()
await flushPromises()
await userEvent.clear(screen.getByTestId('workflow-name'))
await userEvent.click(screen.getByTestId('save-workflow'))
await flushPromises()
expect(mockRenameWorkflow).not.toHaveBeenCalled()
expect(mockWorkflowStoreSaveWorkflow).not.toHaveBeenCalled()
})
it('saves a modified workflow without renaming it', async () => {
const workflow = {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: true
}
setActiveWorkflow(workflow)
renderComponent()
await flushPromises()
await userEvent.click(screen.getByTestId('save-workflow'))
await flushPromises()
expect(mockWorkflowServiceSaveWorkflow).toHaveBeenCalledWith(workflow)
expect(mockRenameWorkflow).not.toHaveBeenCalled()
})
it('shows a save error toast when saving fails', async () => {
setActiveWorkflow({
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: true
})
mockWorkflowServiceSaveWorkflow.mockRejectedValueOnce(
new Error('save failed')
)
renderComponent()
await flushPromises()
await userEvent.click(screen.getByTestId('save-workflow'))
await flushPromises()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('does not save when there is no active workflow', async () => {
setActiveWorkflow(null)
renderComponent()
await flushPromises()
await userEvent.click(screen.getByTestId('save-workflow'))
await flushPromises()
expect(mockWorkflowServiceSaveWorkflow).not.toHaveBeenCalled()
expect(mockWorkflowStoreSaveWorkflow).not.toHaveBeenCalled()
})
it('uses cached prefill when an already published workflow has no server prefill', async () => {
const cached = { description: 'cached published data' }
mockGetCachedPrefill.mockReturnValue(cached)
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: null
})
renderComponent()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith(cached)
expect(screen.getByTestId('publish-wizard-content')).toHaveAttribute(
'data-is-update',
'true'
)
})
it('ignores stale prefill errors after the workflow path changes', async () => {
let rejectStale: (error: unknown) => void = () => {}
mockGetPublishStatus.mockImplementation((path: string) => {
if (path === 'workflows/test.json') {
return new Promise((_resolve, reject) => {
rejectStale = reject
})
}
return Promise.resolve({
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null,
prefill: null
})
})
renderComponent()
await nextTick()
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
await nextTick()
await flushPromises()
rejectStale(new Error('stale failure'))
await flushPromises()
expect(mockGetCachedPrefill).not.toHaveBeenCalledWith('workflows/test.json')
})
it('updates form data patches from wizard content', async () => {
renderComponent()
await flushPromises()
await userEvent.click(screen.getByTestId('patch-form'))
expect(mockFormDataRefHolder.value?.value).toMatchObject({
description: 'patched'
})
})
it('revokes uploaded example image object URLs on unmount', async () => {
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', {
...URL,
revokeObjectURL
})
const file = new File(['image'], 'example.png', { type: 'image/png' })
if (mockFormDataHolder.value) {
mockFormDataHolder.value.exampleImages = [
{
id: 'uploaded',
url: 'blob:uploaded',
file
},
{
id: 'remote',
url: 'https://example.com/remote.png',
file: null
}
]
}
const { unmount } = renderComponent()
await flushPromises()
unmount()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:uploaded')
expect(revokeObjectURL).not.toHaveBeenCalledWith(
'https://example.com/remote.png'
)
})
it('ignores a stale prefill response after the workflow path changes', async () => {
const stalePrefill = { description: 'stale' }
let resolveStale: (value: unknown) => void = () => {}

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
@@ -10,7 +11,10 @@ function renderFooter(props: Record<string, unknown> = {}) {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
template: '<button><slot /></button>'
props: ['disabled', 'loading'],
emits: ['click'],
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>'
}
}
}
@@ -27,4 +31,28 @@ describe('ComfyHubPublishFooter', () => {
renderFooter({ isUpdate: true })
expect(screen.getByText('comfyHubPublish.updateButton')).toBeTruthy()
})
it('shows only the next action on the first non-final step', () => {
renderFooter({ isFirstStep: true, isLastStep: false })
expect(screen.queryByText('comfyHubPublish.back')).toBeNull()
expect(screen.getByText('comfyHubPublish.next')).toBeTruthy()
})
it('emits back and next from middle steps', async () => {
const user = userEvent.setup()
const { emitted } = renderFooter({ isFirstStep: false, isLastStep: false })
await user.click(screen.getByText('comfyHubPublish.back'))
await user.click(screen.getByText('comfyHubPublish.next'))
expect(emitted().back).toHaveLength(1)
expect(emitted().next).toHaveLength(1)
})
it('disables publish while publishing', () => {
renderFooter({ isPublishDisabled: false, isPublishing: true })
expect(screen.getByText('comfyHubPublish.publishButton')).toBeDisabled()
})
})

View File

@@ -0,0 +1,81 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ComfyHubPublishNav from './ComfyHubPublishNav.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
vi.mock('@formkit/auto-animate/vue', () => ({
vAutoAnimate: {}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
emits: ['click'],
template: `
<button type="button" v-bind="$attrs" @click="$emit('click')">
<slot />
</button>
`
}
}))
vi.mock('@/components/common/StatusBadge.vue', () => ({
default: {
props: ['label'],
template:
'<span data-testid="step-badge" v-bind="$attrs">{{ label }}</span>'
}
}))
describe('ComfyHubPublishNav', () => {
it('marks current and completed steps and emits clicked steps', async () => {
const user = userEvent.setup()
const { emitted } = render(ComfyHubPublishNav, {
props: {
currentStep: 'examples'
},
global: {
plugins: [i18n]
}
})
const nav = screen.getByTestId('publish-nav')
expect(
within(nav).getByText('comfyHubPublish.stepExamples')
).toBeInTheDocument()
const currentStepItem = screen.getByRole('listitem', { current: 'step' })
expect(currentStepItem).toHaveTextContent('comfyHubPublish.stepExamples')
expect(currentStepItem).toHaveAttribute('aria-current', 'step')
expect(screen.getAllByTestId('step-badge')[0]).toHaveClass(
'border-base-foreground'
)
expect(screen.getAllByTestId('step-badge')[2]).toHaveClass(
'border-muted-foreground'
)
await user.click(screen.getByText('comfyHubPublish.stepFinish'))
expect(emitted('stepClick')).toEqual([['finish']])
})
it('renders the profile creation sub-step as part of the finish step', () => {
render(ComfyHubPublishNav, {
props: {
currentStep: 'profileCreation'
},
global: {
plugins: [i18n]
}
})
expect(
screen.getByText('comfyHubProfile.profileCreationNav')
).toBeInTheDocument()
const finishStep = screen.getAllByRole('listitem')[2]
expect(finishStep).toHaveClass('bg-secondary-background-hover')
expect(finishStep).not.toHaveAttribute('aria-current')
})
})

Some files were not shown because too many files have changed in this diff Show More