Compare commits
27 Commits
codex/cove
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57de75748e | ||
|
|
ec269512e7 | ||
|
|
417f2d7b5c | ||
|
|
c9d02d1661 | ||
|
|
6c2ab519ac | ||
|
|
6455a49f58 | ||
|
|
b846cf4171 | ||
|
|
e970f5457b | ||
|
|
06d5443de1 | ||
|
|
86219d117d | ||
|
|
8ee6fc6f5f | ||
|
|
d9fd2e8c2f | ||
|
|
414469ed3c | ||
|
|
8e0622e423 | ||
|
|
be251d540a | ||
|
|
6bb1dc972f | ||
|
|
9065b845fc | ||
|
|
61ebcb514d | ||
|
|
b5fd5fd54c | ||
|
|
70c2e5e70e | ||
|
|
8bd12134b2 | ||
|
|
160d7c7a63 | ||
|
|
51efcf0424 | ||
|
|
0975a7ffbc | ||
|
|
8bebdb3021 | ||
|
|
b8207f2647 | ||
|
|
787815eb09 |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
SidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _appsTab: SidebarTab | null = null
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
@@ -104,6 +106,11 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get appsTab() {
|
||||
this._appsTab ??= new SidebarTab(this.page, 'apps')
|
||||
return this._appsTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
export class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
|
||||
@@ -238,6 +238,9 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
|
||||
@@ -137,6 +137,125 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shares the graph side toolbar, filtered to assets + apps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode reuses it with only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(assetsTab.tabButton).toBeVisible()
|
||||
await expect(appsTab.tabButton).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
const appActions = comfyPage.page
|
||||
.getByTestId(TestIds.linear.centerPanel)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const appSegment = comfyPage.page.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
await appSegment.focus()
|
||||
await appSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// Keyboard activation of the inactive segment must switch mode without the
|
||||
// keydown bubbling to the trigger and opening the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
// Exiting the builder lands in graph mode: the app-mode-only center panel
|
||||
// stays hidden while the toggle's teleport host re-mounts and the toggle
|
||||
// re-appears.
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -1,5 +1,89 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
|
||||
--gen-angle must be a registered <angle> so the conic gradient interpolates
|
||||
instead of jumping between keyframes. */
|
||||
@property --gen-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes gen-angle-spin {
|
||||
to {
|
||||
--gen-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.gen-glow {
|
||||
position: absolute;
|
||||
inset: -28%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from var(--gen-angle),
|
||||
#3b82f63b,
|
||||
#8b5cf633,
|
||||
#d946ef2b,
|
||||
#ec489933,
|
||||
#f9731629,
|
||||
#14b8a62e,
|
||||
#3b82f63b
|
||||
);
|
||||
filter: blur(60px);
|
||||
opacity: 0.34;
|
||||
animation: gen-angle-spin 12s linear infinite;
|
||||
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
|
||||
}
|
||||
|
||||
.gen-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from calc(var(--gen-angle) + 120deg),
|
||||
#3b82f629,
|
||||
#8b5cf621,
|
||||
#d946ef1c,
|
||||
#ec489924,
|
||||
#f973161a,
|
||||
#14b8a621,
|
||||
#3b82f629
|
||||
);
|
||||
filter: blur(34px);
|
||||
opacity: 0.39;
|
||||
mask-image: radial-gradient(
|
||||
circle,
|
||||
#000 0%,
|
||||
rgb(0 0 0 / 0.62) 36%,
|
||||
rgb(0 0 0 / 0.22) 50%,
|
||||
rgb(0 0 0 / 0) 64%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gen-glow {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active,
|
||||
.gen-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active {
|
||||
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.genfan-enter-from,
|
||||
.genfan-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
87
src/components/appMode/AppModeToolbar.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeToolbar from './AppModeToolbar.vue'
|
||||
|
||||
const appModeState = vi.hoisted(() => ({
|
||||
enableAppBuilder: true,
|
||||
hasNodes: true
|
||||
}))
|
||||
const enterBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({
|
||||
enterBuilder,
|
||||
hasNodes: computed(() => appModeState.hasNodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const BUILD_AN_APP = 'Build an app'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderToolbar() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppModeToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: true
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppModeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appModeState.enableAppBuilder = true
|
||||
appModeState.hasNodes = true
|
||||
})
|
||||
|
||||
it('shows an enabled build button and enters the builder on click', async () => {
|
||||
const { user } = renderToolbar()
|
||||
|
||||
const button = screen.getByRole('button', { name: BUILD_AN_APP })
|
||||
expect(button).toBeEnabled()
|
||||
|
||||
await user.click(button)
|
||||
|
||||
expect(enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the build button when there are no nodes', () => {
|
||||
appModeState.hasNodes = false
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('hides the build button when app building is disabled', () => {
|
||||
appModeState.enableAppBuilder = false
|
||||
renderToolbar()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: BUILD_AN_APP })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,119 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
variant="base"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
|
||||
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
71
src/components/breadcrumb/SubgraphBreadcrumb.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({ navigationStack: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
dispose: vi.fn(),
|
||||
checkOverflow: vi.fn(),
|
||||
disposed: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { graphNavigation: 'Graph navigation' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderBreadcrumb() {
|
||||
return render(SubgraphBreadcrumb, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
|
||||
Breadcrumb: true,
|
||||
Button: true,
|
||||
SubgraphBreadcrumbItem: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubgraphBreadcrumb', () => {
|
||||
beforeEach(() => {
|
||||
canvasState.linearMode = false
|
||||
})
|
||||
|
||||
it('renders the workflow actions dropdown when not in linear mode', () => {
|
||||
renderBreadcrumb()
|
||||
expect(screen.getByTestId('wad')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the workflow actions dropdown in linear mode', () => {
|
||||
canvasState.linearMode = true
|
||||
renderBreadcrumb()
|
||||
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<WorkflowActionsDropdown
|
||||
v-if="!canvasStore.linearMode"
|
||||
source="breadcrumb_subgraph_menu_selected"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
|
||||
222
src/components/common/WorkflowActionsDropdown.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ViewMode } from '@/utils/appMode'
|
||||
|
||||
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
markAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
const viewState = vi.hoisted(() => ({
|
||||
viewMode: 'graph' as ViewMode,
|
||||
displayViewMode: 'graph' as ViewMode
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({
|
||||
viewMode: computed(() => viewState.viewMode),
|
||||
displayViewMode: computed(() => viewState.displayViewMode)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: spies.execute, commands: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useNewMenuItemIndicator: () => ({
|
||||
hasUnseenItems: ref(true),
|
||||
markAsSeen: spies.markAsSeen
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { shortcutSuffix: ' ({shortcut})' },
|
||||
breadcrumbsMenu: {
|
||||
graph: 'Graph',
|
||||
app: 'App',
|
||||
enterNodeGraph: 'Enter node graph',
|
||||
enterAppMode: 'Enter app mode',
|
||||
workflowActions: 'Workflow actions',
|
||||
activeModeWorkflowActions: '{mode} mode, workflow actions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderDropdown() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(WorkflowActionsDropdown, {
|
||||
props: { source: 'test' },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
WorkflowActionsList: true
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('WorkflowActionsDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
viewState.viewMode = 'graph'
|
||||
viewState.displayViewMode = 'graph'
|
||||
})
|
||||
|
||||
it('keeps the active segment label in its accessible name alongside the actions label', () => {
|
||||
renderDropdown()
|
||||
|
||||
// Graph is the active segment, so its name must contain the visible "Graph"
|
||||
// label (label-in-name) while still matching the "Workflow actions" trigger.
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'Graph mode, workflow actions')
|
||||
})
|
||||
|
||||
it('labels the inactive segment with its switch action only', () => {
|
||||
renderDropdown()
|
||||
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
|
||||
})
|
||||
|
||||
it('flips the segment roles when app mode is active', () => {
|
||||
viewState.viewMode = 'app'
|
||||
viewState.displayViewMode = 'app'
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter node graph' })
|
||||
).toHaveAttribute('aria-label', 'Enter node graph')
|
||||
})
|
||||
|
||||
it('derives the active segment from the real mode, not the lagged display mode', () => {
|
||||
// Mid-animation: the mode has flipped to app but the display still lags.
|
||||
viewState.viewMode = 'app'
|
||||
viewState.displayViewMode = 'graph'
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter node graph' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('carries the popup semantics only on the active segment', () => {
|
||||
renderDropdown()
|
||||
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-haspopup', 'menu')
|
||||
expect(active).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Enter app mode' })
|
||||
).not.toHaveAttribute('aria-haspopup')
|
||||
})
|
||||
|
||||
it('toggles the view mode when the inactive segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the menu instead of toggling the mode when the active segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
await user.click(active)
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
expect(active).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(spies.markAsSeen).toHaveBeenCalled()
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'test',
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the menu when the open trigger is clicked again', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
await user.click(active)
|
||||
await user.click(active)
|
||||
|
||||
expect(active).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('switches mode when the inactive segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
|
||||
inactive.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not switch mode when the active segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the menu on ArrowDown on the active segment', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
expect(active).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuRoot
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -14,8 +15,21 @@ import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ViewMode } from '@/utils/appMode'
|
||||
|
||||
interface ViewModeSegment {
|
||||
mode: ViewMode
|
||||
icon: string
|
||||
label: string
|
||||
switchLabel: string
|
||||
switchTooltip: string
|
||||
/** Truth: drives behavior and aria. Flips as soon as the mode changes. */
|
||||
active: boolean
|
||||
/** Frame-lagged mirror of {@link active}: drives the morph styling/order. */
|
||||
displayActive: boolean
|
||||
}
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
@@ -23,46 +37,120 @@ const { source, align = 'start' } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const toggleShortcut = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
const segments = computed<ViewModeSegment[]>(() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
mode: 'graph',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
label: t('breadcrumbsMenu.graph'),
|
||||
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
|
||||
switchTooltip:
|
||||
t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value
|
||||
},
|
||||
{
|
||||
mode: 'app',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
label: t('breadcrumbsMenu.app'),
|
||||
switchLabel: t('breadcrumbsMenu.enterAppMode'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value
|
||||
}
|
||||
] as const
|
||||
).map((seg) => ({
|
||||
...seg,
|
||||
active: appModeStore.viewMode === seg.mode,
|
||||
displayActive: appModeStore.displayViewMode === seg.mode
|
||||
}))
|
||||
)
|
||||
|
||||
// Display-inactive segment first (left), display-active last (right). On mode
|
||||
// switch the array reorders and TransitionGroup FLIP-animates the keyed nodes
|
||||
// to their new spots.
|
||||
const orderedSegments = computed(() => {
|
||||
const [graph, app] = segments.value
|
||||
return graph.displayActive ? [app, graph] : [graph, app]
|
||||
})
|
||||
|
||||
const toggleContainer = useTemplateRef<HTMLDivElement>('toggleContainer')
|
||||
|
||||
// The active segment is the only element carrying popup semantics, which makes
|
||||
// this a stable, markup-derived way to find it.
|
||||
function activeSegmentElement() {
|
||||
return (
|
||||
toggleContainer.value?.querySelector<HTMLElement>(
|
||||
'[aria-haspopup="menu"]'
|
||||
) ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (!dropdownOpen.value) return
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
function onSegmentClick(seg: ViewModeSegment) {
|
||||
if (seg.active) toggleDropdown()
|
||||
else switchMode()
|
||||
}
|
||||
|
||||
// Match the stock dropdown trigger: ArrowDown on the trigger opens the menu.
|
||||
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
|
||||
if (!seg.active || e.key !== 'ArrowDown') return
|
||||
e.preventDefault()
|
||||
if (!dropdownOpen.value) toggleDropdown()
|
||||
}
|
||||
|
||||
// Reimplements the two trigger-element behaviors of a stock DropdownMenuTrigger
|
||||
// (which this component cannot use without breaking the FLIP morph): a click on
|
||||
// the open menu's trigger toggles it closed instead of dismiss-then-reopen, and
|
||||
// focus returns to the trigger on close unless the user interacted elsewhere.
|
||||
let interactedOutside = false
|
||||
function onInteractOutside(event: PointerDownOutsideEvent | FocusOutsideEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof Node && activeSegmentElement()?.contains(target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
interactedOutside = true
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
if (!interactedOutside) activeSegmentElement()?.focus()
|
||||
interactedOutside = false
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
@@ -75,82 +163,97 @@ const tooltipPt = {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
style: { left: '16px' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-model:open="dropdownOpen"
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
<DropdownMenuRoot v-model:open="dropdownOpen" :modal="false">
|
||||
<div
|
||||
ref="toggleContainer"
|
||||
data-testid="view-mode-toggle"
|
||||
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
|
||||
:data-state="dropdownOpen ? 'open' : 'closed'"
|
||||
>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
move-class="transition-[background-color,color,transform] duration-200"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
v-for="seg in orderedSegments"
|
||||
:key="seg.mode"
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
value: seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchTooltip,
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
pt: seg.active ? undefined : tooltipPt
|
||||
}"
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
seg.active
|
||||
? t('breadcrumbsMenu.activeModeWorkflowActions', {
|
||||
mode: seg.label
|
||||
})
|
||||
: seg.switchLabel
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
:aria-haspopup="seg.active ? 'menu' : undefined"
|
||||
:aria-expanded="seg.active ? dropdownOpen : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
|
||||
seg.displayActive
|
||||
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
|
||||
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@click="onSegmentClick(seg)"
|
||||
@keydown="onSegmentKeydown(seg, $event)"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
|
||||
<span
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
cn(
|
||||
'grid transition-[grid-template-columns,opacity] duration-200',
|
||||
seg.displayActive
|
||||
? 'ml-1.5 grid-cols-[1fr] opacity-100'
|
||||
: 'grid-cols-[0fr] opacity-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
|
||||
>
|
||||
{{ seg.label }}
|
||||
<i
|
||||
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="seg.active && hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</slot>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
:reference="toggleContainer ?? undefined"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
@interact-outside="onInteractOutside"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<WorkflowActionsList :items="menuItems" />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
<template #side-toolbar>
|
||||
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
|
||||
195
src/components/sidebar/SideToolbar.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SideToolbar from './SideToolbar.vue'
|
||||
|
||||
interface TestTab {
|
||||
id: string
|
||||
icon: string
|
||||
tooltip: string
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
toggleAssets: vi.fn()
|
||||
}))
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
isMultiUserServer: false,
|
||||
sidebarTabs: [] as TestTab[],
|
||||
activeSidebarTab: null as { id: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
getSidebarTabs: () => state.sidebarTabs,
|
||||
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'Comfy.Sidebar.Size') return 'large'
|
||||
if (key === 'Comfy.Sidebar.Location') return 'left'
|
||||
return 'floating'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: [
|
||||
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: null })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
type SideToolbarProps = ComponentProps<typeof SideToolbar>
|
||||
|
||||
function renderToolbar(props: SideToolbarProps = {}) {
|
||||
return render(SideToolbar, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
ComfyMenuButton: { template: '<div />' },
|
||||
SidebarTemplatesButton: { template: '<div />' },
|
||||
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
|
||||
SidebarHelpCenterIcon: { template: '<div />' },
|
||||
SidebarSettingsButton: { template: '<div />' },
|
||||
HelpCenterPopups: { template: '<div />' },
|
||||
SidebarBottomPanelToggleButton: {
|
||||
template: '<div data-testid="bottom-panel-toggle" />'
|
||||
},
|
||||
SidebarShortcutsToggleButton: {
|
||||
template: '<div data-testid="shortcuts-toggle" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const assetsTab: TestTab = {
|
||||
id: 'assets',
|
||||
icon: 'pi pi-image',
|
||||
tooltip: 'Assets',
|
||||
label: 'Assets',
|
||||
title: 'Assets'
|
||||
}
|
||||
|
||||
const workflowsTab: TestTab = {
|
||||
id: 'workflows',
|
||||
icon: 'pi pi-folder',
|
||||
tooltip: 'Workflows',
|
||||
label: 'Workflows',
|
||||
title: 'Workflows'
|
||||
}
|
||||
|
||||
describe('SideToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.isMultiUserServer = false
|
||||
state.sidebarTabs = [assetsTab, workflowsTab]
|
||||
state.activeSidebarTab = null
|
||||
})
|
||||
|
||||
it('renders only the tabs listed in visibleTabIds', () => {
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Workflows' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Workflows' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the toolbar as connected when forceConnected is true', () => {
|
||||
renderToolbar({ forceConnected: true })
|
||||
|
||||
// connected-sidebar is a behavioral hook: it drives the global
|
||||
// :root:has() sidebar width variables.
|
||||
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
|
||||
})
|
||||
|
||||
it('shows the shortcuts and bottom panel toggles by default', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the shortcuts and bottom panel toggles when hideWorkspaceToggles is set', () => {
|
||||
renderToolbar({ hideWorkspaceToggles: true })
|
||||
|
||||
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Assets' }))
|
||||
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(spies.toggleAssets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the logout icon only on a multi-user server', () => {
|
||||
const { unmount } = renderToolbar()
|
||||
expect(screen.queryByTestId('logout')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
state.isMultiUserServer = true
|
||||
renderToolbar()
|
||||
expect(screen.getByTestId('logout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -42,8 +42,14 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton
|
||||
v-if="!isCloud && !hideWorkspaceToggles"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarShortcutsToggleButton
|
||||
v-if="!hideWorkspaceToggles"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +95,16 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const {
|
||||
visibleTabIds,
|
||||
forceConnected = false,
|
||||
hideWorkspaceToggles = false
|
||||
} = defineProps<{
|
||||
visibleTabIds?: string[]
|
||||
forceConnected?: boolean
|
||||
hideWorkspaceToggles?: boolean
|
||||
}>()
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -115,12 +131,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
forceConnected ||
|
||||
selectedTab.value ||
|
||||
isOverflowing.value ||
|
||||
sidebarStyle.value === 'connected'
|
||||
)
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const tabs = computed(() => {
|
||||
const all = workspaceStore.getSidebarTabs()
|
||||
return visibleTabIds
|
||||
? all.filter((tab) => visibleTabIds.includes(tab.id))
|
||||
: all
|
||||
})
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
150
src/components/sidebar/SidebarHelpCenterIcon.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
|
||||
const typeformState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true,
|
||||
typeformId: 'jmmzmlKw'
|
||||
}))
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: true }))
|
||||
|
||||
const helpCenterSpies = vi.hoisted(() => ({ toggleHelpCenter: vi.fn() }))
|
||||
|
||||
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useTypeformEmbed: () => ({
|
||||
typeformError: computed(() => typeformState.typeformError),
|
||||
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
|
||||
typeformId: computed(() => typeformState.typeformId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useHelpCenter', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useHelpCenter: () => ({
|
||||
shouldShowRedDot: ref(false),
|
||||
toggleHelpCenter: helpCenterSpies.toggleHelpCenter
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => 'left' })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
return {
|
||||
useCanvasStore: () =>
|
||||
reactive({ linearMode: computed(() => canvasState.linearMode) })
|
||||
}
|
||||
})
|
||||
|
||||
const FEEDBACK_LOAD_ERROR =
|
||||
'Failed to load feedback form. Please try again later.'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { help: 'Help' },
|
||||
sideToolbar: { helpCenter: 'Help Center' },
|
||||
linearMode: {
|
||||
giveFeedback: 'Give feedback',
|
||||
feedbackLoadError: FEEDBACK_LOAD_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderIcon() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(SidebarHelpCenterIcon, {
|
||||
props: { isSmall: false },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('SidebarHelpCenterIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
typeformState.typeformError = false
|
||||
typeformState.isValidTypeformId = true
|
||||
canvasState.linearMode = true
|
||||
})
|
||||
|
||||
it('mounts the Typeform embed container when the id is valid and loads', () => {
|
||||
const { container } = renderIcon()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
|
||||
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the localized fallback instead of the embed when loading fails', () => {
|
||||
typeformState.typeformError = true
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the localized fallback when the form id is invalid', () => {
|
||||
typeformState.isValidTypeformId = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not open the help center from the feedback button in app mode', async () => {
|
||||
const { user } = renderIcon()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Give feedback' }))
|
||||
|
||||
expect(helpCenterSpies.toggleHelpCenter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the help center button instead of the feedback popover in graph mode', () => {
|
||||
canvasState.linearMode = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Help Center' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Give feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles the help center on click in graph mode', async () => {
|
||||
canvasState.linearMode = false
|
||||
const { user } = renderIcon()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Help Center' }))
|
||||
|
||||
expect(helpCenterSpies.toggleHelpCenter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,34 @@
|
||||
<template>
|
||||
<Popover
|
||||
v-if="linearMode"
|
||||
:side="sidebarOnLeft ? 'right' : 'left'"
|
||||
:side-offset="8"
|
||||
>
|
||||
<template #button>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('linearMode.giveFeedback')"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="typeformError || !isValidTypeformId"
|
||||
class="text-danger p-4 text-sm"
|
||||
>
|
||||
{{ $t('linearMode.feedbackLoadError') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="feedbackRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
/>
|
||||
</Popover>
|
||||
<SidebarIcon
|
||||
v-else
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
@@ -13,13 +42,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
|
||||
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
|
||||
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
|
||||
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
|
||||
feedbackRef,
|
||||
APP_MODE_FEEDBACK_TYPEFORM_ID
|
||||
)
|
||||
</script>
|
||||
|
||||
207
src/components/sidebar/tabs/AppsSidebarTab.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import AppsSidebarTab from './AppsSidebarTab.vue'
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn())
|
||||
|
||||
const workflowStoreState = vi.hoisted(() => ({
|
||||
persistedWorkflows: [] as ComfyWorkflow[]
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { ComfyWorkflow } =
|
||||
await import('@/platform/workflow/management/stores/comfyWorkflow')
|
||||
return {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore: () => ({
|
||||
get workflows() {
|
||||
return workflowStoreState.persistedWorkflows
|
||||
},
|
||||
get persistedWorkflows() {
|
||||
return workflowStoreState.persistedWorkflows
|
||||
},
|
||||
bookmarkedWorkflows: [],
|
||||
openWorkflows: [],
|
||||
activeWorkflow: undefined,
|
||||
isSyncLoading: false,
|
||||
syncWorkflows: vi.fn()
|
||||
}),
|
||||
useWorkflowBookmarkStore: () => ({ loadBookmarks: vi.fn() })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return { useAppMode: () => ({ isAppMode: computed(() => true) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => undefined })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
beta: 'Beta',
|
||||
refresh: 'Refresh',
|
||||
searchPlaceholder: 'Search {subject}'
|
||||
},
|
||||
sideToolbar: {
|
||||
workflowTab: {
|
||||
workflowTreeType: {
|
||||
open: 'Open',
|
||||
bookmarks: 'Bookmarks',
|
||||
browse: 'Browse'
|
||||
}
|
||||
}
|
||||
},
|
||||
linearMode: {
|
||||
appModeToolbar: {
|
||||
apps: 'Apps',
|
||||
create: 'Create',
|
||||
createApp: 'Create app',
|
||||
appsEmptyMessage: 'No apps yet',
|
||||
appsEmptyMessageAction: 'Create one to get started'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const noResultsPlaceholderStub = {
|
||||
props: ['buttonLabel'],
|
||||
emits: ['action'],
|
||||
template: '<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
|
||||
}
|
||||
|
||||
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseWorkflowsSidebarTab: {
|
||||
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
|
||||
},
|
||||
NoResultsPlaceholder: noResultsPlaceholderStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
async function makeWorkflow(path: string): Promise<ComfyWorkflow> {
|
||||
const { ComfyWorkflow } =
|
||||
await import('@/platform/workflow/management/stores/comfyWorkflow')
|
||||
return new ComfyWorkflow({ path, modified: 0, size: 1 })
|
||||
}
|
||||
|
||||
function renderTabWithRealBase() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
SidebarTabTemplate: {
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
|
||||
},
|
||||
SidebarTopArea: { template: '<div><slot /></div>' },
|
||||
SearchInput: { template: '<input />', methods: { focus() {} } },
|
||||
TreeExplorer: { template: '<div data-testid="tree-explorer" />' },
|
||||
NoResultsPlaceholder: noResultsPlaceholderStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowStoreState.persistedWorkflows = []
|
||||
})
|
||||
|
||||
it('shows the create action only when there are results', () => {
|
||||
const { unmount } = renderTab({ hasResults: false })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create' })
|
||||
).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderTab({ hasResults: true })
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs the new-workflow command when the create action is clicked', async () => {
|
||||
const { user } = renderTab({ hasResults: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
it('runs the new-workflow command from the empty-state action', async () => {
|
||||
const { user } = renderTab({ hasResults: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create app' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
describe('with the real workflows tab', () => {
|
||||
it('counts only app workflows as results', async () => {
|
||||
workflowStoreState.persistedWorkflows = [
|
||||
await makeWorkflow('workflows/my-app.app.json'),
|
||||
await makeWorkflow('workflows/regular.json')
|
||||
]
|
||||
|
||||
renderTabWithRealBase()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create app' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the empty state when no app workflows exist', async () => {
|
||||
workflowStoreState.persistedWorkflows = [
|
||||
await makeWorkflow('workflows/regular.json')
|
||||
]
|
||||
|
||||
renderTabWithRealBase()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Create' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create app' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,18 +13,25 @@
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-actions="{ hasResults }">
|
||||
<Button
|
||||
v-if="hasResults"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@click="createApp"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
|
||||
{{ $t('linearMode.appModeToolbar.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
|
||||
button-icon="icon-[lucide--plus]"
|
||||
:button-label="$t('linearMode.appModeToolbar.createApp')"
|
||||
@action="createApp"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
@@ -33,16 +40,17 @@
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
function createApp() {
|
||||
void commandStore.execute('Comfy.NewBlankWorkflow')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot
|
||||
name="header-actions"
|
||||
:has-results="filteredPersistedWorkflows.length > 0"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SidebarTopArea>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
35
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { text, side = 'top' } = defineProps<{
|
||||
text: string
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child v-bind="$attrs">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
:side
|
||||
:side-offset="5"
|
||||
class="z-1700 rounded-md border border-border-subtle bg-base-background px-2 py-1 text-xs shadow-sm"
|
||||
>
|
||||
{{ text }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
import type { DefaultConnectionColors } 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<NodeInputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeInputSlot {
|
||||
return new NodeInputSlot(
|
||||
fromAny({
|
||||
name: 'in',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
boundingRect: [10, 20, 20, 20],
|
||||
...overrides
|
||||
}),
|
||||
node
|
||||
)
|
||||
}
|
||||
|
||||
function createOutputSlot(
|
||||
overrides: Partial<NodeOutputSlot> = {},
|
||||
node = createNode()
|
||||
): NodeOutputSlot {
|
||||
return new NodeOutputSlot(
|
||||
fromAny({
|
||||
name: 'out',
|
||||
type: 'STRING',
|
||||
links: null,
|
||||
boundingRect: [10, 20, 20, 20],
|
||||
...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 = fromAny(undefined)
|
||||
expect(createInputSlot().highlightColor).toBe(
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR
|
||||
)
|
||||
|
||||
LiteGraph.NODE_SELECTED_TITLE_COLOR = fromAny(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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,13 +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 = { name: 'my-widget', type: 'number' } as IWidget
|
||||
|
||||
const serialised = outputAsSerialisable(output)
|
||||
expect(serialised.widget).toEqual({ name: 'my-widget' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromAny, 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 {
|
||||
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 = fromAny(
|
||||
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(fromAny(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' }),
|
||||
fromAny({}),
|
||||
fromAny({}),
|
||||
fromAny({})
|
||||
)
|
||||
expect(slot.linkIds).toHaveLength(0)
|
||||
|
||||
// Remove action deletes the slot from the subgraph.
|
||||
void captured?.opts.callback?.(
|
||||
fromPartial({ value: 'remove' }),
|
||||
fromAny({}),
|
||||
fromAny({}),
|
||||
fromAny({})
|
||||
)
|
||||
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' }),
|
||||
fromAny({}),
|
||||
fromAny({}),
|
||||
fromAny({})
|
||||
)
|
||||
|
||||
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?.(fromAny({}))
|
||||
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
inputNode,
|
||||
slot
|
||||
)
|
||||
|
||||
pointer.onDragEnd?.(fromAny({}))
|
||||
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?.(fromAny({}))
|
||||
|
||||
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?.(fromAny({}))
|
||||
|
||||
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(() => fromAny({})),
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,655 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromAny, 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 { Subgraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
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 = fromAny({ 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 = fromAny([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(
|
||||
fromAny({ 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 = fromAny('')
|
||||
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?.(fromAny({}))
|
||||
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
|
||||
subgraph,
|
||||
outputNode,
|
||||
slot
|
||||
)
|
||||
pointer.onDragEnd?.(fromAny({}))
|
||||
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 = fromAny(
|
||||
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(
|
||||
fromAny({}),
|
||||
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', fromAny(''))
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,20 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { 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 { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
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 }))
|
||||
@@ -187,392 +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: fromAny({ name: 'sub' })
|
||||
})
|
||||
const unresolvable = fromPartial<ResolvedConnection>({})
|
||||
|
||||
const grouped = groupResolvedByOutput([
|
||||
first,
|
||||
second,
|
||||
bySubgraphInput,
|
||||
unresolvable
|
||||
])
|
||||
|
||||
expect(grouped.size).toBe(3)
|
||||
expect(grouped.get(fromAny(output))).toEqual([first, second])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSubgraphInputsAndLinks', () => {
|
||||
function createResolvedInput(
|
||||
linkId: number,
|
||||
inputOverrides: Record<string, unknown> = {}
|
||||
): { resolved: ResolvedConnection; link: LLink } {
|
||||
const link = new LLink(
|
||||
fromAny(linkId),
|
||||
'number',
|
||||
fromAny(1),
|
||||
0,
|
||||
fromAny(2),
|
||||
0
|
||||
)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromAny({ name: 'in', type: 'number', ...inputOverrides }),
|
||||
output: fromAny({ 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(fromAny(1), 'number', fromAny(1), 0, fromAny(2), 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
output: fromAny({ 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(
|
||||
fromAny(linkId),
|
||||
'number',
|
||||
fromAny(1),
|
||||
0,
|
||||
fromAny(2),
|
||||
0
|
||||
)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromAny({ name: 'in', type: 'number' }),
|
||||
output: fromAny({ 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(fromAny(1), 'number', fromAny(1), 0, fromAny(2), 0)
|
||||
const resolved = fromPartial<ResolvedConnection>({
|
||||
link,
|
||||
input: fromAny({ name: 'in', type: 'number' })
|
||||
})
|
||||
|
||||
const outputs = mapSubgraphOutputsAndLinks([resolved], [], new Map())
|
||||
|
||||
expect(outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputs', () => {
|
||||
it('returns silently when the node has no subgraph', () => {
|
||||
expect(() =>
|
||||
reorderSubgraphInputs(fromAny({ subgraph: undefined }), [])
|
||||
).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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
|
||||
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(fromAny(undefined))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when all nodes are falsy', () => {
|
||||
expect(getBoundaryNodes(fromAny([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(fromAny([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(fromAny(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(fromAny(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)
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
|
||||
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(fromAny(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(
|
||||
fromAny<TestSlot[], undefined>(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]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -3069,6 +3069,7 @@
|
||||
"enterBuilderMode": "Build app",
|
||||
"editBuilderMode": "Edit app",
|
||||
"workflowActions": "Workflow actions",
|
||||
"activeModeWorkflowActions": "{mode} mode, workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
@@ -3690,13 +3691,13 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"generating": "Generating…",
|
||||
"stopGeneration": "Stop generation",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
@@ -3705,7 +3706,6 @@
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
@@ -3724,7 +3724,10 @@
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"buildAnApp": "Build an app",
|
||||
"create": "Create",
|
||||
"createApp": "Create app"
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
@@ -3767,6 +3770,7 @@
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
|
||||
@@ -25,6 +25,6 @@ function handleClose() {
|
||||
}
|
||||
|
||||
function handleSubscribe() {
|
||||
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleSubscribe = () => {
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog({ reason: 'subscribe_now_button' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ export function useSubscriptionActions() {
|
||||
})
|
||||
|
||||
const handleAddApiCredits = () => {
|
||||
telemetry?.trackAddApiCreditButtonClicked({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||