Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
3d3e7feeb5 test: cover critical store branches 2026-06-30 22:37:09 -07:00
93 changed files with 1134 additions and 4348 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const HEADER_OFFSET = -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_PX,
offset: HEADER_OFFSET,
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"
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-['']"
>
<slot />
</li>

View File

@@ -1,45 +0,0 @@
{
"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,10 +34,6 @@ 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. */
@@ -96,19 +92,13 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
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.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId(TestIds.linear.runButton)
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,9 +172,6 @@ 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

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

View File

@@ -3,7 +3,6 @@ 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 ({
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).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="viewErrorsInGraph"
@click="seeErrors"
>
{{
appMode
@@ -67,18 +67,31 @@ 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 { viewErrorsInGraph } = useViewErrorsInGraph()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
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({ reason: 'avatar_menu_plans' })
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
subscriptionDialog.showPricingTable()
emit('close')
}

View File

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

View File

@@ -1,6 +1,5 @@
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,
@@ -76,10 +75,9 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
* Shows the subscription dialog.
*/
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
showSubscriptionDialog: () => void
}
export interface BillingState {

View File

@@ -7,7 +7,6 @@ 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
@@ -282,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {

View File

@@ -2,7 +2,6 @@ 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,
@@ -190,12 +189,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {

View File

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

View File

@@ -1,105 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -3799,7 +3799,7 @@ export class LGraphNode
const title = String(rawTitle) + (this.pinned ? '📌' : '')
if (title) {
if (selected) {
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR ?? '#FFF'
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
} else {
ctx.fillStyle = this.constructor.title_text_color || default_title_color
}

View File

@@ -54,10 +54,10 @@ export class LiteGraphGlobal {
NODE_COLLAPSED_RADIUS = 10
NODE_COLLAPSED_WIDTH = 80
NODE_TITLE_COLOR = '#999'
NODE_SELECTED_TITLE_COLOR: string | undefined = '#FFF'
NODE_SELECTED_TITLE_COLOR = '#FFF'
NODE_TEXT_SIZE = 14
NODE_TEXT_COLOR = '#AAA'
NODE_TEXT_HIGHLIGHT_COLOR: string | undefined = '#EEE'
NODE_TEXT_HIGHLIGHT_COLOR = '#EEE'
NODE_SUBTEXT_SIZE = 12
NODE_DEFAULT_COLOR = '#333'
NODE_DEFAULT_BGCOLOR = '#353535'

View File

@@ -1,451 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import type {
DefaultConnectionColors,
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeInputSlot } from './NodeInputSlot'
import { NodeOutputSlot } from './NodeOutputSlot'
function createColorContext(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn(() => '#0f0'),
getDisconnectedColor: vi.fn(() => '#f00')
}
}
function createNode(): LGraphNode {
const node = new LGraphNode('Test Node')
node.pos = [0, 0]
return node
}
function createInputSlot(
overrides: Partial<INodeInputSlot> = {},
node = createNode()
): NodeInputSlot {
return new NodeInputSlot(
{
name: 'in',
type: 'STRING',
link: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
function createOutputSlot(
overrides: Partial<INodeOutputSlot> = {},
node = createNode()
): NodeOutputSlot {
return new NodeOutputSlot(
{
name: 'out',
type: 'STRING',
links: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
describe('NodeSlot rendering', () => {
let ctx: CanvasRenderingContext2D
let colorContext: DefaultConnectionColors
beforeEach(() => {
ctx = createMockCanvasRenderingContext2D()
colorContext = createColorContext()
})
describe('draw', () => {
it('draws a disconnected circle slot with its label', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext })
expect(colorContext.getDisconnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
expect(ctx.fill).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith(
'in',
expect.any(Number),
expect.any(Number)
)
})
it('uses the connected colour and a larger radius when highlighted', () => {
const slot = createInputSlot({ link: toLinkId(1) })
slot.draw(ctx, { colorContext, highlight: true })
expect(colorContext.getConnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
5,
0,
Math.PI * 2
)
})
it('prefers color_on over the colour context when connected', () => {
const slot = createInputSlot({ link: toLinkId(1), color_on: '#abc' })
let fillStyleAtFill: typeof ctx.fillStyle | undefined
vi.mocked(ctx.fill).mockImplementation(() => {
fillStyleAtFill = ctx.fillStyle
})
slot.draw(ctx, { colorContext })
expect(fillStyleAtFill).toBe('#abc')
expect(ctx.fillStyle).not.toBe('#abc') // restored after draw
})
it('draws a box for event slots', () => {
const slot = createInputSlot({ type: SlotType.Event })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.BOX })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws a triangle for arrow-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.ARROW })
slot.draw(ctx, { colorContext })
expect(ctx.moveTo).toHaveBeenCalledTimes(1)
expect(ctx.lineTo).toHaveBeenCalledTimes(2)
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws a 3x3 grid for array-typed slots', () => {
const slot = createInputSlot({ type: SlotType.Array })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(9)
})
it('draws a simple rect and no label in low quality mode', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('clips hollow circle slots to a ring', () => {
const arc = vi.fn()
vi.stubGlobal(
'Path2D',
class {
arc = arc
}
)
try {
const slot = createInputSlot({ shape: RenderShape.HollowCircle })
slot.draw(ctx, { colorContext, highlight: true })
slot.draw(ctx, { colorContext })
expect(ctx.clip).toHaveBeenCalledTimes(2)
// Inner radius is larger while highlighted.
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
2.5,
0,
Math.PI * 2
)
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
1.5,
0,
Math.PI * 2
)
} finally {
vi.unstubAllGlobals()
}
})
it('draws one pie segment per type for multi-type slots', () => {
const slot = createInputSlot({ type: 'STRING,INT' })
slot.draw(ctx, { colorContext })
// Once for the base slot colour, then once per type in the pie.
expect(colorContext.getDisconnectedColor).toHaveBeenCalledTimes(3)
// One filled arc per type, plus the final outline arc.
expect(ctx.arc).toHaveBeenCalledTimes(3)
expect(ctx.stroke).toHaveBeenCalled()
})
it('hides the label for widget input slots', () => {
const slot = createInputSlot({ widget: { name: 'in' } })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('skips the label when there is no text to render', () => {
const slot = createInputSlot({ name: '' })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('draws input labels above the slot when directed up', () => {
const slot = createInputSlot({ dir: LinkDirection.UP })
slot.draw(ctx, { colorContext })
const [, x, y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentre = [
slot.boundingRect[0] + 10 - slot.node.pos[0],
slot.boundingRect[1] + 10 - slot.node.pos[1]
]
expect(x).toBe(slotCentre[0])
expect(y).toBeLessThan(slotCentre[1])
})
it('draws output labels to the left of the slot', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
const [, x] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreX = slot.boundingRect[0] + 10 - slot.node.pos[0]
expect(x).toBeLessThan(slotCentreX)
})
it('draws output labels above the slot when directed down', () => {
const slot = createOutputSlot({ dir: LinkDirection.DOWN })
slot.draw(ctx, { colorContext })
const [, , y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreY = slot.boundingRect[1] + 10 - slot.node.pos[1]
expect(y).toBeLessThan(slotCentreY)
})
it('strokes output slots in normal quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
expect(ctx.stroke).toHaveBeenCalled()
})
it('does not stroke output slots in low quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('rings the slot in red when it has errors', () => {
const slot = createInputSlot({ hasErrors: true })
slot.draw(ctx, { colorContext })
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
12,
0,
Math.PI * 2
)
expect(ctx.stroke).toHaveBeenCalled()
})
})
describe('highlightColor', () => {
const original = {
highlight: LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR,
selectedTitle: LiteGraph.NODE_SELECTED_TITLE_COLOR
}
afterEach(() => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = original.highlight
LiteGraph.NODE_SELECTED_TITLE_COLOR = original.selectedTitle
})
it('prefers the dedicated text highlight colour', () => {
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR
)
})
it('falls back to the selected title colour, then text colour', () => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_SELECTED_TITLE_COLOR
)
LiteGraph.NODE_SELECTED_TITLE_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(LiteGraph.NODE_TEXT_COLOR)
})
})
describe('renderingLabel', () => {
it.for<[string, Partial<NodeInputSlot>, string]>([
['label', { label: 'A Label', localized_name: 'Localized' }, 'A Label'],
['localized_name', { localized_name: 'Localized' }, 'Localized'],
['name', {}, 'in'],
['empty string', { name: '' }, '']
])('falls back through %s', ([, overrides, expected]) => {
expect(createInputSlot(overrides).renderingLabel).toBe(expected)
})
})
describe('drawCollapsed', () => {
it('draws a box for event slots', () => {
createInputSlot({ type: SlotType.Event }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fill).toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
createInputSlot({ shape: RenderShape.BOX }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws an input-facing arrow for arrow-shaped input slots', () => {
createInputSlot({ shape: RenderShape.ARROW }).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(8, expect.any(Number))
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws an output-facing arrow for arrow-shaped output slots', () => {
const node = createNode()
node._collapsed_width = 60
createOutputSlot({ shape: RenderShape.ARROW }, node).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(66, expect.any(Number))
})
it('draws a circle by default', () => {
createInputSlot().drawCollapsed(ctx)
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
})
})
describe('collapsedPos', () => {
it('places output slots at the collapsed node width', () => {
const node = createNode()
node._collapsed_width = 42
expect(createOutputSlot({}, node).collapsedPos[0]).toBe(42)
})
it('falls back to the default collapsed width', () => {
const slot = createOutputSlot()
expect(slot.collapsedPos[0]).toBe(LiteGraph.NODE_COLLAPSED_WIDTH)
})
it('places input slots at the node origin', () => {
expect(createInputSlot().collapsedPos[0]).toBe(0)
})
})
describe('isValidTarget', () => {
it('validates input slots against output slots', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
})
it('rejects connections between incompatible slot types', () => {
const input = createInputSlot()
const output = createOutputSlot({ type: 'INT' })
expect(input.isValidTarget(output)).toBe(false)
})
it('validates output slots against subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphOutput = subgraph.outputNode.slots[0]
expect(createOutputSlot().isValidTarget(subgraphOutput)).toBe(true)
expect(createOutputSlot({ type: 'INT' }).isValidTarget(subgraphOutput)) //
.toBe(false)
})
it('validates input slots against subgraph inputs', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphInput = subgraph.inputNode.slots[0]
expect(createInputSlot().isValidTarget(subgraphInput)).toBe(true)
})
it('rejects unknown slot shapes', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
expect(output.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
})
})
describe('isConnected', () => {
it('reports output connectivity from the links array', () => {
expect(createOutputSlot().isConnected).toBe(false)
expect(createOutputSlot({ links: [] }).isConnected).toBe(false)
expect(createOutputSlot({ links: [toLinkId(1)] }).isConnected).toBe(true)
})
})
})

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { toLinkId } from '@/types/linkId'
import { outputAsSerialisable } from './slotUtils'
@@ -34,18 +33,4 @@ describe('outputAsSerialisable', () => {
const serialised = outputAsSerialisable(output as OutputSlotParam)
expect(serialised.links).toBeNull()
})
it('serialises only the widget name for outputs with widgets', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number') as OutputSlotParam
output.widget = node.addWidget(
'number',
'my-widget',
0,
null
) as INumericWidget
const serialised = outputAsSerialisable(output)
expect(serialised.widget).toEqual({ name: 'my-widget' })
})
})

View File

@@ -8,10 +8,8 @@ import {
LGraphEventMode,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import {
createNestedSubgraphs,
@@ -716,373 +714,3 @@ describe('ExecutableNodeDTO Scale Testing', () => {
}
})
})
describe('ExecutableNodeDTO error and edge branches', () => {
it('throws NullGraphError when the node has no graph', () => {
const orphan = new LGraphNode('Orphan')
expect(() => new ExecutableNodeDTO(orphan, [], new Map())).toThrow(
NullGraphError
)
})
it('returns itself from getInnerNodes for regular nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Plain')
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(dto.getInnerNodes()).toEqual([dto])
})
it('throws InvalidLinkError for dangling input link ids', () => {
const graph = new LGraph()
const node = new LGraphNode('Dangling')
node.addInput('in', 'IMAGE')
graph.add(node)
node.inputs[0].link = toLinkId(999)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
})
function createBypassCycle() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, [], map, undefined)
const dtoB = new ExecutableNodeDTO(b, [], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
return { dtoA }
}
it('throws a RecursionError when input resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveInput(0)).toThrow('Circular reference detected')
})
it('throws a RecursionError when output resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'Circular reference detected'
)
})
it('includes the subgraph path in recursion errors', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, ['7'], map, undefined)
const dtoB = new ExecutableNodeDTO(b, ['7'], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
expect(() => dtoA.resolveInput(0)).toThrow('at path 7')
})
describe('subgraph boundary resolution', () => {
function createBoundarySetup(options: { connectOuter?: boolean } = {}) {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }],
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
inner.addOutput('out', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.outputs[0].connect(inner.outputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
// DTOs snapshot their input links, so wire the outer graph first.
let outer: LGraphNode | undefined
if (options.connectOuter) {
outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
}
const map = new Map()
if (outer) {
const outerDto = new ExecutableNodeDTO(outer, [], map)
map.set(outerDto.id, outerDto)
}
const subgraphNodeDto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(subgraphNodeDto.id, subgraphNodeDto)
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
return {
subgraph,
inner,
outer,
subgraphNode,
map,
subgraphNodeDto,
innerDto
}
}
it('resolves inner node inputs through to the outer graph', () => {
const { outer, innerDto } = createBoundarySetup({ connectOuter: true })
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_id).toBe(String(outer!.id))
expect(resolved?.origin_slot).toBe(0)
})
it('returns undefined for unconnected subgraph inputs without widgets', () => {
const { innerDto } = createBoundarySetup()
expect(innerDto.resolveInput(0)).toBeUndefined()
})
it('returns the promoted widget value for widget-backed subgraph inputs', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
subgraphNode.inputs[0].widgetId = widgetId(
subgraphNode.graph!.id,
subgraphNode.id,
'value'
)
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_slot).toBe(-1)
expect(resolved?.widgetInfo).toBeDefined()
expect(resolved?.origin_id).toBe(innerDto.id)
})
it('throws SlotIndexError when the subgraph node lacks the input slot', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
// Characterises corruption handling: the subgraph node lost its slots.
subgraphNode.inputs.length = 0
expect(() => innerDto.resolveInput(0)).toThrow('No input found for slot')
})
it('resolves subgraph node outputs through the inner node', () => {
const { inner, subgraphNode, subgraphNodeDto } = createBoundarySetup()
const resolved = subgraphNodeDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(`${subgraphNode.id}:${inner.id}`)
expect(resolved?.origin_slot).toBe(0)
})
it('throws SlotIndexError for missing subgraph output slots', () => {
const { subgraphNodeDto } = createBoundarySetup()
expect(() =>
subgraphNodeDto.resolveOutput(5, 'IMAGE', new Set())
).toThrow('No output found for flattened id')
})
it('returns undefined when the subgraph output has no internal link', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const map = new Map()
const dto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(dto.id, dto)
expect(dto.resolveOutput(0, 'IMAGE', new Set())).toBeUndefined()
})
})
describe('bypass slot matching', () => {
it('matches by slot index for wildcard target types', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out0', 'IMAGE')
node.addOutput('out1', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
// Both resolve through unconnected inputs, so the result is undefined,
// but neither is rejected as a failed type match.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, '*', new Set())).toBeUndefined()
expect(dto.resolveOutput(1, '*', new Set())).toBeUndefined()
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('prefers an exact type match over the opposite slot index', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('mask', 'MASK')
const node = new LGraphNode('Bypass')
node.addInput('image', 'IMAGE')
node.addInput('mask', 'MASK')
node.addOutput('mask', 'MASK')
node.mode = LGraphEventMode.BYPASS
graph.add(source)
graph.add(node)
source.connect(0, node, 1)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'MASK', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
})
it('warns and returns undefined when no input type matches', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, 'MASK', new Set())).toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
it('resolves virtual nodes through their input link', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
map.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
expect(resolved?.origin_slot).toBe(0)
})
})
describe('ExecutableNodeDTO missing DTO map entries', () => {
it('throws when the upstream node DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const target = new LGraphNode('Target')
target.addInput('in', 'IMAGE')
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
const map = new Map()
const dto = new ExecutableNodeDTO(target, [], map)
map.set(dto.id, dto)
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
})
it('throws when the containing subgraph node DTO is missing from the map', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const map = new Map()
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
expect(() => innerDto.resolveInput(0)).toThrow('No subgraph node DTO found')
})
it('throws when a virtual node input DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
// The virtual node resolves through its own input, whose DTO is missing.
const map = new Map()
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No input node DTO found'
)
})
})

View File

@@ -1,389 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type {
IContextMenuOptions,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function pointerEvent(
canvasX: number,
canvasY: number,
button = 0
): CanvasPointerEvent {
return fromPartial({ canvasX, canvasY, button })
}
function createArrangedInputNode() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const inputNode = subgraph.inputNode
inputNode.configure({ id: inputNode.id, bounding: [0, 0, 150, 100] })
inputNode.arrange()
return { subgraph, inputNode }
}
function slotCentre(slot: {
boundingRect: ArrayLike<number>
}): [number, number] {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2]
}
describe('SubgraphIONodeBase', () => {
beforeEach(() => {
resetSubgraphFixtureState()
LGraphCanvas._measureText = (text: string) => text.length * 8
})
afterEach(() => {
LGraphCanvas._measureText = undefined
vi.restoreAllMocks()
})
describe('pointer hover', () => {
it('tracks pointer enter, slot hover, and leave', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
const overSlot = inputNode.onPointerMove(pointerEvent(slotX, slotY))
expect(inputNode.isPointerOver).toBe(true)
expect(overSlot & CanvasItem.SubgraphIoNode).toBeTruthy()
expect(overSlot & CanvasItem.SubgraphIoSlot).toBeTruthy()
expect(inputNode.slots[0].isPointerOver).toBe(true)
// Move within the node but off the slot
const overNode = inputNode.onPointerMove(pointerEvent(1, 1))
expect(overNode).toBe(CanvasItem.SubgraphIoNode)
// Leave the node entirely
const outside = inputNode.onPointerMove(pointerEvent(500, 500))
expect(outside).toBe(CanvasItem.Nothing)
expect(inputNode.isPointerOver).toBe(false)
expect(inputNode.slots[0].isPointerOver).toBe(false)
// Moving outside while already outside stays a no-op
expect(inputNode.onPointerMove(pointerEvent(500, 500))).toBe(
CanvasItem.Nothing
)
})
it('reports whether a point is inside the node', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.containsPoint([1, 1])).toBe(true)
expect(inputNode.containsPoint([500, 500])).toBe(false)
})
})
describe('snapToGrid', () => {
it('does not snap pinned nodes', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pinned = true
expect(inputNode.snapToGrid(10)).toBe(false)
})
it('snaps unpinned nodes to the grid', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pos = [7, 13]
expect(inputNode.snapToGrid(10)).toBe(true)
expect([inputNode.pos[0], inputNode.pos[1]]).toEqual([10, 10])
})
})
describe('getSlotInPosition', () => {
it('returns the slot at the given canvas position', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
expect(inputNode.getSlotInPosition(slotX, slotY)).toBe(inputNode.slots[0])
})
it('returns undefined when no slot contains the position', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.getSlotInPosition(500, 500)).toBeUndefined()
})
})
describe('slot context menu', () => {
interface CapturedMenu {
options: (IContextMenuValue | null)[]
opts: IContextMenuOptions
}
let captured: CapturedMenu | undefined
const OriginalContextMenu = LiteGraph.ContextMenu
beforeEach(() => {
captured = undefined
LiteGraph.ContextMenu = fromPartial<typeof LiteGraph.ContextMenu>(
class {
constructor(
options: (IContextMenuValue | null)[],
opts: IContextMenuOptions
) {
captured = { options, opts }
}
}
)
})
afterEach(() => {
LiteGraph.ContextMenu = OriginalContextMenu
})
it('offers disconnect, rename, and remove for connected slots', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
slot.linkIds.push(toLinkId(1))
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeDefined()
expect(captured?.options.map((o) => o?.value)).toEqual([
'disconnect',
'rename',
undefined,
'remove'
])
// Disconnect action clears the slot's links.
void captured?.opts.callback?.(fromPartial({ value: 'disconnect' }))
expect(slot.linkIds).toHaveLength(0)
// Remove action deletes the slot from the subgraph.
void captured?.opts.callback?.(fromPartial({ value: 'remove' }))
expect(subgraph.inputs).toHaveLength(0)
})
it('renames the slot through the canvas prompt', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn(
(
_title: string,
_value: unknown,
callback: (value: string) => void
) => {
callback('renamed')
}
)
subgraph.list_of_graphcanvas = [
fromPartial<LGraphCanvas>({ prompt, setDirty: vi.fn() })
]
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
void captured?.opts.callback?.(fromPartial({ value: 'rename' }))
expect(prompt).toHaveBeenCalledWith(
'Slot name',
'value',
expect.any(Function),
expect.anything()
)
// Renaming an input updates its display label.
expect(subgraph.inputs[0].displayName).toBe('renamed')
})
it('does not show a menu for the empty slot', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
it('ignores right-clicks outside all slots', () => {
const { inputNode } = createArrangedInputNode()
inputNode.onPointerDown(
pointerEvent(500, 500, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
})
describe('left-click drag and double-click', () => {
it('wires up drag handlers when a slot is clicked', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphInput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
linkConnector
)
pointer.onDragStart?.(pointer)
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
subgraph,
inputNode,
slot
)
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('prompts to rename on double-click of a regular slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromPartial<CanvasPointerEvent>({}))
expect(prompt).toHaveBeenCalled()
})
it('does not prompt to rename on double-click of the empty slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromPartial<CanvasPointerEvent>({}))
expect(prompt).not.toHaveBeenCalled()
})
})
describe('arrange', () => {
it('sizes the node to fit its widest slot', () => {
LGraphCanvas._measureText = () => 300
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
// Slot width (300 + slot height) exceeds the minimum width of 100.
expect(inputNode.size[0]).toBeGreaterThan(300)
})
it('falls back to zero-width labels without a text measurer', () => {
LGraphCanvas._measureText = undefined
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
expect(inputNode.size[0]).toBeGreaterThan(0)
})
})
describe('serialisation', () => {
it('round-trips pinned state', () => {
const { inputNode } = createArrangedInputNode()
inputNode.configure({
id: inputNode.id,
bounding: [5, 6, 150, 100],
pinned: true
})
expect(inputNode.pinned).toBe(true)
expect(inputNode.asSerialisable().pinned).toBe(true)
inputNode.configure({ id: inputNode.id, bounding: [5, 6, 150, 100] })
expect(inputNode.pinned).toBe(false)
expect(inputNode.asSerialisable().pinned).toBeUndefined()
})
})
describe('draw', () => {
it('draws with hover-dependent stroke styling and restores context state', () => {
const { inputNode } = createArrangedInputNode()
const strokeStyles: unknown[] = []
const ctx = fromPartial<CanvasRenderingContext2D>({
getTransform: vi.fn(() => new DOMMatrix()),
setTransform: vi.fn(),
translate: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(() => {
strokeStyles.push(ctx.strokeStyle)
}),
fill: vi.fn(),
rect: vi.fn(),
fillText: vi.fn(),
lineWidth: 1,
strokeStyle: 'original',
fillStyle: 'original',
font: 'original',
textBaseline: 'alphabetic'
})
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
inputNode.draw(ctx, colorContext)
const [defaultStroke] = strokeStyles
inputNode.onPointerEnter()
inputNode.draw(ctx, colorContext)
const [, hoverStroke] = strokeStyles
expect(defaultStroke).not.toBe(hoverStroke)
expect(ctx.strokeStyle).toBe('original')
expect(ctx.fillStyle).toBe('original')
expect(ctx.font).toBe('original')
})
})
})

View File

@@ -1,657 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type {
INodeInputSlot,
INodeOutputSlot,
Point
} from '@/lib/litegraph/src/interfaces'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { SubgraphInput } from './SubgraphInput'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
afterEach(() => {
vi.restoreAllMocks()
})
function createIoSubgraph() {
return createTestSubgraph({
inputs: [{ name: 'in', type: 'STRING' }],
outputs: [{ name: 'out', type: 'STRING' }]
})
}
function addInnerNode(subgraph: Subgraph, type = 'STRING') {
const node = new LGraphNode('Inner')
node.addInput('in', type)
node.addOutput('out', type)
subgraph.add(node)
return node
}
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
describe('SubgraphOutput.connect', () => {
it('rejects type-incompatible connections', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph, 'INT')
const link = subgraph.outputs[0].connect(node.outputs[0], node)
expect(link).toBeUndefined()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('throws when the slot does not belong to the given node', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const otherNode = addInnerNode(subgraph)
expect(() =>
subgraph.outputs[0].connect(otherNode.outputs[0], node)
).toThrow('Slot is not an output of the given node')
})
it('lets nodes veto the connection via onConnectOutput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectOutput = () => false
expect(subgraph.outputs[0].connect(node.outputs[0], node)).toBeUndefined()
})
it('replaces an existing connection', () => {
const subgraph = createIoSubgraph()
const first = addInnerNode(subgraph)
const second = addInnerNode(subgraph)
subgraph.outputs[0].connect(first.outputs[0], first)
const replacement = subgraph.outputs[0].connect(second.outputs[0], second)
expect(replacement).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([replacement?.id])
expect(first.outputs[0].links).toEqual([])
expect(second.outputs[0].links).toEqual([replacement?.id])
})
})
describe('SubgraphOutput.disconnect', () => {
it('skips dangling link ids', () => {
const subgraph = createIoSubgraph()
subgraph.outputs[0].linkIds.push(toLinkId(999))
subgraph.outputs[0].disconnect()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('removes link references from the origin output', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
subgraph.outputs[0].connect(node.outputs[0], node)
subgraph.outputs[0].disconnect()
expect(node.outputs[0].links).toEqual([])
expect(onConnectionsChange).toHaveBeenLastCalledWith(
expect.anything(),
0,
false,
expect.anything(),
subgraph.outputs[0]
)
})
})
describe('SubgraphOutput.isValidTarget', () => {
it('accepts a compatible subgraph input as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.outputs[0].isValidTarget(subgraph.inputs[0])).toBe(true)
})
})
describe('SubgraphInput.connect', () => {
it('lets nodes veto the connection via onConnectInput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectInput = () => false
expect(subgraph.inputs[0].connect(node.inputs[0], node)).toBeUndefined()
})
it('disconnects an existing link on the target input first', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'STRING' }
]
})
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
const replacement = subgraph.inputs[1].connect(node.inputs[0], node)
expect(replacement).toBeDefined()
expect(node.inputs[0].link).toBe(replacement?.id)
expect(subgraph.inputs[0].linkIds).toHaveLength(0)
})
it('rejects widget inputs that do not match the bound widget', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const textNode = new LGraphNode('Text')
const textInput = textNode.addInput('value', 'STRING')
textNode.addWidget('text', 'value', '', () => {})
textInput.widget = { name: 'value' }
subgraph.add(textNode)
const numberNode = new LGraphNode('Number')
const numberInput = numberNode.addInput('value', 'STRING')
numberNode.addWidget('number', 'value', 0, () => {})
numberInput.widget = { name: 'value' }
subgraph.add(numberNode)
const first = subgraph.inputs[0].connect(textInput, textNode)
const second = subgraph.inputs[0].connect(numberInput, numberNode)
expect(first).toBeDefined()
expect(second).toBeUndefined()
expect(warn).toHaveBeenCalledWith(
'Target input has invalid widget.',
numberInput,
numberNode
)
})
})
describe('SubgraphInput.matchesWidget', () => {
it('accepts any widget when none is bound', () => {
const subgraph = createIoSubgraph()
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: {} })
)
).toBe(true)
})
it('compares type and numeric constraint options', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('number', 'value', 0, () => {}, { min: 0, max: 10 })
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
const boundOptions = { min: 0, max: 10 }
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions } })
)
).toBe(true)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions, min: 5 } })
)
).toBe(false)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: { ...boundOptions } })
)
).toBe(false)
})
})
describe('SubgraphInput.getConnectedWidgets', () => {
it('reports an error for dangling link ids', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(error).toHaveBeenCalledWith('Link not found', 999)
})
it('skips inputs without widgets', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.addWidget('text', 'unrelated', '', () => {})
subgraph.inputs[0].connect(node.inputs[0], node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
})
it('warns when the referenced widget cannot be found', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
input.widget = { name: 'missing' }
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Widget not found', { name: 'missing' })
input.widget = { name: '' }
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Invalid widget name', { name: '' })
})
it('returns widgets for connected widget inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
const widget = node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
})
})
describe('SubgraphInput.isValidTarget', () => {
it('accepts a compatible subgraph output as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputs[0].isValidTarget(subgraph.outputs[0])).toBe(true)
})
})
describe('SubgraphSlot base behaviour', () => {
it('ignores malformed positions', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.pos = [3, 4]
slot.pos = fromPartial<Point>([5])
expect([slot.pos[0], slot.pos[1]]).toEqual([3, 4])
})
it('generates an id when the serialised slot has none', () => {
const subgraph = createIoSubgraph()
const slot = new SubgraphInput(
fromPartial<SubgraphIO>({ name: 'anon', type: 'STRING', linkIds: [] }),
subgraph.inputNode
)
expect(slot.id).toEqual(expect.any(String))
expect(slot.id.length).toBeGreaterThan(0)
})
it('skips dangling link ids in getLinks', () => {
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getLinks()).toEqual([])
})
it('decrements link slot indices and warns on dangling ids', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.outputs[0].connect(node.outputs[0], node)
const slot = subgraph.outputs[0]
slot.decrementSlots('outputs')
expect(link?.target_slot).toBe(-1)
slot.linkIds.push(toLinkId(999))
slot.decrementSlots('outputs')
expect(warn).toHaveBeenCalledWith('decrementSlots: link ID not found', 999)
})
describe('draw', () => {
it('draws a simple square in low quality', () => {
const subgraph = createIoSubgraph()
const ctx = createMockCanvasRenderingContext2D()
subgraph.inputs[0].draw({ ctx, colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('strokes hollow circles with a hover-dependent radius', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.shape = RenderShape.HollowCircle
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
slot.isPointerOver = true
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenNthCalledWith(1, 0, 0, 3, 0, Math.PI * 2)
expect(ctx.arc).toHaveBeenNthCalledWith(2, 0, 0, 4, 0, Math.PI * 2)
expect(ctx.stroke).toHaveBeenCalledTimes(2)
})
it('enlarges the highlighted filled circle', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.isPointerOver = true
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenCalledWith(0, 0, 5, 0, Math.PI * 2)
})
it('dims slots that are invalid targets for the dragged link', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
const alphas: number[] = []
const ctx = createMockCanvasRenderingContext2D({
fill: vi.fn(() => {
alphas.push(ctx.globalAlpha)
})
})
// Dragging from an incompatible output slot.
const incompatible = fromPartial<INodeOutputSlot>({
name: 'other',
type: 'INT',
links: null,
boundingRect: [0, 0, 0, 0]
})
slot.draw({ ctx, colorContext, fromSlot: incompatible })
expect(alphas[0]).toBeCloseTo(0.4)
})
it('falls back to the default label colour when unset', () => {
const originalColor = LiteGraph.NODE_TEXT_COLOR
try {
LiteGraph.NODE_TEXT_COLOR = ''
const subgraph = createIoSubgraph()
const fillStyles: unknown[] = []
const ctx = createMockCanvasRenderingContext2D({
fillText: vi.fn(() => {
fillStyles.push(ctx.fillStyle)
})
})
subgraph.inputs[0].draw({ ctx, colorContext })
expect(fillStyles).toEqual(['#AAA'])
} finally {
LiteGraph.NODE_TEXT_COLOR = originalColor
}
})
})
})
describe('SubgraphOutputNode interaction', () => {
function createArrangedOutputNode() {
const subgraph = createIoSubgraph()
const outputNode = subgraph.outputNode
outputNode.configure({ id: outputNode.id, bounding: [0, 0, 150, 100] })
outputNode.arrange()
return { subgraph, outputNode }
}
function slotCentre(slot: { boundingRect: ArrayLike<number> }) {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2] as const
}
it('wires drag handlers on left-click over a slot', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const slot = outputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphOutput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [x, y] = slotCentre(slot)
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 0 }),
pointer,
linkConnector
)
pointer.onDragStart?.(pointer)
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
subgraph,
outputNode,
slot
)
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('shows the slot context menu on right-click', () => {
const { outputNode } = createArrangedOutputNode()
const OriginalContextMenu = LiteGraph.ContextMenu
let constructed = false
LiteGraph.ContextMenu = fromPartial<typeof LiteGraph.ContextMenu>(
class {
constructor() {
constructed = true
}
}
)
try {
const [x, y] = slotCentre(outputNode.slots[0])
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 2 }),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(true)
constructed = false
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({
canvasX: 500,
canvasY: 500,
button: 2
}),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(false)
} finally {
LiteGraph.ContextMenu = OriginalContextMenu
}
})
it('connects by type through connectByTypeOutput', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const node = addInnerNode(subgraph)
const link = outputNode.connectByTypeOutput(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined when no output of the requested type exists', () => {
const { outputNode } = createArrangedOutputNode()
const node = new LGraphNode('No Outputs')
expect(outputNode.connectByTypeOutput(0, node, 'STRING')).toBeUndefined()
})
})
describe('SubgraphInputNode connections', () => {
it('throws for invalid slot indices in connectSlots', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
expect(() =>
subgraph.inputNode.connectSlots(
fromPartial<SubgraphInput>({}),
node,
node.inputs[0],
undefined
)
).toThrow('Invalid slot indices.')
})
it('creates links via connectSlots, preferring the input type', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
expect(String(link.origin_id)).toBe(String(subgraph.inputNode.id))
})
it('falls back to the subgraph slot type for untyped inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Untyped')
node.addInput('in', '')
subgraph.add(node)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
})
it('connects an existing slot directly via connectByType', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectByType(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined from connectByType when no input matches', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('No Inputs')
expect(subgraph.inputNode.connectByType(0, node, 'STRING')).toBeUndefined()
})
it('finds output slots by name and type', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputNode.findOutputSlot('in')).toBe(subgraph.inputs[0])
expect(subgraph.inputNode.findOutputSlot('nope')).toBeUndefined()
expect(subgraph.inputNode.findOutputByType('STRING')).toBe(
subgraph.inputs[0]
)
})
describe('_disconnectNodeInput corruption handling', () => {
it('clears the input without a link', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], undefined)
expect(node.inputs[0].link).toBeNull()
})
it('warns when the link references a missing subgraph input slot', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: link points at a nonexistent slot.
link.origin_slot = 99
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: subgraphInput not found',
subgraph.inputNode,
99
)
})
it('warns when the slot does not list the link id', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: the slot lost its link id.
subgraph.inputs[0].linkIds.length = 0
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
link.id
)
})
it('skips connection callbacks for foreign inputs', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
const foreignInput = fromPartial<INodeInputSlot>({
name: 'foreign',
type: 'STRING',
link: null,
boundingRect: [0, 0, 0, 0]
})
subgraph.inputNode._disconnectNodeInput(node, foreignInput, link)
expect(onConnectionsChange).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
false,
expect.anything(),
expect.anything()
)
})
})
})

View File

@@ -1,69 +1,20 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot,
Positionable
} from '@/lib/litegraph/src/interfaces'
import {
LGraph,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph,
findUsedSubgraphIds,
getDirectSubgraphIds
} from '@/lib/litegraph/src/litegraph'
import type { UUID } from '@/lib/litegraph/src/litegraph'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import { toRerouteId } from '@/types/rerouteId'
import { toLinkId } from '@/types/linkId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import type { SubgraphInput } from './SubgraphInput'
import {
getBoundaryLinks,
groupResolvedByOutput,
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
reorderSubgraphInputs,
splitPositionables
} from './subgraphUtils'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
/** Creates a graph with three chained nodes: a -> b -> c. */
function createChainedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', 'number')
const b = new LGraphNode('B')
b.addInput('in', 'number')
b.addOutput('out', 'number')
const c = new LGraphNode('C')
c.addInput('in', 'number')
graph.add(a)
graph.add(b)
graph.add(c)
const linkAb = a.connect(0, b, 0)
const linkBc = b.connect(0, c, 0)
if (!linkAb || !linkBc) throw new Error('Failed to connect test nodes')
return { graph, a, b, c, linkAb, linkBc }
}
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -194,389 +145,4 @@ describe('subgraphUtils', () => {
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
})
})
describe('splitPositionables', () => {
it('splits items into typed buckets', () => {
const { graph, a, linkAb } = createChainedGraph()
const group = new LGraphGroup('Test Group')
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const subgraph = createTestSubgraph()
const unknown = createMockPositionable()
const result = splitPositionables([
a,
group,
reroute,
subgraph.inputNode,
subgraph.outputNode,
unknown
])
expect(result.nodes).toEqual(new Set([a]))
expect(result.groups).toEqual(new Set([group]))
expect(result.reroutes).toEqual(new Set([reroute]))
expect(result.subgraphInputNodes).toEqual(new Set([subgraph.inputNode]))
expect(result.subgraphOutputNodes).toEqual(new Set([subgraph.outputNode]))
expect(result.unknown).toEqual(new Set([unknown]))
})
})
describe('getBoundaryLinks', () => {
it('classifies links crossing into and out of the item set', () => {
const { graph, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(result.boundaryInputLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
expect(result.internalLinks).toEqual([])
})
it('classifies links between selected nodes as internal', () => {
const { graph, a, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([a, b]))
expect(result.internalLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryInputLinks).toEqual([])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
})
it('treats subgraph IO links as boundary links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'number' }],
outputs: [{ name: 'out', type: 'number' }]
})
const node = new LGraphNode('Inner')
node.addInput('in', 'number')
node.addOutput('out', 'number')
subgraph.add(node)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.outputs[0].connect(node.outputs[0], node)
const result = getBoundaryLinks(subgraph, new Set<Positionable>([node]))
expect(result.boundaryInputLinks).toHaveLength(1)
expect(result.boundaryOutputLinks).toHaveLength(1)
})
it('marks reroute links as boundary when an endpoint is outside the set', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const boundary = getBoundaryLinks(graph, new Set<Positionable>([reroute]))
expect(boundary.boundaryLinks.map((l) => l.id)).toEqual([linkAb.id])
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([a, b, reroute])
)
expect(contained.boundaryLinks).toEqual([])
})
it('collects floating links whose reroutes cross the boundary', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
// Removing the output side turns the link into a floating link.
graph.remove(a)
expect(graph.floatingLinks.size).toBe(1)
// The floating link's reroute is outside the item set.
const crossing = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(crossing.boundaryFloatingLinks).toHaveLength(1)
// With the reroute inside the set, the floating link does not cross.
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([b, reroute])
)
expect(contained.boundaryFloatingLinks).toEqual([])
})
})
describe('multiClone', () => {
class CloneTestNode extends LGraphNode {
constructor() {
super('CloneTest')
this.addInput('in', 'number')
}
}
beforeEach(() => {
LiteGraph.registerNodeType('test/CloneTest', CloneTestNode)
})
afterEach(() => {
LiteGraph.unregisterNodeType('test/CloneTest')
vi.restoreAllMocks()
})
it('clones registered nodes preserving ids', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/CloneTest')
if (!node) throw new Error('Failed to create node')
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(String(cloned[0].id)).toBe(String(node.id))
expect(cloned[0].type).toBe('test/CloneTest')
})
it('falls back to serialised data for unregistered node types', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const graph = new LGraph()
const node = new LGraphNode('Mystery')
node.type = 'test/UnregisteredType'
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(cloned[0].type).toBe('test/UnregisteredType')
expect(warn).toHaveBeenCalled()
})
})
describe('groupResolvedByOutput', () => {
it('groups connections sharing an output and isolates unresolvable ones', () => {
const output = fromPartial<ResolvedConnection['output']>({
name: 'shared'
})
const first = fromPartial<ResolvedConnection>({ output })
const second = fromPartial<ResolvedConnection>({ output })
const bySubgraphInput = fromPartial<ResolvedConnection>({
subgraphInput: fromPartial<SubgraphInput>({ name: 'sub' })
})
const unresolvable = fromPartial<ResolvedConnection>({})
const grouped = groupResolvedByOutput([
first,
second,
bySubgraphInput,
unresolvable
])
expect(grouped.size).toBe(3)
expect(grouped.get(output!)).toEqual([first, second])
})
})
describe('mapSubgraphInputsAndLinks', () => {
function createResolvedInput(
linkId: number,
inputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(toLinkId(linkId), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({
name: 'in',
type: 'number',
...inputOverrides
}),
output: fromPartial<INodeOutputSlot>({ name: 'out', type: 'number' })
})
return { resolved, link }
}
it('creates one subgraph input per distinct output', () => {
const { resolved } = createResolvedInput(1)
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks([resolved], links, new Map())
expect(inputs).toHaveLength(1)
expect(inputs[0].name).toBe('in')
expect(inputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].origin_slot).toBe(0)
})
it('deduplicates names and localised names across inputs', () => {
const first = createResolvedInput(1, { localized_name: 'In' })
const second = createResolvedInput(2, { localized_name: 'In' })
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks(
[first.resolved, second.resolved],
links,
new Map()
)
expect(inputs.map((i) => i.name)).toEqual(['in', 'in_1'])
expect(inputs.map((i) => i.localized_name)).toEqual(['In', 'In_1'])
})
it('skips connections without a resolved input', () => {
const link = new LLink(toLinkId(1), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
output: fromPartial<INodeOutputSlot>({ name: 'out', type: 'number' })
})
const inputs = mapSubgraphInputsAndLinks([resolved], [], new Map())
expect(inputs).toEqual([])
})
it('rewires reroute parents to the last reroute outside the subgraph', () => {
const { resolved, link } = createResolvedInput(1)
link.parentId = toRerouteId(10)
const insideReroute = fromPartial<Reroute>({ parentId: toRerouteId(99) })
const reroutes = new Map<ReturnType<typeof toRerouteId>, Reroute>([
[toRerouteId(10), insideReroute]
])
mapSubgraphInputsAndLinks([resolved], [], reroutes)
// The chain terminated at reroute 99, which is not in the map.
expect(link.parentId).toBe(toRerouteId(99))
expect(insideReroute.parentId).toBeUndefined()
})
})
describe('mapSubgraphOutputsAndLinks', () => {
function createResolvedOutput(
linkId: number,
outputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(toLinkId(linkId), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' }),
output: fromPartial<INodeOutputSlot>({
name: 'out',
type: 'number',
...outputOverrides
})
})
return { resolved, link }
}
it('creates one subgraph output per distinct output slot', () => {
const { resolved } = createResolvedOutput(1)
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks([resolved], links, new Map())
expect(outputs).toHaveLength(1)
expect(outputs[0].name).toBe('out')
expect(outputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].target_slot).toBe(0)
})
it('deduplicates localised names across outputs', () => {
const first = createResolvedOutput(1, { localized_name: 'Out' })
const second = createResolvedOutput(2, { localized_name: 'Out' })
const outputs = mapSubgraphOutputsAndLinks(
[first.resolved, second.resolved],
[],
new Map()
)
expect(outputs.map((o) => o.name)).toEqual(['out', 'out_1'])
expect(outputs.map((o) => o.localized_name)).toEqual(['Out', 'Out_1'])
})
it('skips connections without a resolved output', () => {
const link = new LLink(toLinkId(1), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' })
})
const outputs = mapSubgraphOutputsAndLinks([resolved], [], new Map())
expect(outputs).toEqual([])
})
})
describe('reorderSubgraphInputs', () => {
it('returns silently when the node has no subgraph', () => {
const subgraphNode = createTestSubgraphNode(createTestSubgraph())
Object.defineProperty(subgraphNode, 'subgraph', {
value: undefined,
configurable: true
})
expect(() => reorderSubgraphInputs(subgraphNode, [])).not.toThrow()
})
it('rejects indices that are not a permutation', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const originalOrder = subgraph.inputs.map((i) => i.id)
reorderSubgraphInputs(subgraphNode, [0]) // wrong length
reorderSubgraphInputs(subgraphNode, [0, 0]) // duplicate
reorderSubgraphInputs(subgraphNode, [0, 2]) // out of range
expect(error).toHaveBeenCalledTimes(3)
expect(subgraph.inputs.map((i) => i.id)).toEqual(originalOrder)
vi.restoreAllMocks()
})
it('does not dispatch an event for an identity permutation', () => {
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [0, 1])
expect(listener).not.toHaveBeenCalled()
})
it('reorders slots and updates link slot indices', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'number' },
{ name: 'beta', type: 'number' }
]
})
const inner = new LGraphNode('Inner')
inner.addInput('a', 'number')
inner.addInput('b', 'number')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.inputs[1].connect(inner.inputs[1], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'number')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [1, 0])
expect(subgraph.inputs.map((i) => i.name)).toEqual(['beta', 'alpha'])
// Inner links follow their reordered slots.
const innerLinkSlots = subgraph.inputs.map((input) =>
input.linkIds.map((id) => subgraph.getLink(id)?.origin_slot)
)
expect(innerLinkSlots).toEqual([[0], [1]])
// The outer link now targets the moved slot.
const outerLinkId = subgraphNode.inputs.find(
(input) => input.link != null
)?.link
expect(outerLinkId).not.toBeNull()
const outerLink = subgraph.rootGraph.getLink(outerLinkId!)
expect(outerLink?.target_slot).toBe(1)
expect(listener).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,6 +1,5 @@
import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import type { SubgraphNode } from './SubgraphNode'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
@@ -29,6 +28,7 @@ import type {
import type { GraphOrSubgraph } from './Subgraph'
import type { SubgraphInput } from './SubgraphInput'
import { SubgraphInputNode } from './SubgraphInputNode'
import type { SubgraphNode } from './SubgraphNode'
import type { SubgraphOutput } from './SubgraphOutput'
import { SubgraphOutputNode } from './SubgraphOutputNode'

View File

@@ -1,135 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
function createNode(x: number, y: number, width: number, height: number) {
const node = new LGraphNode('Test Node')
node.pos = [x, y]
node.size = [width, height]
return node
}
describe('getBoundaryNodes', () => {
it('returns null when no nodes are supplied', () => {
expect(getBoundaryNodes([])).toBeNull()
expect(getBoundaryNodes(undefined)).toBeNull()
})
it('returns null when all nodes are falsy', () => {
expect(getBoundaryNodes([undefined, null])).toBeNull()
})
it('returns the same node for all edges with a single node', () => {
const node = createNode(10, 20, 100, 50)
expect(getBoundaryNodes([node])).toEqual({
top: node,
right: node,
bottom: node,
left: node
})
})
it('finds the farthest node in each direction', () => {
const topLeft = createNode(0, 0, 10, 10)
const bottomRight = createNode(200, 200, 50, 50)
const middle = createNode(100, 100, 10, 10)
const boundary = getBoundaryNodes([middle, topLeft, bottomRight])
expect(boundary).not.toBeNull()
expect(boundary?.top).toBe(topLeft)
expect(boundary?.left).toBe(topLeft)
expect(boundary?.right).toBe(bottomRight)
expect(boundary?.bottom).toBe(bottomRight)
})
it('skips falsy entries while finding boundaries', () => {
const node = createNode(5, 5, 10, 10)
const other = createNode(50, 50, 10, 10)
const boundary = getBoundaryNodes([node, undefined, other])
expect(boundary?.left).toBe(node)
expect(boundary?.right).toBe(other)
})
})
describe('distributeNodes', () => {
it('returns an empty array when fewer than two nodes are supplied', () => {
expect(distributeNodes([])).toEqual([])
expect(distributeNodes([createNode(0, 0, 10, 10)])).toEqual([])
expect(distributeNodes(undefined)).toEqual([])
})
it('distributes nodes evenly along the horizontal plane', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(100, 0, 20, 10)
const middle = createNode(30, 0, 10, 10)
const positions = distributeNodes([first, last, middle], true)
expect(positions.map(({ node }) => node)).toEqual([first, middle, last])
// Total span 0..120, widths 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[0]).toBe(0)
expect(middle.pos[0]).toBe(50)
expect(last.pos[0]).toBe(100)
expect(positions.map(({ newPos }) => newPos.x)).toEqual([0, 50, 100])
})
it('distributes nodes evenly along the vertical plane by default', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(0, 100, 10, 20)
const middle = createNode(0, 30, 10, 10)
const positions = distributeNodes([first, last, middle])
// Total span 0..120, heights 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[1]).toBe(0)
expect(middle.pos[1]).toBe(50)
expect(last.pos[1]).toBe(100)
expect(positions.map(({ newPos }) => newPos.y)).toEqual([0, 50, 100])
})
})
describe('alignNodes', () => {
it('returns an empty array when nodes are not supplied', () => {
expect(alignNodes(undefined, 'left')).toEqual([])
})
it('returns an empty array when boundary nodes cannot be determined', () => {
expect(alignNodes([], 'left')).toEqual([])
})
it.for<[Direction, [number, number], [number, number]]>([
// Anchor is at [100, 100] with size [50, 50]; node is at [0, 0] size [10, 10].
['left', [100, 0], [100, 100]],
['right', [140, 0], [100, 100]],
['top', [0, 100], [100, 100]],
['bottom', [0, 140], [100, 100]]
])(
'aligns nodes to the %s edge of the anchor node',
([direction, expectedNodePos, expectedAnchorPos]) => {
const node = createNode(0, 0, 10, 10)
const anchor = createNode(100, 100, 50, 50)
const positions = alignNodes([node, anchor], direction, anchor)
expect(positions).toHaveLength(2)
expect([node.pos[0], node.pos[1]]).toEqual(expectedNodePos)
expect([anchor.pos[0], anchor.pos[1]]).toEqual(expectedAnchorPos)
}
)
it('uses boundary nodes when no anchor is supplied', () => {
const left = createNode(0, 0, 10, 10)
const right = createNode(100, 50, 20, 10)
alignNodes([left, right], 'left')
expect(left.pos[0]).toBe(0)
expect(right.pos[0]).toBe(0)
})
})

View File

@@ -7,11 +7,8 @@ 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: Array<LGraphNode | null | undefined> | undefined
): IBoundaryNodes | null {
if (!nodes) return null
const valid = nodes.find((x) => x)
export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
const valid = nodes?.find((x) => x)
if (!valid) return null
let top = valid
@@ -44,11 +41,11 @@ export function getBoundaryNodes(
* @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane.
*/
export function distributeNodes(
nodes: LGraphNode[] | undefined,
nodes: LGraphNode[],
horizontal?: boolean
): NewNodePosition[] {
if (!nodes || nodes.length <= 1) return []
const nodeCount = nodes.length
const nodeCount = nodes?.length
if (!(nodeCount > 1)) return []
const index = horizontal ? 0 : 1
@@ -90,7 +87,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[] | undefined,
nodes: LGraphNode[],
direction: Direction,
align_to?: LGraphNode
): NewNodePosition[] {

View File

@@ -1,150 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import {
findFirstNode,
findFreeSlotOfType,
getAllNestedItems
} from './collections'
describe('getAllNestedItems', () => {
it('returns an empty set when items are not supplied', () => {
expect(getAllNestedItems(undefined).size).toBe(0)
})
it('excludes pinned items', () => {
const pinned = createMockPositionable({ pinned: true })
const unpinned = createMockPositionable()
const result = getAllNestedItems(new Set([pinned, unpinned]))
expect(result.has(pinned)).toBe(false)
expect(result.has(unpinned)).toBe(true)
})
it('recurses into children and deduplicates shared children', () => {
const shared = createMockPositionable()
const parentA: Positionable = createMockPositionable({
children: new Set([shared])
})
const parentB: Positionable = createMockPositionable({
children: new Set([shared])
})
const result = getAllNestedItems(new Set([parentA, parentB]))
expect(result).toEqual(new Set([parentA, parentB, shared]))
})
it('does not recurse into pinned children', () => {
const pinnedChild = createMockPositionable({ pinned: true })
const parent: Positionable = createMockPositionable({
children: new Set([pinnedChild])
})
const result = getAllNestedItems(new Set([parent]))
expect(result).toEqual(new Set([parent]))
})
})
describe('findFirstNode', () => {
it('returns the first LGraphNode in the collection', () => {
const notANode = createMockPositionable()
const node = new LGraphNode('Test Node')
const otherNode = new LGraphNode('Other Node')
expect(findFirstNode([notANode, node, otherNode])).toBe(node)
})
it('returns undefined when the collection has no nodes', () => {
expect(findFirstNode([createMockPositionable()])).toBeUndefined()
expect(findFirstNode([])).toBeUndefined()
})
})
describe('findFreeSlotOfType', () => {
interface TestSlot {
type: string
free: boolean
}
const hasNoLinks = (slot: TestSlot) => slot.free
it('returns undefined when no slots are supplied', () => {
expect(findFreeSlotOfType([], 'A', hasNoLinks)).toBeUndefined()
expect(findFreeSlotOfType(undefined, 'A', hasNoLinks)).toBeUndefined()
})
it('returns the first free slot with an exact type match', () => {
const slots: TestSlot[] = [
{ type: 'a', free: false },
{ type: 'a', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied slot with a matching type', () => {
const slots: TestSlot[] = [{ type: 'a', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('falls back to a free wildcard slot when no types match', () => {
const slots: TestSlot[] = [
{ type: 'b', free: true },
{ type: '*', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied wildcard slot as a last resort', () => {
const slots: TestSlot[] = [{ type: '*', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('matches wildcard search types against occupied concrete slots', () => {
const slots: TestSlot[] = [{ type: 'b', free: false }]
expect(findFreeSlotOfType(slots, '*', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('returns undefined when nothing matches', () => {
const slots: TestSlot[] = [{ type: 'b', free: true }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toBeUndefined()
})
it('matches any comma-delimited type in the search list', () => {
const slots: TestSlot[] = [
{ type: 'c', free: true },
{ type: 'b,c', free: true }
]
expect(findFreeSlotOfType(slots, 'A,B', 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> | undefined
items: ReadonlySet<Positionable>
): Set<Positionable> {
const allItems = new Set<Positionable>()
if (items) {
@@ -61,7 +61,7 @@ type FreeSlotResult<T extends { type: ISlotType }> =
* @returns The index and slot if found, otherwise `undefined`.
*/
export function findFreeSlotOfType<T extends { type: ISlotType }>(
slots: T[] | undefined,
slots: T[],
type: ISlotType,
hasNoLinks: (slot: T) => boolean
) {

View File

@@ -1,70 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty, warnDeprecated } from './feedback'
let messageId = 0
/** Unique message per test; warnDeprecated deduplicates per session. */
function uniqueMessage(): string {
messageId += 1
return `test deprecation message ${messageId}`
}
describe('warnDeprecated', () => {
const originalAlwaysRepeat = LiteGraph.alwaysRepeatWarnings
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = originalAlwaysRepeat
LiteGraph.onDeprecationWarning.length = 0
})
it('notifies callbacks once per unique message', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const source = {}
warnDeprecated(message, source)
warnDeprecated(message, source)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, source)
})
it('repeats warnings when alwaysRepeatWarnings is enabled', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
LiteGraph.alwaysRepeatWarnings = true
const message = uniqueMessage()
warnDeprecated(message)
warnDeprecated(message)
expect(callback).toHaveBeenCalledTimes(2)
})
})
describe('defineDeprecatedProperty', () => {
afterEach(() => {
LiteGraph.onDeprecationWarning.length = 0
})
it('proxies reads and writes to the current property with a warning', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const target: { current: number } & Record<string, unknown> = { current: 1 }
defineDeprecatedProperty(target, 'legacy', 'current', message)
expect(target.legacy).toBe(1)
target.legacy = 2
expect(target.current).toBe(2)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, undefined)
})
})

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { resolveConnectingLinkColor } from './linkColors'
describe('resolveConnectingLinkColor', () => {
it('uses the event link colour for event slots', () => {
expect(resolveConnectingLinkColor(LiteGraph.EVENT)).toBe(
LiteGraph.EVENT_LINK_COLOR
)
})
it('uses the connecting link colour for other slot types', () => {
expect(resolveConnectingLinkColor('STRING')).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
expect(resolveConnectingLinkColor(undefined)).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
})
})

View File

@@ -89,13 +89,6 @@ describe('evaluateMathExpression', () => {
}
)
test.for(['-', '2*', '3/-'])(
'dangling operator returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
}
)
test('division by zero returns Infinity', () => {
expect(evaluateMathExpression('1/0')).toBe(Infinity)
})

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { commonType, isColorable, isNodeBindable } from './type'
describe('isColorable', () => {
it.for<[string, unknown]>([
['a primitive', 42],
['null', null],
['an object without setColorOption', { getColorOption: () => null }],
['an object without getColorOption', { setColorOption: () => {} }]
])('returns false for %s', ([, value]) => {
expect(isColorable(value)).toBe(false)
})
it('returns true for an object with both color option methods', () => {
const colorable = {
setColorOption: () => {},
getColorOption: () => null
}
expect(isColorable(colorable)).toBe(true)
})
})
describe('isNodeBindable', () => {
it.for<[string, unknown]>([
['a primitive', 'widget'],
['null', null],
['an object without setNodeId', {}],
['an object with a non-function setNodeId', { setNodeId: true }]
])('returns false for %s', ([, value]) => {
expect(isNodeBindable(value)).toBe(false)
})
it('returns true for an object with a setNodeId function', () => {
expect(isNodeBindable({ setNodeId: () => {} })).toBe(true)
})
})
describe('commonType', () => {
it('returns undefined when any type is not a string', () => {
expect(commonType('STRING', -1)).toBeUndefined()
})
it('returns the wildcard when all types are wildcards', () => {
expect(commonType('*', '*')).toBe('*')
})
it('ignores wildcards when other types are present', () => {
expect(commonType('*', 'STRING')).toBe('STRING')
})
it('returns the intersection of comma-delimited type lists', () => {
expect(commonType('A,B', 'B,C')).toBe('B')
})
it('returns undefined when types do not intersect', () => {
expect(commonType('A', 'B')).toBeUndefined()
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ 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 }))
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent(props?: { reason?: PaymentIntentSource }) {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -46,18 +43,4 @@ 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="!isCreditsBlockedVariant"
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,7 +65,10 @@
</p>
<p
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -85,7 +88,7 @@
@click="$emit('upgrade')"
>
{{
isCreditsBlockedVariant
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -100,12 +103,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
const { reason } = defineProps<{
reason?: PaymentIntentSource
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
@@ -126,10 +129,4 @@ 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,7 +261,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -342,7 +341,6 @@ 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 () => {
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -141,6 +150,7 @@ 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 () => {
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,6 +7,7 @@ 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'
@@ -61,6 +62,7 @@ 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 { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogReason } 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,7 +237,14 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
void showSubscriptionRequiredDialog(options)
}
@@ -270,7 +277,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog({ reason: 'subscription_required' })
showSubscriptionDialog()
}
}

View File

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

View File

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

View File

@@ -5,10 +5,8 @@ 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 }))
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -206,51 +198,6 @@ 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', () => {
@@ -288,20 +235,6 @@ 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,8 +4,6 @@ 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'
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -34,17 +38,6 @@ 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
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -176,8 +167,6 @@ 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')
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable({ reason: 'team_upgrade_resume' })
showPricingTable()
}
} catch {
// sessionStorage may be unavailable

View File

@@ -1,49 +0,0 @@
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,12 +7,7 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -28,6 +23,7 @@ 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 {
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
export interface PendingSubscriptionCheckoutAttempt {
interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface PendingSubscriptionCheckoutAttemptInput {
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -174,9 +168,6 @@ 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 }
: {})
}
}
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
return {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
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.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
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
@@ -318,9 +287,6 @@ 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,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly')
await performSubscriptionCheckout('pro', 'yearly', true)
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',
@@ -151,12 +150,6 @@ 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'
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
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
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
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)
})
)
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})

View File

@@ -4,19 +4,12 @@ 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`
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
options: PerformSubscriptionCheckoutOptions = {}
openInNewTab: boolean = true
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -122,29 +108,14 @@ 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(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
}
if (openInNewTab) {
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
} else {
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,13 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -20,12 +16,6 @@ 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'
@@ -53,9 +43,7 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -63,14 +51,6 @@ 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 () => {
@@ -102,16 +82,6 @@ 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,16 +1,10 @@
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
@@ -28,8 +22,7 @@ interface PerformTeamSubscriptionCheckoutOptions {
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
billingCycle: BillingCycle
): Promise<void> {
if (!isCloud) return
@@ -40,14 +33,6 @@ 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,39 +30,6 @@ 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,7 +1,6 @@
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -100,10 +99,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)
)
trackAddApiCreditButtonClicked(): void {
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

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

View File

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

View File

@@ -12,29 +12,12 @@
* 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
*/
@@ -443,23 +426,16 @@ export interface CheckoutAttributionMetadata {
export interface SubscriptionMetadata {
current_tier?: string
reason?: PaymentIntentSource
}
export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
reason?: SubscriptionDialogReason
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
tier: SubscriptionCheckoutTier
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_attempt_id?: string
billing_op_id?: string
checkout_type: 'new' | 'change'
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
}
interface EcommerceItemMetadata {
@@ -481,9 +457,8 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: 'new' | 'change'
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
value: number
currency: string
ecommerce: EcommerceMetadata
@@ -514,7 +489,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void

View File

@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -336,12 +336,13 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
subscriptionDialog.showPricingTable()
emit('close')
}
const handleTopUp = () => {
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}

View File

@@ -391,13 +391,12 @@ const showZeroState = computed(
)
function handleSubscribeWorkspace() {
showSubscriptionDialog({ reason: 'settings_billing_panel' })
showSubscriptionDialog()
}
function handleUpgrade() {
if (isFreeTierPlan.value)
showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
if (isFreeTierPlan.value) showPricingTable()
else showSubscriptionDialog()
}
function handleViewMoreDetails() {

View File

@@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
@@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue'
const { onClose, reason, initialPlanMode } = defineProps<{
onClose: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
initialPlanMode?: 'personal' | 'team'
}>()
@@ -152,7 +152,7 @@ const {
handleConfirmTransition,
handleTeamSubscribe,
handleResubscribe
} = useSubscriptionCheckout(emit, reason)
} = useSubscriptionCheckout(emit)
// Backspace mirrors the back arrow on the confirm step, but never while an
// editable element is focused (let it delete text there).

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
@@ -17,10 +17,25 @@ const mockHandleResubscribe = vi.fn()
const mockHandleSuccessClose = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
const mockPreviewData = ref<{ transition_type: string } | null>(null)
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
useSubscriptionCheckout: mockUseSubscriptionCheckout
useSubscriptionCheckout: () => ({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
}))
const i18n = createI18n({
@@ -76,7 +91,7 @@ const SuccessStub = {
function renderComponent(
props: {
onClose?: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
isPersonal?: boolean
} = {}
) {
@@ -106,23 +121,6 @@ function renderComponent(
describe('SubscriptionRequiredDialogContentWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSubscriptionCheckout.mockReturnValue({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
mockCheckoutStep.value = 'pricing'
mockPreviewData.value = null
})
@@ -134,15 +132,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
})
it('passes the reason into subscription checkout', () => {
renderComponent({ reason: 'out_of_credits' })
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
expect.any(Function),
'out_of_credits'
)
})
it('shows the team workspace header by default', () => {
renderComponent()
expect(screen.getByText('Team Workspace')).toBeInTheDocument()

View File

@@ -116,7 +116,7 @@
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
@@ -130,7 +130,7 @@ const {
isPersonal = false
} = defineProps<{
onClose: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
isPersonal?: boolean
}>()
@@ -154,7 +154,7 @@ const {
handleConfirmTransition,
handleResubscribe,
handleSuccessClose
} = useSubscriptionCheckout(emit, reason)
} = useSubscriptionCheckout(emit)
</script>
<style scoped>

View File

@@ -61,9 +61,6 @@ function onDismiss() {
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
subscriptionDialog.show({
planMode: 'team',
reason: 'invite_member_upsell'
})
subscriptionDialog.show({ planMode: 'team' })
}
</script>

View File

@@ -277,7 +277,7 @@ export function useMembersPanel() {
}
function showTeamPlans() {
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
subscriptionDialog.show({ planMode: 'team' })
}
return {

View File

@@ -1,9 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
import { computed } from 'vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { findPlanSlug } from './useSubscriptionCheckout'
@@ -76,9 +75,7 @@ const {
mockPlans,
mockResubscribe,
mockToastAdd,
mockStartOperation,
mockTrackBeginCheckout,
mockUserId
mockStartOperation
} = vi.hoisted(() => ({
mockSubscribe: vi.fn(),
mockPreviewSubscribe: vi.fn(),
@@ -87,9 +84,7 @@ const {
mockPlans: { value: [] as Plan[] },
mockResubscribe: vi.fn(),
mockToastAdd: vi.fn(),
mockStartOperation: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
mockStartOperation: vi.fn()
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
@@ -124,14 +119,7 @@ vi.mock('primevue/usetoast', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackMonthlySubscriptionSucceeded: vi.fn(),
trackBeginCheckout: mockTrackBeginCheckout
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
}))
vi.mock('vue-i18n', async (importOriginal) => {
@@ -147,10 +135,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
describe('useSubscriptionCheckout', () => {
let emit: ReturnType<typeof vi.fn>
async function setup(paymentIntentSource?: PaymentIntentSource) {
async function setup() {
const { useSubscriptionCheckout } =
await import('./useSubscriptionCheckout')
return useSubscriptionCheckout(emit as never, paymentIntentSource)
return useSubscriptionCheckout(emit as never)
}
beforeEach(() => {
@@ -158,7 +146,6 @@ describe('useSubscriptionCheckout', () => {
vi.clearAllMocks()
mockPlans.value = allPlans()
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
mockUserId.value = 'user-1'
emit = vi.fn()
})
@@ -472,13 +459,6 @@ describe('useSubscriptionCheckout', () => {
cancelUrl: 'https://platform.comfy.org/payment/failed'
})
expect(checkout.checkoutStep.value).toBe('success')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
checkout_type: 'new',
billing_op_id: 'op-team-1'
})
)
})
it('uses the annual plan slug for the yearly cycle', async () => {
@@ -573,39 +553,6 @@ describe('useSubscriptionCheckout', () => {
detail: 'Team payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('keeps team checkout_type as change when the preview request fails', async () => {
const checkout = await setup()
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
await checkout.handleSubscribeTeamClick({
stop: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
},
billingCycle: 'monthly',
isChange: true
})
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-team-change'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleTeamSubscribe()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
cycle: 'monthly',
checkout_type: 'change',
billing_op_id: 'op-team-change'
})
)
})
})
@@ -656,47 +603,6 @@ describe('useSubscriptionCheckout', () => {
expect(checkout.checkoutStep.value).toBe('success')
})
it('skips begin_checkout when no user id is available', async () => {
mockUserId.value = null
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
mockUserId.value = 'user-1'
})
it('fires begin_checkout carrying the payment intent source', async () => {
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'standard',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op-1',
payment_intent_source: 'subscribe_to_run'
})
})
it('opens payment URL when needs_payment_method', async () => {
const checkout = await setup()
checkout.selectedTierKey.value = 'standard'
@@ -814,7 +720,6 @@ describe('useSubscriptionCheckout', () => {
detail: 'Payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -9,26 +9,16 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
type CheckoutStep = 'pricing' | 'preview' | 'success'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
interface SelectedTeamCheckout {
stop: TeamPlanSelection
checkoutType: SubscriptionCheckoutType
}
/**
* Which screen the `preview` step shows. Only a change prorates: a team change
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
@@ -55,12 +45,9 @@ export function findPlanSlug(
return plan?.slug ?? null
}
export function useSubscriptionCheckout(
emit: {
(e: 'close', subscribed: boolean): void
},
paymentIntentSource?: PaymentIntentSource
) {
export function useSubscriptionCheckout(emit: {
(e: 'close', subscribed: boolean): void
}) {
const { t } = useI18n()
const toast = useToast()
const {
@@ -81,16 +68,13 @@ export function useSubscriptionCheckout(
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const selectedTeamStop = computed(
() => selectedTeamCheckout.value?.stop ?? null
)
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
const previewVariant = computed<PreviewVariant>(() => {
if (selectedTeamCheckout.value) {
if (selectedTeamStop.value) {
return previewData.value ? 'team-change' : 'team-new'
}
if (previewData.value) {
@@ -170,10 +154,7 @@ export function useSubscriptionCheckout(
billingCycle: BillingCycle
isChange?: boolean
}) {
selectedTeamCheckout.value = {
stop: payload.stop,
checkoutType: payload.isChange ? 'change' : 'new'
}
selectedTeamStop.value = payload.stop
selectedBillingCycle.value = payload.billingCycle
selectedTierKey.value = null
previewData.value = null
@@ -201,7 +182,7 @@ export function useSubscriptionCheckout(
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
selectedTeamCheckout.value = null
selectedTeamStop.value = null
}
function handleSuccessClose() {
@@ -209,34 +190,20 @@ export function useSubscriptionCheckout(
}
async function handleSubscription() {
const tierKey = selectedTierKey.value
if (!tierKey) return
const billingCycle = selectedBillingCycle.value
const checkoutType =
previewData.value &&
previewData.value.transition_type !== 'new_subscription'
? 'change'
: 'new'
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(tierKey, billingCycle)
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: tierKey,
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)
@@ -302,8 +269,8 @@ export function useSubscriptionCheckout(
}
async function handleTeamSubscription() {
const teamCheckout = selectedTeamCheckout.value
if (!teamCheckout?.stop.id) {
const stop = selectedTeamStop.value
if (!stop?.id) {
toast.add({
severity: 'error',
summary: t('subscription.teamPlan.name'),
@@ -312,28 +279,16 @@ export function useSubscriptionCheckout(
return
}
const { stop, checkoutType } = teamCheckout
const billingCycle = selectedBillingCycle.value
isSubscribing.value = true
try {
const planSlug = getTeamPlanSlug(billingCycle)
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
const response = await subscribe(planSlug, {
teamCreditStopId: stop.id,
billingCycle,
billingCycle: selectedBillingCycle.value,
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)

View File

@@ -2,7 +2,6 @@ import { computed, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
@@ -276,12 +275,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show({ reason: 'subscription_required' })
subscriptionDialog.show()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
subscriptionDialog.show(options)
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
return {

View File

@@ -1,38 +0,0 @@
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutTier,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useAuthStore } from '@/stores/authStore'
interface TrackWorkspaceCheckoutStartedOptions {
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkoutType: SubscriptionCheckoutType
billingOpId: string
paymentIntentSource?: PaymentIntentSource
}
export function trackWorkspaceCheckoutStarted({
tier,
cycle,
checkoutType,
billingOpId,
paymentIntentSource
}: TrackWorkspaceCheckoutStartedOptions) {
const { userId } = useAuthStore()
if (!userId) return
useTelemetry()?.trackBeginCheckout({
user_id: userId,
tier,
cycle,
checkout_type: checkoutType,
billing_op_id: billingOpId,
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {})
})
}

View File

@@ -1,208 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -148,10 +134,9 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -181,24 +166,18 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -219,14 +198,9 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -1,2 +0,0 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -18,7 +18,7 @@ import type {
} from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
@@ -442,9 +442,9 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog(
options?: SubscriptionDialogOptions
) {
async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}

View File

@@ -0,0 +1,135 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AboutPageBadge } from '@/types/comfy'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
interface SystemInfo {
comfyui_version?: string
installed_templates_version?: string
required_templates_version?: string
}
const { dist, stats, exts } = vi.hoisted(() => ({
dist: { isCloud: false, isDesktop: false },
stats: { system: {} as SystemInfo },
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: {
github: 'https://github.com/comfyanonymous/ComfyUI',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
comfyOrg: 'https://comfy.org',
discord: 'https://discord.com'
}
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({ extensions: exts.list })
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => ({ systemStats: stats })
}))
function label(badges: AboutPageBadge[], includes: string) {
return badges.find((b) => b.label.includes(includes))
}
beforeEach(() => {
setActivePinia(createPinia())
dist.isCloud = false
dist.isDesktop = false
stats.system = {}
exts.list = []
})
describe('aboutPanelStore', () => {
it('builds the default desktop-less, non-cloud core badges', () => {
stats.system = { comfyui_version: 'abc1234' }
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-github')
expect(core.url).toContain('github.com/comfyanonymous')
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
expect(label(store.badges, 'Discord')).toBeDefined()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('uses cloud url and icon for the core badge when running on cloud', () => {
dist.isCloud = true
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-cloud')
expect(core.url).toBe('https://comfy.org')
})
it('uses the electron-reported version label on desktop', () => {
dist.isDesktop = true
const store = useAboutPanelStore()
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
})
it('adds a danger templates badge when the installed version is outdated', () => {
stats.system = {
installed_templates_version: '1.0.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.0.0')!
expect(templates.severity).toBe('danger')
})
it('adds a templates badge without severity when versions match', () => {
stats.system = {
installed_templates_version: '1.1.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('does not mark templates outdated when the required version is missing', () => {
stats.system = {
installed_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('appends extension badges and tolerates extensions without any', () => {
exts.list = [
{
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
},
{} // extension without aboutPageBadges -> ?? [] branch
]
const store = useAboutPanelStore()
expect(label(store.badges, 'My Ext')).toBeDefined()
})
})

View File

@@ -0,0 +1,197 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const {
handlers,
openSet,
errorStore,
dist,
resolvePrecondition,
classifyCloud
} = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>(),
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
dist: { isCloud: false },
resolvePrecondition: vi.fn(),
classifyCloud: vi.fn()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => errorStore
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
}
}))
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
resolveAccountPrecondition: resolvePrecondition
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function fireError(detail: Record<string, unknown>) {
handlers['execution_error']?.({ detail })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
dist.isCloud = false
resolvePrecondition.mockReturnValue(null)
classifyCloud.mockReturnValue(null)
for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors'])
delete errorStore[key]
})
describe('executionStore error handling', () => {
it('marks an open workflow failed and records the raw execution error', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-1',
promptOutput: promptOutput(),
workflow: wf
})
const detail = {
prompt_id: 'job-1',
node_id: '5',
exception_message: 'boom'
}
fireError(detail)
expect(store.getWorkflowStatus(wf)).toBe('failed')
expect(errorStore.lastExecutionError).toBe(detail)
})
it('routes account-precondition errors away from the failed badge', () => {
resolvePrecondition.mockReturnValue({ type: 'credits' })
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
fireError({
prompt_id: 'job-2',
node_id: '5',
exception_type: 'AccountError'
})
expect(resolvePrecondition).toHaveBeenCalledWith({
exceptionType: 'AccountError',
exceptionMessage: ''
})
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastExecutionError).toBeUndefined()
expect(errorStore.lastPromptError).toBeUndefined()
})
it('records a node-less service-level error as a prompt error', () => {
setup()
fireError({
prompt_id: 'job-3',
exception_type: 'StagnationError',
exception_message: 'stuck',
traceback: ['line1', 'line2']
})
expect(errorStore.lastPromptError).toEqual({
type: 'StagnationError',
message: 'StagnationError: stuck',
details: 'line1\nline2'
})
})
it('records classified cloud validation node errors without a failed badge', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'nodeErrors',
nodeErrors: { '5': { errors: [] } }
})
const store = setup()
const wf = workflow('c.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-4',
promptOutput: promptOutput(),
workflow: wf
})
fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } })
})
})

View File

@@ -0,0 +1,243 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
setSpy: vi.fn(),
nodeDefs: {} as Record<string, unknown>
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { reactive } = await import('vue')
const reactiveSettings = reactive(settings)
setSpy.mockImplementation(async (id: string, value: unknown) => {
reactiveSettings[id] = value
})
return {
useSettingStore: () => ({
get: (id: string) => reactiveSettings[id],
set: setSpy
})
}
})
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
createDummyFolderNodeDef: (path: string) => ({
isDummyFolder: true,
nodePath: path,
name: path
})
}))
type BookmarkNodeFixture = Pick<
ComfyNodeDefImpl,
'isDummyFolder' | 'nodePath' | 'category' | 'name'
>
function folderNode(nodePath: string) {
const node = {
isDummyFolder: true,
nodePath,
category: nodePath.replace(/\/$/, ''),
name: nodePath
} satisfies BookmarkNodeFixture
return node as ComfyNodeDefImpl
}
function leafNode(name: string, nodePath = name) {
const node = {
isDummyFolder: false,
name,
nodePath,
category: ''
} satisfies BookmarkNodeFixture
return node as ComfyNodeDefImpl
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(settings)) delete settings[key]
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
settings[BOOKMARK_ID] = []
settings[CUSTOMIZATION_ID] = {}
setSpy.mockClear()
})
describe('nodeBookmarkStore', () => {
it('reports isBookmarked by either nodePath or top-level name', () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
const store = useNodeBookmarkStore()
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
true
)
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
})
it('adds a bookmark by appending to the current list', async () => {
settings[BOOKMARK_ID] = ['A']
const store = useNodeBookmarkStore()
await store.addBookmark('B')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
})
it('toggles an un-bookmarked node by adding its name', async () => {
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
})
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
expect(store.bookmarks).toEqual([])
})
it('creates a folder under a parent and at the root', async () => {
const store = useNodeBookmarkStore()
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
expect(rootPath).toBe('Favorites/')
const childPath = await store.addNewBookmarkFolder(
folderNode('Favorites/'),
'Nested'
)
expect(childPath).toBe('Favorites/Nested/')
})
it('builds the bookmark tree, dropping unknown node defs', () => {
nodeDefs['KSampler'] = leafNode('KSampler')
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
const store = useNodeBookmarkStore()
const children = (store.bookmarkedRoot as { children: unknown[] }).children
expect(children).toHaveLength(2)
})
describe('renameBookmarkFolder', () => {
it('rejects renaming a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
).rejects.toThrow('Cannot rename non-folder node')
})
it('rejects a name containing a slash', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
).rejects.toThrow('cannot contain')
})
it('rejects a rename that collides with an existing folder', async () => {
settings[BOOKMARK_ID] = ['Taken/']
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
).rejects.toThrow('already exists')
})
it('rewrites matching bookmark paths on a valid rename', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
'New/',
'New/KSampler',
'Other/Node'
])
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'New/': { color: '#abc' }
})
})
it('does nothing when the folder keeps the same path', async () => {
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
expect(setSpy).not.toHaveBeenCalled()
})
})
it('deletes a folder and all its descendants', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
const store = useNodeBookmarkStore()
await store.deleteBookmarkFolder(folderNode('Old/'))
expect(settings[BOOKMARK_ID]).toEqual(['Keep/Node'])
expect(
(settings[CUSTOMIZATION_ID] as Record<string, unknown>)['Old/']
).toBeUndefined()
})
it('rejects deleting a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.deleteBookmarkFolder(leafNode('KSampler'))
).rejects.toThrow('Cannot delete non-folder node')
})
describe('updateBookmarkCustomization', () => {
it('persists a non-default customization', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: '#ff0000',
icon: 'pi-star'
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': { color: '#ff0000', icon: 'pi-star' }
})
})
it('drops attributes set to their default values', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: store.defaultBookmarkColor,
icon: store.defaultBookmarkIcon
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': undefined
})
})
})
it('renames a customization entry, moving the old key to the new one', async () => {
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
const store = useNodeBookmarkStore()
await store.renameBookmarkCustomization('Old/', 'New/')
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'New/': { color: '#abc' }
})
})
})

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -10,7 +10,11 @@ import type {
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyApp } from '@/scripts/app'
import * as jobOutputCache from '@/services/jobOutputCache'
import type { TaskOutput } from '@/schemas/apiSchema'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { TaskItemImpl } from '@/stores/queueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => ({
@@ -44,7 +48,9 @@ const mockJobDetail = {
}
},
outputs: {
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
'1': {
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
}
}
}
@@ -137,4 +143,110 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should load full outputs for history tasks', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
mockJobDetail as JobDetail
)
const loaded = await task.loadFullOutputs()
expect(loaded).not.toBe(task)
expect(loaded.flatOutputs[0].filename).toBe('test.png')
})
it('should not load full outputs for running tasks', async () => {
const job = createRunningJob('test-job-id')
const task = new TaskItemImpl(job)
const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail')
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
expect(detailSpy).not.toHaveBeenCalled()
})
it('should keep history tasks when full outputs are unavailable', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ id: 'test-job-id', status: 'completed' })
)
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
})
it('should load workflow outputs from the task when job detail has none', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, mockJobDetail.outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).toHaveBeenCalledOnce()
expect(
nodeOutputStore.getNodeOutputByExecutionId(
createNodeExecutionId([toNodeId(1)])
)
).toEqual(mockJobDetail.outputs['1'])
})
it('should skip workflow output loading when no outputs exist', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, fromAny<TaskOutput, unknown>(null))
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).not.toHaveBeenCalled()
expect(nodeOutputStore.nodeOutputs).toEqual({})
})
it('should skip invalid node execution ids while loading outputs', async () => {
const job = createHistoryJob('test-job-id')
const outputs = fromAny<TaskOutput, unknown>({
'': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] },
'1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] }
})
const task = new TaskItemImpl(job, outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(setOutputsSpy).toHaveBeenCalledOnce()
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
expect(
nodeOutputStore.getNodeOutputByExecutionId(
createNodeExecutionId([toNodeId(1)])
)
).toEqual(outputs['1'])
expect(Object.keys(nodeOutputStore.nodeOutputs)).toEqual(['1'])
})
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,7 +7,14 @@ import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import {
isInstantMode,
isInstantRunningMode,
ResultItemImpl,
TaskItemImpl,
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
// Fixture factory for JobListItem
function createJob(
@@ -67,6 +75,86 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should default missing result URL fields', () => {
const output = new ResultItemImpl(
fromAny<ConstructorParameters<typeof ResultItemImpl>[0], unknown>({
nodeId: 'node-1',
mediaType: 'images'
})
)
expect(output.filename).toBe('')
expect(output.subfolder).toBe('')
expect(output.type).toBe('')
expect(output.url).toBe('')
})
it('should use the raw URL as preview URL for non-images', () => {
const output = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'video',
filename: 'clip.webm',
type: 'output',
subfolder: ''
})
expect(output.previewUrl).toBe(output.url)
})
it('should recognize VHS mp4 and unsupported video formats', () => {
const webm = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/webm',
frame_rate: 24
})
const mp4 = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/mp4',
frame_rate: 24
})
const avi = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/avi',
frame_rate: 24
})
expect(webm.htmlVideoType).toBe('video/webm')
expect(mp4.htmlVideoType).toBe('video/mp4')
expect(avi.htmlVideoType).toBeUndefined()
})
it('should detect image media type without an image suffix', () => {
const image = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated',
type: 'output',
subfolder: ''
})
const audioFile = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated.wav',
type: 'output',
subfolder: ''
})
expect(image.isImage).toBe(true)
expect(audioFile.isImage).toBe(false)
})
it('should exclude animated from flatOutputs', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
@@ -259,6 +347,41 @@ describe('TaskItemImpl', () => {
expect(taskItem.executionError).toEqual(errorDetail)
})
})
it('should expose queue API task type for running tasks', () => {
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
expect(task.apiTaskType).toBe('queue')
})
it('should return empty flat outputs when outputs are missing', () => {
const task = new TaskItemImpl(
createHistoryJob(0, 'job-id'),
fromAny<TaskOutput, unknown>(null)
)
expect(task.calculateFlatOutputs()).toEqual([])
})
it('should calculate execution time in seconds', () => {
const task = new TaskItemImpl({
...createHistoryJob(0, 'job-id'),
execution_start_time: 1000,
execution_end_time: 3500
})
expect(task.executionStartTimestamp).toBe(1000)
expect(task.executionEndTimestamp).toBe(3500)
expect(task.executionTime).toBe(2500)
expect(task.executionTimeInSeconds).toBe(2.5)
})
it('should return undefined execution seconds without both timestamps', () => {
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'))
expect(task.executionTime).toBeUndefined()
expect(task.executionTimeInSeconds).toBeUndefined()
})
})
describe('useQueueStore', () => {
@@ -314,6 +437,19 @@ describe('useQueueStore', () => {
expect(store.pendingTasks[1].jobId).toBe('pend-1')
})
it('should register workflow ids for active jobs', async () => {
const executionStore = useExecutionStore()
mockGetQueue.mockResolvedValue({
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
Pending: []
})
mockGetHistory.mockResolvedValue([])
await store.update()
expect(executionStore.jobIdToWorkflowId.get('run-1')).toBe('wf-1')
})
it('should load history tasks from API', async () => {
const historyJob1 = createHistoryJob(5, 'hist-1')
const historyJob2 = createHistoryJob(4, 'hist-2')
@@ -1115,3 +1251,43 @@ describe('useQueueStore', () => {
})
})
})
describe('useQueuePendingTaskCountStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates from status websocket messages', () => {
const store = useQueuePendingTaskCountStore()
store.update(
fromAny<CustomEvent, unknown>({
detail: { exec_info: { queue_remaining: 3 } }
})
)
expect(store.count).toBe(3)
})
it('falls back to zero when status details are missing', () => {
const store = useQueuePendingTaskCountStore()
store.count = 3
store.update(fromAny<CustomEvent, unknown>({}))
expect(store.count).toBe(0)
})
})
describe('queue mode helpers', () => {
it('detect instant queue modes', () => {
expect(isInstantMode('instant-idle')).toBe(true)
expect(isInstantMode('instant-running')).toBe(true)
expect(isInstantMode('change')).toBe(false)
})
it('detect instant running mode', () => {
expect(isInstantRunningMode('instant-running')).toBe(true)
expect(isInstantRunningMode('instant-idle')).toBe(false)
})
})

View File

@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -164,6 +165,7 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"